Backend API Surface
The backend is a single Elysia application mounted with prefix: '/v1' (backend/src/index.ts:66). Every route group is a plugin .use()d onto that one app, so the /v1 in a path comes from the mount, not from the plugin — a group that declares no prefix of its own lands directly under /v1.
This page is the inventory that the mount chain does not give you at a glance: for each prefix, who owns it, how a caller authenticates, which rate-limit tier it consumes, and whether it bypasses the minimum-app-version gate. Those last two are invisible in the route files themselves — the tier is threaded in from backend/src/index.ts:90-198, and the exemption lives in a list in a middleware — yet both are decisions you have to make every time you add a route.
Global middleware, in order
The order in backend/src/index.ts:112-205 is load-bearing:
createCorsMiddleware— first, so even short-circuited responses carryAccess-Control-Allow-Origin.createLoggerMiddlewareandcreateHttpLoggingMiddleware— so a rejected request is still access-logged.createAppVersionMiddleware— the 426 gate, after CORS and logging for the two reasons above.createErrorHandlingMiddleware— the rootonError. Elysia’s plugin isolation keeps it from seeing errors thrown inside a plugin, so it only covers routes defined directly on the main app; that is why nearly every route module installs its own.onError(safeErrorHandler)(backend/src/middleware/error-handling.ts:107-111).- The Better Auth plugin, then the route groups.
Better Auth is mounted as a catch-all plugin.all('/*', …) (backend/src/auth/elysia-plugin.ts:55) rather than with Elysia’s mount(), because mount() short-circuits the pipeline before onBeforeHandle and would silently bypass the IP rate limit wrapped around it. Better Auth’s own basePath: '/v1/api/auth' (backend/src/auth/auth.ts:156) does the filtering; more specific routes registered later still win, so /v1/config resolves to the config route and only unclaimed /v1/... paths reach the auth handler.
The inventory
Auth modes are described in the next section. “Gate” is whether the path sits under a prefix in appVersionExemptPrefixes (backend/src/middleware/app-version.ts:16-25) — exempt means the route is reachable without an X-App-Version header even when MIN_APP_VERSION is set.
| Path | Module | Auth | Rate limit | Gate |
|---|---|---|---|---|
ALL /v1/* (Better Auth: /v1/api/auth/*) |
auth/elysia-plugin.ts, auth/auth.ts |
the flow’s own | auth (IP) |
gated* |
GET /v1/api/auth/sso/desktop-initiate, /desktop-callback |
auth/sso-desktop-callback.ts |
browser redirect | — | exempt |
GET /v1/health |
api/routes.ts |
public | — | exempt |
GET /v1/health/{database,powersync,email,models} |
api/health.ts |
MONITORING_TOKEN |
— | exempt |
GET /v1/config |
api/config.ts |
public | — | exempt |
POST /v1/waitlist/join |
waitlist/routes.ts |
public | auth (IP) + 15s per-email cooldown |
gated |
GET /v1/posthog/config, ALL /v1/posthog/* |
posthog/routes.ts |
public | — | exempt |
GET /v1/auth/oidc/config |
auth/oidc.ts |
public | — | gated |
GET /v1/auth/{google,microsoft}/config, POST …/exchange, POST …/refresh |
auth/google.ts, auth/microsoft.ts |
session | — | gated |
GET /v1/locations, GET /v1/locations/:id |
api/routes.ts |
session | — | gated |
POST /v1/chat/completions, POST /v1/chat/v1/messages |
inference/routes.ts |
session, CLI-device-checked | inference |
gated |
POST /v1/inference-usage/receipts |
inference/usage-receipt-routes.ts |
session, web-only by default | receipt |
gated |
ALL /v1/tinfoil/* |
tinfoil/routes.ts |
session, web-only by default | pro |
gated |
ALL /v1/proxy |
proxy/routes.ts |
session | pro |
gated |
WS /v1/proxy/ws |
proxy/ws.ts |
subprotocol bearer | pro mounted, but not enforcing† |
exempt |
GET /v1/search |
api/search.ts |
session | pro |
gated |
POST /v1/preview |
api/preview.ts |
session | pro |
gated |
POST /v1/pro/fetch-content |
pro/routes.ts, pro/exa.ts |
session | pro |
gated |
POST /v1/debug-transcripts |
api/debug-transcripts.ts |
session | debug-transcript |
gated |
POST /v1/debug-transcripts/intake |
api/debug-transcripts-intake.ts |
hashed client key | debug-transcript-intake (IP and per-client) |
exempt |
GET /v1/powersync/token |
api/powersync.ts |
session, or signed bearer with no session | — | gated |
PUT /v1/powersync/upload |
api/powersync.ts |
session, non-anonymous | — | gated |
POST/GET/DELETE /v1/devices/*, GET /v1/encryption/canary |
api/encryption.ts |
session | — | gated |
PUT /v1/account/devices/cli, POST /v1/account/devices/cli/logout |
api/account.ts |
signed bearer, verified inline | — | gated |
POST /v1/account/devices/:id/revoke, DELETE /v1/account |
api/account.ts |
session (+ canary under E2EE) | — | gated |
GET /v1/agents |
agents/routes.ts |
session, non-anonymous | — | gated |
GET /v1/haystack/files/:fileId |
haystack/routes.ts |
session, non-anonymous | — | gated |
WS /v1/haystack/ws |
haystack/routes.ts |
subprotocol bearer | — | gated |
GET /v1/swagger, /v1/swagger/json |
@elysiajs/swagger, SWAGGER_ENABLED |
public | — | gated |
* Two subtrees of the Better Auth surface are exempt: /v1/api/auth/sso (browser redirects) and /v1/api/auth/device (the headless CLI device grant). The rest of /v1/api/auth/* is gated.
† The pro limiter is mounted on the WebSocket plugin, but it keys on the user the auth macro resolves and the bearer is authorized inside open() instead, so the handshake itself is not user-limited. rate-limiting.md spells this out.
Several groups are conditional and simply do not exist in some deployments: PowerSync routes return an empty plugin without POWERSYNC_JWT_SECRET, the SSO desktop callbacks mount only when AUTH_MODE is not consumer, GET /v1/auth/oidc/config only under AUTH_MODE=oidc with an OIDC_ISSUER, the transcript intake only under DEBUG_TRANSCRIPT_INTAKE_ENABLED, and the transcript relay degrades to a 403 stub when no upstream (DEBUG_TRANSCRIPT_UPSTREAM_URL) is configured. PUT /v1/account/devices/cli stays mounted but answers 404 unless CLI_DEVICE_REGISTRATION_ENABLED is set.
The table above was derived from Elysia’s own router — app.routes on an app built by createApp() lists every mounted method and path, which is the fastest way to re-verify it after a change (see the pattern in backend/src/index.test.ts).
Prefix quirks worth knowing
- The E2EE device routes are owned by
api/encryption.tsbut live at/v1/devices/*, while the CLI/revocation device routes are owned byapi/account.tsat/v1/account/devices/*. Two modules, two prefixes, one word. The eleven encryption endpoints are enumerated with their canary requirements in e2e-encryption.md. - The Anthropic-shaped managed endpoint really is
/v1/chat/v1/messages— the inner/v1is part of the Messages API shape, not a mistake. GET /v1/health(public liveness,api/routes.ts) and/v1/health/*(token-gated probes,api/health.ts) are different modules sharing a prefix.- Managed inference lives under
/v1/chat, not/v1/inference— the only/v1/inference-*path is the usage receipt endpoint. There is no MCP-specific route either: remote MCP traffic rides the universal proxy like any other upstream (see mcp-connections.md).
Auth modes
The session layer itself — which flows mint a session, how SSO and the CLI device grant differ, why bearer tokens must be signed — is covered in backend/docs/authentication.md, and PAT creation and revocation in pat-lifecycle.md. What follows is only how a route consumes a credential.
Session (auth: true). The common case. createAuthMacro resolves a Better Auth session and 401s when there is none, putting a typed user/session on the context (backend/src/auth/elysia-plugin.ts). A session can come from a cookie, a signed bearer token, or — because the API-key plugin runs with enableSessionForAPIKeys — an x-api-key personal access token.
Session via .derive. api/powersync.ts, agents/routes.ts and haystack/routes.ts call auth.api.getSession in a .derive and branch themselves, because they need to distinguish “no session” from “anonymous session” and answer 401 vs 403 with a machine-readable code. Anonymous users are rejected from sync, agent discovery and Haystack files.
Signed bearer verified inline. GET /v1/powersync/token accepts a bearer with no session (credential refresh) and the CLI device routes verify the HMAC themselves via verifySignedBearerToken (backend/src/auth/bearer-token.ts). The bearer plugin runs with requireSignature: true, so any path that reads a raw Authorization header must re-verify the signature or it becomes a way to bypass it.
WebSocket subprotocol bearer. Browsers cannot set headers on new WebSocket(), so /v1/proxy/ws and /v1/haystack/ws carry the credential in a thunderbolt.bearer.<token> Sec-WebSocket-Protocol entry, validated in open() — not beforeHandle, which Bun’s adapter may invoke more than once per upgrade. The bearer entry is deliberately not echoed back, so it never lands on WebSocket.protocol or in response logs.
Operator token. The deep health probes compare Authorization against MONITORING_TOKEN with timingSafeEqual, answering 403 when the token is unset and 401 when it does not match (backend/src/api/health.ts:47-55). See self-hosting configuration for the env var.
Server-to-server client key. The transcript intake is authenticated by a per-client key, hashed and looked up in the database — never by a user session (backend/src/api/debug-transcripts-intake.ts).
Two extra guards layer on top of a session on the metered routes: rejectPersonalAccessToken returns 403 WEB_LOGIN_REQUIRED for x-api-key callers on the confidential routes unless CONFIDENTIAL_API_KEYS_ENABLED is set (the rationale is in backend/src/inference/web-session.ts), and rejectUnregisteredCliDevice returns 409 CLI_DEVICE_NOT_BOUND when a device-grant session is not bound to a live CLI device.
Rate-limit tiers
Limits are hardcoded per tier in backend/src/middleware/rate-limit.ts:33-40 and persisted through rate-limiter-flexible’s Drizzle store (backend/src/db/rate-limit-schema.ts), so they hold across instances:
| Tier | Budget | Key |
|---|---|---|
inference |
60 / minute | user:<id> |
receipt |
100 / minute | user:<id> |
pro |
100 / minute | user:<id> |
auth |
10 / minute | ip:<addr> |
debug-transcript |
10 / hour | user:<id> |
debug-transcript-intake |
600 / hour | ip:<addr>, and client:<id> |
Three properties will bite you:
- User-keyed limiters must be
.use()d inside theguard({ auth: true }, …)callback. They read theuserthe auth macro resolved; registered at app level the macro’s resolve runs afteronBeforeHandleand the limit becomes a silent no-op. Every call site follows this shape — copy it. - IP-keyed limiters fail closed. An unresolvable client IP shares one
ip:unknownbucket rather than skipping the check, so a control guarding OTP send and waitlist join cannot disable itself. The real client IP comes fromextractClientIpand only trusts forwarding headers whenTRUSTED_PROXYis configured. RATE_LIMIT_ENABLED=falsereturns an empty plugin, so the limiter disappears entirely rather than being bypassed per request.
Every limited response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 adds Retry-After. backend/docs/rate-limiting.md goes deeper on the mechanism and how to add a tier.
Version gate and CORS
Both are explained in depth in AGENTS.md — “App version gate” and “CORS and API headers”. The short version for route authors:
- The gate is fail-closed: a missing or unparseable
X-App-Versionis rejected on every non-exempt/v1path onceMIN_APP_VERSIONis set.OPTIONSpreflights are always exempt, and prefix matching is on segment boundaries so/v1/configcannot exempt a future/v1/configuration. - Anything reached by a browser redirect, a WebSocket upgrade, a header-less SDK (posthog-js) or a server-to-server caller needs a prefix in
appVersionExemptPrefixes. Note that/v1/haystack/wsis not in that list today even though the managed-ACP client opens it as a native browserWebSocket(src/acp/transports/index.ts), which cannot attach the header — check this before enabling the gate. - Request headers need no CORS change: both the main mount and the PostHog route use
allowedHeaders: trueand echo whatever the browser asks for. Response headers are the opposite — a browser can only read whatcorsExposeHeaderslists (backend/src/config/settings.ts:11), so a new protocol header on a response is invisible cross-origin until you add it there.
Adding a route
- Pick the prefix. Extend an existing group if the resource belongs to it; a new group is a new plugin
.use()d inbackend/src/index.ts. Give it its ownprefix:unless you genuinely want to sit at the root of/v1. - Install an error handler.
.onError(safeErrorHandler)on the plugin — the root middleware will not cover it. - Choose the auth mode from the list above. Prefer
guard({ auth: true }, …); reach for a.deriveonly when you need to answer differently for anonymous sessions. Decide explicitly whether anonymous users andx-api-keycallers are allowed. - Choose a rate-limit tier. Reuse an existing one where it fits; a new tier means a new entry in
tierConfigsand acreateUserTierRateLimit/createIpTierRateLimitcall threaded fromcreateApp. Mount user-keyed limiters inside the guard. - Decide the version-gate exemption. If any caller cannot send
X-App-Version, add the prefix toappVersionExemptPrefixes; otherwise leave it gated. - Check response headers. If cross-origin JavaScript must read a header you set, add it to
corsExposeHeaders. - Update this page, and re-derive the table from
app.routesrather than by hand.
Related
- E2E Encryption — what the
/v1/devices/*and/v1/encryption/*routes actually do. - PowerSync, Accounts and Devices — token issuance, device identity, the
x-device-idcontract. - Delete Account and Revoke Device — the hard-delete paths under
/v1/account. - Self-hosting configuration — every env var named above.