apps/backend instead of adding more long-lived apps/web route handlers. See
platform/architecture/tanstack-rust-migration for the route manifest, OpenAPI,
Docker, E2E, and benchmark gates.
Route Organization
Directory Structure
Upstream Proxy Routes
When anapps/web route proxies requests to an upstream service and needs
low-level Undici transport controls such as a custom dispatcher or larger
maxHeaderSize, prefer undici.request(...) over the global server fetch.
On Next.js 16, the wrapped route-handler fetch can reject custom Undici
dispatchers with UND_ERR_INVALID_ARG / invalid onRequestStart method. Keep
custom transport configuration on the direct Undici request instead of passing
it through fetch.
Authentication Wrappers
apps/web routes do not hand-roll try/catch + supabase.auth.getUser()
membership checks. Two wrappers in apps/web/src/lib centralize IP blocking,
pre-auth rate limiting, payload-size limits, suspension checks, and adaptive
abuse controls so handlers only contain business logic.
| Wrapper | Module | Auth source | Handler context |
|---|---|---|---|
withSessionAuth | @/lib/api-auth | Signed-in Supabase session (cookie/Bearer JWT, optional AI temp auth / app-session) | { user, supabase } |
withApiAuth | @/lib/api-middleware | Workspace API key (Authorization: Bearer ttr_...) | { context, params } where context is the WorkspaceContext |
TypedSupabaseClient already scoped to the authenticated user, so
RLS stays intact.
The olderauthorizeRequest/authorizehelpers in@/lib/api-authare@deprecated. PreferwithSessionAuthfor new routes.
Versioned Public API
Pattern: /api/v1/*
Use for product and public-facing APIs. For session-authenticated dashboard
surfaces, wrap the handler with withSessionAuth. The wrapper provides the
authenticated user and a request-scoped supabase client; the third handler
argument is the resolved route params.
When you do need a Supabase client outside a wrapper (Server Components, background jobs), remember the factory async-ness in@tuturuuu/supabase/next/server:createClient()andcreateDynamicClient()are async and must be awaited;createAdminClient()is synchronous (do notawaitit unless you pass it through another async helper).
Workspace-Scoped API
Pattern: /api/v1/workspaces/[wsId]/*
Use for workspace-scoped operations. Route params are a Promise; the wrapper
resolves them and passes them as the third handler argument. Authorize with the
workspace helpers from @tuturuuu/utils/workspace-helper:
normalizeWorkspaceId(wsId, supabase)— resolvespersonal/handle aliases to a concrete UUID.getPermissions({ wsId, user })— returns aPermissionsResultwithcontainsPermission(permissionId)/withoutPermission(permissionId). It returnsnullwhen the caller is not a member, which doubles as the membership check.
Real task-board routes (for exampleapps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts) keep handler logic in@tuturuuu/apis/tu-do/tasks/routeand only wire the wrapper in the route file. Extract shared handler logic the same way when a route grows past a simple CRUD shape. UsecontainsPermission()with a realPermissionId(such as'manage_projects'); there is nomanage_taskspermission and no@/lib/permissionsmodule.
AI Endpoints
Pattern: /api/ai/*
Use for AI-specific operations with model selection and token tracking.
The repo uses AI SDK v6 (ai ^6.x, @ai-sdk/google ^3.x). On v6,
streamText results expose result.toUIMessageStreamResponse() (the v4/v5
toDataStreamResponse() no longer exists), and onFinish usage is reported as
usage.inputTokens / usage.outputTokens (not promptTokens /
completionTokens). Wrap the route with withSessionAuth and pass
allowAiTempAuth if the endpoint must accept short-lived AI temp tokens.
usage.inputTokens ?? 0 and
usage.outputTokens ?? 0 — mirror that when adding new AI endpoints.
Authentication Endpoints
Pattern: /api/auth/*
Use edge runtime for auth endpoints.
Error Response Standards
Standard Error Format
HTTP Status Codes
200- Success201- Created204- No Content400- Bad Request (validation errors)401- Unauthorized (not authenticated)403- Forbidden (not authorized)404- Not Found409- Conflict422- Unprocessable Entity429- Too Many Requests500- Internal Server Error
Error Handling Pattern
API-Key Authenticated Routes
Pattern: withApiAuth
External SDK clients authenticate with a workspace API key
(Authorization: Bearer ttr_...) instead of a Supabase session. Wrap those
routes with withApiAuth from @/lib/api-middleware. It validates the key,
enforces IP blocks, applies pre-auth and adaptive rate limits, logs API key
usage, optionally checks workspace permissions, and passes the resolved
WorkspaceContext plus route params to the handler.
withSessionAuth:
- Permissions are declared in the wrapper
options.permissions(aPermissionId[]) and checked against the API key’s granted scopes viahasAnyPermission/hasAllPermissions. SetrequireAll: trueto require every listed permission. - The handler context is
{ context, params }(not{ user, supabase }); the API-key path does not hand you a session-scoped Supabase client. - Rate-limit defaults: GET/HEAD reads are open, mutations default to 100
req/min, and workspace-specific overrides from
workspace_secretstake precedence.
validateQueryParams(request, schema) and
validateRequestBody(request, schema, maxBytes) for Zod-validated, byte-size-capped
input handling.
CORS Configuration
Rate Limiting
For signed-in product APIs, preferwithSessionAuth(...) over manual
supabase.auth.getUser() calls. The wrapper resolves the session through local
JWT claims first and only falls back to getUser() when claims are unavailable,
which avoids exhausting Supabase Auth limits on request-heavy dashboard surfaces
such as task boards. Default GET/HEAD requests are not rate-limited by the
session wrapper; default mutations use the shared mutation budget unless the
route provides an explicit rateLimit option.
The API proxy still protects request-heavy dashboard reads before route auth is
validated. High-fanout task-board reads use a dedicated task-board-read proxy
bucket so normal board loading, per-list pagination, and focused revalidation do
not exhaust the generic anonymous read budget. The defaults are 600 requests per
minute, 12000 per hour, and 80000 per day, and can be tuned with
API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE,
API_PROXY_TASK_BOARD_READ_LIMIT_HOUR, and
API_PROXY_TASK_BOARD_READ_LIMIT_DAY.
Proxy-side 429 responses include support diagnostics when available:
X-RateLimit-Client-IP, X-RateLimit-User-Id, and
X-RateLimit-User-Email, plus the selected policy/window headers emitted by the
proxy guard. Exact server-verified browser sessions ending in @tuturuuu.com
are allowed through proxy-side rate-limit blocks for debugging with
X-RateLimit-Warning: staff-debug-bypass,
X-RateLimit-Debug-Bypass: tuturuuu-staff, and
X-RateLimit-Original-Status: 429. This bypass only applies after the server
revalidates the Supabase session, only to proxy guard rate-limit blocks, and does
not bypass malformed auth, permissions, payload-size checks, route-handler
errors, or non-staff/anonymous requests.
Finance invoice creation support reads use a separate
finance-invoice-create-read proxy bucket for the shared-IP burst created by
the new invoice flow. It covers GET/HEAD reads for customer search, products,
wallets, transaction categories, promotions, invoice default settings, linked
products, user group data, user promotion data, invoice history, and
subscription context. Invoice creation mutations remain on the default mutation
policy. The defaults are 600 requests per minute, 6000 per hour, and 40000 per
day, and can be tuned with
API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE,
API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_HOUR, and
API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_DAY.
Verified sessions and trusted read uplift
Proxy limits default to per-IP anonymous buckets because auth headers and cookies are forgeable at the edge — their presence alone must never raise a budget. To separate genuinely signed-in browser sessions and give legitimate teams (often many people behind one office NAT/VPN IP) higher read throughput, the proxy consults a server-written trust cache in Redis, keyed by the same subject keys as the abuse-reputation system (session:<hash>,
cidr:<network>, ip:<addr>):
- A genuinely trusted session (its key is a hash of the real auth cookie, so it cannot be forged) gets its own per-session read and mutation buckets, so signed-in teammates behind one shared IP no longer collide on the pre-auth proxy budget.
- A trusted location (office
cidr/ip, learned automatically from organic reputation or set explicitly via anabuse_trust_overridescidrentry in the abuse-intelligence admin API) uplifts the shared per-IP read limit for the whole team.
recordResponseAbuseSignal and direct session-auth helpers, and reconciled
every 10 minutes by the /api/cron/infrastructure/sync-trust-cache cron (which
also propagates admin trusted-location overrides). Set
API_PROXY_EDGE_TRUST_ENABLED=0 to disable the edge trust cache and fall back to
per-IP keying; tune the cache lifetime with EDGE_TRUST_CACHE_TTL_SECONDS.
Best Practices
✅ DO
-
Wrap handlers with the auth wrapper
-
Always validate input
-
Verify workspace permissions
❌ DON’T
-
Don’t expose sensitive errors
-
Don’t skip workspace isolation
-
Don’t reach for the admin client to skip authorization
Use the admin client only for trusted server-side work that has already performed its own authorization (e.g. inside
getPermissions), and remember it is synchronous.