Auth flow, public routes, and billing gating
Auth & Middleware
Surflink uses Supabase Auth with server-side sessions via @supabase/ssr, enforced in src/middleware.js. Native apps authenticate API calls with a bearer token instead of cookies.
Auth flow
- Coaches get in through
/request-access(invite-only; forwards to/signupwhile public signup is on) or a workspace invite (/org/invite/[token]). Athletes get in through a coach's invite (/invite/[token]); under-13s through the guardian page. - Sessions live in cookies and are refreshed by the middleware on each request; stale chunked auth cookies are cleaned up to avoid 431 errors.
- New coaches get a
coachesprofile and a workspace (as Owner) with no plan yet — they choose one next (no free trial). - Role routing after login: coach →
/dashboard, athlete →/athlete/dashboard— both are Today; a signed-in visitor on/goes there too (unless their workspace has no plan), and someone with no role sees "You're not in a club yet".
What the middleware does
- Refreshes the auth token and redirects signed-out visitors on protected pages to
/login; API requests get a JSON 401 instead of a redirect (so a native client's POST never turns into a 405). Any/api/*request carrying a bearer token is let through for the route to verify. - Sends signed-in users away from
/loginand/signup, and users with an unverified email to/verify-email. - The paywall: every page and every API request — cookie or bearer, coach or athlete — is checked against the caller's plan (
src/lib/planAccess.js). Without a live subscription (active,past_due, or a pre-existingtrialingwith time left) a coach's pages redirect to/pricing(with areason; no workspace at all →reason=no_workspace), an athlete's to/plan-inactive, and API requests get 402{ code: 'plan_required' }. Super admins are never gated. Billing, sign-in, invites, guardians, account deletion, privacy, export and health stay open. An allowed decision is cached for five minutes in an HMAC-signed cookie (one minute in memory for bearer callers). - Skips
/api/video/streamentirely — it is protected by HMAC tokens instead.
Public routes (no auth)
Pages: /, /login, /signup, /request-access, /pricing, /privacy, /terms, /forgot-password, /reset-password, /verify-email, /auth/callback, /invite, /org/invite, /docs, /guides (the invited-coach PDF), /tv (the TV sign-in approve page).
APIs: /api/auth/frameio, /api/auth/frameio/callback, /api/auth/signup, /api/signup-mode, /api/mobile/billing/plans (the plan catalogue — the app's sign-up reads it before an account exists), /api/waitlist, /api/orgs/invite, /api/stripe/webhook, /api/cron, /api/health, /api/time, /api/invite/claim, /api/invite/guardian, /api/invite/lookup, /api/tv/signin/start, /api/tv/signin/poll, /api/notifications/send, /api/notifications/deliver (the last two self-authorize with the service-role key).
Open without a plan
The paywall (src/lib/planAccess.js) decides every other page and API request. These stay reachable for a club with no plan: the public pages, /pricing, /billing, /settings, the auth pages, /invite, /org/invite, /docs, /guides, /tv, /admin, /plan-inactive (an athlete's notice) and /guardian-pending (the guardian wait); and the APIs for signing in and up, /api/mobile/me, billing (/api/billing/**, /api/mobile/billing/**, /api/stripe/webhook), account deletion, privacy and export, invites and guardians, the parent view, notification settings and push tokens, the super admin console, health, time, crons and the signed video stream. Everything else answers 402 { code: 'plan_required' } (a page: a coach to /pricing, an athlete to /plan-inactive).
Supabase clients
| File | Usage |
|---|---|
src/lib/supabase-browser.js | Client-side, anon key, RLS-scoped |
src/lib/supabase-server.js | Server-side, cookie-bound SSR client |
src/lib/supabase-admin.js | Service-role client (server only) |
Stream URL signing
Video access is gated by HMAC-signed, time-limited tokens. Clients call GET /api/video/sign (authenticated and access-checked against the clip) and play the returned URL through /api/video/stream. Tokens live one hour.