/v1 lanes are registered in buildV1Router in apps/core-api/src/modules/index.ts. Three more surfaces are mounted directly in app.ts.
Summary
Registration order
The order inbuildV1Router is deliberate:
- Webhooks.
- Service signed routes (
/internal,/admin/default-forms,/admin/notification-configs). - Trainee routers. The specific sub paths (
/trainee/notification-configs,/trainee/forms,/trainee/uploads, and so on) are mounted before the catch-all/traineerouter. /partnerand/automation./public/sign,/public/onboarding,/public.- Every
/web/*module. router.use(replay).- The three bearer routers.
requireServiceAuth calls nonceStore.consume). Putting those lanes first means a signed request is never asked for a second nonce.
Operational
/healthz always answers 200 with { "ok": true }. /readyz runs the readiness checks. /metrics serves the Prometheus default metrics from prom-client. None of them is authenticated or rate limited, and the HTTP logger skips them. See Observability.
Auth and billing tier (/api)
app.use('/api', honoGateway) forwards the request to the Hono app exported by @repo/api. That app serves:
This tier has its own CORS policy (origins from
getTrustedOrigins, credentials allowed) and is not behind the /v1 rate limiter. See Authentication and the api package.
Hosted MCP (/mcp)
POST /mcp is a stateless Streamable HTTP MCP endpoint. It requires Authorization: Bearer with a key that starts with pf_live_, otherwise it answers 401 with a WWW-Authenticate header and a JSON-RPC error. Each tool call is executed by calling /v1/automation on 127.0.0.1 with the same key, so the automation lane does the real key check. GET /mcp answers 405. See MCP.
Webhook lane
Two routes receive calls from SmartSend:
SmartSend cannot sign its callbacks, so both read the
token query parameter and call ctx.smartsend.verifyWebhookToken(token). That function does a timing safe compare against SMARTSEND_WEBHOOK_SECRET and returns false when the secret is empty. A bad token throws UNAUTHORIZED.
Internal service lane
req.auth is not set. internalRouter exposes POST /studios and POST /coaches for provisioning. @repo/auth calls /v1/internal/coaches from its provisionCoach helper when an invitation is accepted or a member’s role changes.
The signing contract is on the Service auth page.
Web gateway lane
SERVICE_AUTH_SECRET.
webUserContext sets req.auth = { studioId, userId, role }. Note that studioId here is the backend Studio.id, not the organization id from the header. The middleware also provisions on first contact. See Middleware.
Modules mounted on this lane: crm, dashboard, monitoring, studios/current/api-keys, studios, onboarding, billing, autofit-import, notification-configs, studio/notification-settings, clients, coaches, products, programs, program-templates, program-folders, exercises, foods, content, home-banner, forms, tracking, technique-videos, inbox, task-automations, assistant, checkins, plan-import, forms-ai, automation, calendar, audit, whatsapp.
/web/studios/current/api-keys is mounted before /web/studios so the more specific router wins.Bearer lane
authenticate(sessionVerifier) turns the token into a principal. The verifier is chosen once at router build time:
SESSION_VERIFY_INPROCESSfalse:createSessionVerifier(BETTER_AUTH_URL)doesGET <BETTER_AUTH_URL>/api/auth/get-sessionwith the bearer header.SESSION_VERIFY_INPROCESStrue:createInProcessSessionVerifier(ctx.auth)callsauth.api.getSessiondirectly.
principalFromSession in modules/auth/session-verifier.ts:
A session without a user or an active organization is rejected as
UNAUTHORIZED.
Trainee lane
The trainee mobile app signs in with a phone number and a WhatsApp code, then sends a self signed trainee token asAuthorization: Bearer. It never uses better-auth.
The principal is
req.trainee = { clientId, studioId, preview? }. Routes that stay open to a frozen trainee are the ones registered with auth only: /me, /app-release, DELETE /me, the push token routes and auth/switch-studio. Full detail is on Trainee sessions.
The trainee lane sends no replay headers and no signature. It is rate limited per IP by the /v1 limiter.
Partner lane
/v1/partner/* is a read mostly API used by SmartSend. Its router starts with router.use(requirePartnerKey(...)).
The caller sends Authorization: Bearer pf_live_.... requirePartnerKey in modules/partner/partner-auth.ts:
- Rejects a missing header or a value without the
pf_live_prefix withUNAUTHORIZED. - Hashes the key with SHA-256 and looks the hash up (
apiKeyByHash). Keys are never stored in clear. - Rejects when no row matches, when
revokedAtis set, or when the key’s studio hasdeletedAtset. - Stamps
lastUsedAtat most once every 5 minutes, fire and forget. - Sets
req.partner = { studioId, apiKeyId }.
partnerOf(req). Keys are created and revoked from the web lane at /v1/web/studios/current/api-keys, which is gated to OWNER and HEAD_COACH.
Responses use the standard envelope. The comment in the automation router calls this lane a frozen wire contract with SmartSend, so do not change its shapes.
Automation API lane
/v1/automation/* uses the same requirePartnerKey guard and the same keys, but has its own response envelope and its own error middleware (automationErrorMiddleware), registered on the router. It is the lane for Make.com and for the MCP tools.
Public lane
The signing lane has a special rule. The trainee’s browser never calls the API directly. The web app proxies the request, so every signing request arrives from one address. To keep the proxy from exhausting the per IP limiter:
app.tsskips the global limiter when the path is under/v1/public/signand the request carriesx-service-id.- The signing router then requires a valid service signature for any request that carries
x-service-id. A request that claims to be the proxy but is not signed is refused. - A direct caller without
x-service-idis not signed and is counted by the global limiter under its own address. - In both cases the lane applies its own Redis throttle per link and per visitor IP (
form-sign.throttle.ts).
{ error, code }, with Hebrew messages, and sets Cache-Control: no-store and X-Robots-Tag: noindex.