Route handlers live in frontend/apps/saas/app/api. Most of the app uses server actions, so each handler here exists for a specific reason: the browser needs a streamed body, a cancellable GET, a public endpoint or a transparent proxy.

Summary

The catch-all proxy

app/api/[[...rest]]/route.ts forwards anything not matched by a more specific handler to the core API with proxyToCoreApi. This is how /api/auth/* (better-auth) and /api/rpc/* (oRPC) work from the browser. The details are in Talking to the core API.

Chunked media upload relay

app/api/content/media-upload/[action]/route.ts accepts POST for four actions: start, part, complete and abort. Any other action returns 404. The media bucket is shared with other products and its CORS policy cannot be changed, so the browser cannot upload to storage directly. Instead it slices the file and posts each part to this route, and the API streams the part into an S3 multipart upload. Limits and checks:
  • The studio comes from ?slug= through resolveStudioContext. No context returns 401. This is what stops the route being an open relay.
  • JSON bodies are capped at 128 KiB (MAX_JSON_BYTES).
  • A part is capped at 16 MiB (MAX_PART_BYTES). The client sends 8 MiB parts. An empty part returns 400, an oversized one 413.
  • The parts list in complete is validated entry by entry: partNumber must be an integer of at least 1 and etag a non empty string.
  • A CoreApiError is relayed as its status and code only. Other failures return 502.
The part action forwards the body as raw bytes. Parsing or base64 encoding it would corrupt the part.

PDF relays for a studio

modules/shared/lib/studio-pdf-relay.ts exports relayStudioPdf(request, id, pathOf, disposition?). The media CDN answers without CORS headers, so pdf.js cannot read a stored PDF by URL, and a link with download to another origin only opens a tab. Streaming through the web origin solves both.
  • id must match /^[A-Za-z0-9_-]{1,64}$/, otherwise 404.
  • ?slug= selects the studio. No context returns 401.
  • The upstream body is streamed back with content-type: application/pdf, cache-control: private, no-store and x-content-type-options: nosniff.
  • A refusal is relayed by status and code only.

Public signing relays

Trainees open a signing link from a message, usually on a phone and signed out. The token in the path is the only credential. modules/sign/sign-upstream.ts holds the shared code.
  • isSignToken(token) checks the token against SIGN_TOKEN_PATTERN (16 to 64 characters of letters, digits, _ and -) before anything is sent upstream. A bad token returns 404.
  • signUpstream(token, init) makes a signed call to /v1/public/sign/:token and forwards the visitor’s address and browser as x-client-ip and user-agent.
  • clientIpOf(headers) prefers cf-connecting-ip, then x-real-ip, then the last element of x-forwarded-for. The leftmost x-forwarded-for entry is client supplied and is never trusted.
  • Responses carry cache-control: no-store and x-robots-tag: noindex.
The page itself, (sign)/sign/[token]/page.tsx, loads the first view on the server with loadSigning(token), which is wrapped in React cache() so generateMetadata and the page body share one call.

Public passthroughs

These have no session and forward to public core API endpoints with a plain fetch.

/api/onboarding/*

Used by SignupPopup.tsx in the /start wizard to verify a phone number and an email before an account exists. Each handler:
  • Returns 503 when CORE_API_URL is not set.
  • Rejects bodies over 2,048 bytes with 413.
  • Forwards the caller’s address as x-client-ip (same header order as the signing relay), so the upstream per IP throttle does not see every visitor as the web server.
  • Returns the upstream status and body, or 502 on a network error.

/api/waitlist

The marketing landing is served on the app’s origin, so its waitlist form posts here and the handler forwards to /v1/public/waitlist-leads. No CORS is involved. Bodies over 4,096 bytes return 413, invalid JSON returns 400.

Work board reads

The task panel on the work board loads a lot of trainee data as the coach moves between tasks. These reads are GET route handlers instead of server actions, for two reasons stated in workboard/panel-fetch.ts:
  • Next.js runs a page’s server actions one at a time. A queued board read was holding up the next task’s context, and even “mark done”.
  • A fetch takes an AbortSignal, so a stale read is cancelled when the selection moves on.
workboard/panel-route.ts holds the shared server side:
It resolves the studio from the slug (401 when that fails), runs the loader, and returns JSON with cache-control: no-store. A CoreApiError becomes its status and code, anything else 502. Upstream error text never crosses over. The loaders are the same functions the server actions export, so access rules are identical. On the browser side, panelReadUrl(slug, segments, query) builds the URL with every segment encoded, and the request functions throw a PanelReadError carrying the status.

Writing a new handler

Prefer a server action. Add a route handler only when one of these applies:
  • The response is a stream or a file.
  • The request body is binary and large.
  • The read must run in parallel with other reads, or be cancellable.
  • The caller has no session (public link, marketing form).
Then follow the existing handlers:
  1. Resolve the studio from the session with resolveStudioContext(slug) and return 401 when it is null.
  2. Validate ids and sizes before calling upstream.
  3. Relay only the status and the error code. Do not forward upstream error text.
  4. Set cache-control: no-store on anything private.