Platform v2 — Specification
Status: scope reduced per ADR-017. Phase 1 (framework + 10 reference routes + dep + ESM substrate) complete on branch ralph/platform-v2; bulk migration cancelled. The architectural commitments below remain authoritative for v2 routes only. v1 patterns (ADR-009, ADR-011, ADR-014) remain canonical for the ~300 routes that haven't been ported and are not slated for migration. Coexistence with v1 is permanent. Implementation tracked in plan.md.
Authoritative for the HTTP framework, response/error contracts, frontend client surface, and bundled platform changes (module system, major dep versions, lint rules) of v2 routes. Business rules live in docs/business/<domain>/. Domain services live in apps/api/src/services/. Schema lives in apps/api/prisma/schema.prisma. When in conflict: this spec wins for the HTTP/framework layer of v2 routes; business/ wins for domain rules.
Architectural commitments
-
Routes are declarations. A route is a typed value built with
defineRoute({ ... }). The framework compiles declarations to runtime handlers. Authors never seeRequest/Responseoutside the framework directory. -
Naked data on the wire. Successful responses carry the entity (or list+pagination) directly in the body. No envelope. HTTP status code is the discriminator.
-
RFC 9457 for errors. Error responses use
application/problem+jsonwith{ type, title, status, detail, instance, ... }. Thetypefield is the canonical machine-readable error identifier. -
Cursor pagination, single kind. All paginated lists use cursor pagination. Cursors are HMAC-signed. Routes that need to display total count opt in via
withTotal: true. -
Auth as composable predicates. No role enums in route declarations. Predicates compose with
and/or/not. Predicates that load entities cache them on the request — handlers reuse, no double-querying. -
Errors thrown, not returned. Routes throw
RouteError.<kind>(...). Framework catches and serializes. Unknown exceptions become 500 + Sentry. -
Portable runtime.
defineRoutereturns aRouteSpecdecoupled from Next.js types. Ships with two adapters: Next adapter for production, test adapter for in-process invocation (no Next, no HTTP, sub-millisecond per test). Portability is proven by the test adapter's existence. -
Schemas are the single source of truth. Zod schemas define input + output. TypeScript types, frontend client, and test fixtures all derive from them.
The defineRoute API
defineRoute({
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
auth: AuthPredicate,
input?: {
params?: ZodSchema
query?: ZodSchema
body?: ZodSchema
headers?: ZodSchema
},
output: OutputKind,
side_effects?: 'reads' | 'writes' | 'none', // default 'reads'
audit?: AuditConfig, // required when auth is public/cron/webhook
rateLimit?: RateLimitConfig,
idempotency?: IdempotencyConfig,
csrf?: 'required' | 'none', // default 'required' for POST/PATCH/DELETE
handler: (ctx: RouteContext) => Promise<HandlerReturn>,
})
Handler context
interface RouteContext {
input: ValidatedInput // typed from input schemas
auth: ResolvedAuthContext // { userId, claims, scopes, entities }
db: PrismaTransactionClient // transaction-scoped if 'writes'; non-tx if 'reads'; absent if 'none'
logger: Logger // request-scoped, JSON via console.log
trace: TraceBuilder // wraps Sentry spans
env: RuntimeEnv // { isVercelPreview, isDev, ... }
}
Headers are accessed via input.headers (declared per-route, Zod-validated). Domain services are imported directly:
import { UserService } from '@/services/user'
handler: async ({ input, db }) => {
return await UserService.approve({ userId: input.params.id, tx: db, ... })
}
Test mocking uses vi.mock('@/services/user', ...).
Service return shapes are standardized. Services that return paginated results return { items: T[], nextCursor: string | null }. Routes pass this through list() which derives hasMore and (optionally) appends total. Single-entity services return the entity directly. No { users, page, limit } / { resources, totalItems } / per-domain variants — one shape across all services.
Services throw DomainError (renamed from v1's AppError) with a typed kind field. Route handlers' default catch maps DomainError.kind → RouteError via a registry. Services don't import framework code; the framework translates domain errors at the route boundary.
AppError bridge in the auth path. v1's AppError from @mg/shared-types is still alive at one site: apps/api/src/lib/auth/predicates/user.ts catches AppError thrown by Supabase auth helpers (which haven't been migrated to DomainError) and translates to RouteError.unauthenticated. This is a deliberate v1↔v2 bridge, not drift — the rename is "services-side." Future agents should not introduce new AppError imports outside this bridge.
Output kinds
ok<T>(schema) // 200 OK, body = T
created<T>(schema) // 201 Created, body = T, Location: header
list<T>(itemSchema, opts: ListOptions) // 200 OK, body = { data: T[], pagination }
empty() // 204 No Content
redirect({ to: (ctx) => string | URL; sameOriginOnly: boolean; status?: 302 | 303 | 307 })
file({ contentType: string; filename: string }) // download with Content-Disposition: attachment
stream({ contentType: string }) // SSE / streaming body
twiml() // text/xml — Twilio responses
interface ListOptions {
defaultLimit?: number // default 20
maxLimit?: number // default 100
withTotal?: boolean // include total count on first page; default false
}
New content-type needs add a new named kind. No generic raw escape.
redirect with sameOriginOnly: true rejects redirects to external URLs. Set false only with a documented reason in the route file.
Allowed origins are sourced from the ALLOWED_ORIGINS env var (comma-separated). The same allowlist is used by CORS. sameOriginOnly: true accepts:
- Relative paths (
/dashboard,/users/abc?tab=settings) - Any URL whose origin matches an entry in
ALLOWED_ORIGINS - Vercel preview origins (
mg-(web|api)-*-metrognome.vercel.app) whenVERCEL_ENV=preview http://localhost:*whenNODE_ENV !== 'production'
Everything else → RouteError.forbidden({ detail: 'Redirect target not allowed' }).
Auth predicates
requires.public()
requires.user.authenticated()
requires.user.admin()
requires.user.staff()
requires.user.staffAt((ctx) => string)
requires.user.role(role, opts?: { atLocation?, atOrg? })
requires.user.owns(resource, idFn, opts?: { includeDeleted?: false })
requires.user.belongsToOrgOf(resource, idFn)
requires.cron()
requires.webhook.stripe()
requires.webhook.slack()
requires.webhook.twilio()
requires.webhook.supabase()
requires.all(...predicates)
requires.any(...predicates)
requires.not(predicate)
Resource-loading predicates filter out soft-deleted records by default. The framework manages this directly via explicit deletedAt: null clauses in predicate queries — no Prisma extension dependency. includeDeleted: true opt-in omits the filter. This keeps soft-delete logic in framework code rather than depending on a third-party extension.
Each webhook.*() predicate wraps the appropriate signature verification:
requires.webhook.stripe()— wrapsstripe.webhooks.constructEvent(rawBody, signature, secret)from the official Stripe SDK. Populatesctx.auth.webhook.eventas a typedStripe.Event.requires.webhook.slack()— wrapsverifySlackSignature()helper atapps/api/src/lib/slack.ts(HMAC-SHA256 ofv0:<timestamp>:<rawBody>).requires.webhook.twilio()— wrapsvalidateRequest()from the officialtwiliopackage (HMAC-SHA1 of URL + sorted POST fields).requires.webhook.supabase()— checksx-webhook-signatureheader againstSUPABASE_WEBHOOK_SECRET.
The unified surface is the predicate API; the internals delegate to provider SDKs where they exist (Stripe, Twilio) and to hand-rolled HMAC where the provider doesn't ship a verifier (Slack, Supabase).
Impersonation. Authenticated predicates honor an x-impersonate header carrying an ImpersonationSession token. When valid, framework loads the target user's claims into ctx.auth.userId; the original admin's ID surfaces as ctx.auth.impersonatorId. Audit log rows attribute to both. Invalid or expired tokens are silently ignored (request proceeds as the original user).
Test mode. No API_TEST_MODE env var or x-test-user-id request headers — those are deleted. The test adapter accepts a typed auth: TestAuthContext parameter (testUser(), testStaff(), testAdmin(), testImpersonator({ admin, target })) and constructs the same ResolvedAuthContext production predicates would produce. Production code paths never check for "is this a test request?" — the concept doesn't exist outside the test adapter.
Permissions
High-level "can-do-X" functions in apps/api/src/permissions/. Routes use these by name:
permissions.canEditLocation(idFn)
permissions.canApproveUsers()
permissions.canViewReservation(idFn)
Permissions compose predicates. Testable, named, reusable. Permissions are the abstraction routes use; predicates are the building blocks.
Error model
throw RouteError.notFound({ detail?: string })
throw RouteError.unauthenticated({ detail?: string })
throw RouteError.forbidden({ detail?: string })
throw RouteError.validation(issues: ZodIssue[])
throw RouteError.conflict({ detail?: string; issues?: ZodIssue[] })
throw RouteError.unprocessable({ detail?: string })
throw RouteError.businessRule(kind: BusinessRuleKind, { detail?: string; extra?: object })
throw RouteError.rateLimited({ retryAfter: number })
throw RouteError.maintenance({ retryAfter: number })
throw RouteError.paymentRequired({ detail?: string })
throw RouteError.externalUnavailable({ service: ExternalService; detail?: string })
throw RouteError.internal({ detail?: string; cause?: Error })
Type URIs are stable:
https://api.metrognome.com/errors/not-found
https://api.metrognome.com/errors/unauthenticated
https://api.metrognome.com/errors/forbidden
https://api.metrognome.com/errors/validation-failed
https://api.metrognome.com/errors/conflict
https://api.metrognome.com/errors/unprocessable
https://api.metrognome.com/errors/business-rule/<kind>
https://api.metrognome.com/errors/rate-limited
https://api.metrognome.com/errors/maintenance
https://api.metrognome.com/errors/payment-required
https://api.metrognome.com/errors/external-unavailable/<service>
https://api.metrognome.com/errors/internal
Typed registries at apps/api/src/lib/errors/:
BusinessRuleKind— TypeScript union of all legal business rule kinds. Each kind maps to a title:export const BUSINESS_RULES = { 'reservation-already-cancelled': { title: 'Reservation already cancelled' }, 'reservation-overlaps-existing': { title: 'Reservation conflicts with existing' }, 'lockout-active': { title: 'Lockout is currently active' }, 'insufficient-credit-balance': { title: 'Insufficient credit balance' }, 'duplicate-checkout-invitation': { title: 'Checkout invitation already exists' }, } as const export type BusinessRuleKind = keyof typeof BUSINESS_RULESExternalService—'stripe' | 'twilio' | 'supabase' | 'slack' | 'acuity' | 'upstash'.AuditSource— typed union of all valid audit sources (see Audit section below).
Routes cannot invent free-form kinds. Adding a new value requires editing the registry.
Validation issues pass through Zod's native ZodIssue shape — path: (string|number)[], code: ZodIssueCode, message, plus issue-specific fields.
Unknown thrown exceptions → RouteError.internal + Sentry capture. 4xx errors are INFO-logged, not Sentry-reported.
Wire shapes
Single entity (200)
HTTP 200
Content-Type: application/json
{ "id": "abc-123", "email": "foo@bar.com", "createdAt": "2026-05-11T..." }
Created (201)
HTTP 201
Content-Type: application/json
Location: /users/abc-123
{ "id": "abc-123", "email": "foo@bar.com", ... }
List (200)
HTTP 200
Content-Type: application/json
{
"data": [ {...}, {...} ],
"pagination": {
"nextCursor": "eyJpZCI6ImFiYy0xMjMifQ==",
"hasMore": true
}
}
List with total count (200)
For routes that declared withTotal: true, the first response (no cursor in request) includes the total. Subsequent paginated requests omit it.
HTTP 200
Content-Type: application/json
{
"data": [ {...}, {...} ],
"pagination": {
"nextCursor": "eyJpZCI6ImFiYy0xMjMifQ==",
"hasMore": true,
"total": 1427
}
}
Empty (204)
HTTP 204
(no body)
Redirect (302)
HTTP 302
Location: /dashboard
(no body)
Error — generic
HTTP 404
Content-Type: application/problem+json
{
"type": "https://api.metrognome.com/errors/not-found",
"title": "Resource not found",
"status": 404,
"detail": "User abc-123 not found",
"instance": "/users/abc-123"
}
Error — 5xx (includes requestId)
HTTP 500
Content-Type: application/problem+json
{
"type": "https://api.metrognome.com/errors/internal",
"title": "Internal server error",
"status": 500,
"instance": "/users/abc-123",
"requestId": "req_01HG8Z..."
}
4xx errors do not include requestId. 5xx errors always do.
Error — validation
HTTP 400
Content-Type: application/problem+json
{
"type": "https://api.metrognome.com/errors/validation-failed",
"title": "Validation failed",
"status": 400,
"instance": "/users",
"issues": [
{ "path": ["email"], "code": "invalid_string", "message": "Invalid email" },
{ "path": ["addresses", 0, "zip"], "code": "invalid_string", "message": "Invalid zip" },
{ "path": ["role"], "code": "invalid_enum_value", "message": "...", "options": ["USER", "STAFF", "ADMIN"] }
]
}
Error — rate limited
HTTP 429
Content-Type: application/problem+json
Retry-After: 60
{
"type": "https://api.metrognome.com/errors/rate-limited",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Too many requests. Retry after 60 seconds.",
"retryAfter": 60
}
Error — business rule
HTTP 422
Content-Type: application/problem+json
{
"type": "https://api.metrognome.com/errors/business-rule/reservation-already-cancelled",
"title": "Business rule violation",
"status": 422,
"detail": "Reservation abc-123 was already cancelled at 2026-05-10T14:32:00Z",
"instance": "/reservations/abc-123/actions/cancel"
}
URL conventions
Plural nouns, always.
/users, /reservations, /lockouts, /payments
/users/me, /users/me/preferences
HTTP verbs do the action.
GET /resources list
POST /resources create
GET /resources/:id read
PATCH /resources/:id update — field-only, no side effects
DELETE /resources/:id soft delete by default; returns soft-deleted entity
Sub-resources are flat. Resources have their own URLs regardless of conceptual ownership. Filter by parent via query string.
GET /insurance-policies?lockoutId=xxx
GET /access-codes?reservationId=xxx
GET /direction-steps?locationId=xxx
Non-CRUD operations use actions/[verb].
POST /reservations/:id/actions/cancel
POST /reservations/:id/actions/check-in
POST /reservations/:id/actions/no-show
POST /users/:id/actions/approve
POST /users/:id/actions/ban
POST /payments/:id/actions/refund
POST /lockouts/:id/actions/transfer
Verbs are kebab-case.
PATCH vs actions/[verb]. PATCH is for field updates with no side effects beyond the obvious DB write + audit log. Anything with cross-resource effects, external API calls, emails, SMS, or money movement goes through actions/[verb].
Lint rule no-side-effects-in-patch flags PATCH handlers that import side-effecting services (Stripe, Email, SMS, etc.).
Hard delete is reserved. DELETE is soft-delete by default. Hard delete (db.<model>.delete()) is only permitted in:
- Test fixtures
- Draft entities with no business significance
- Admin cleanup tools under
/admin/*labeled as such
Lint rule no-implicit-hard-delete enforces.
Public routes. Unauthenticated routes live under /public/*.
POST /public/contact
GET /public/unsubscribe
GET /public/maps/timezone
GET /public/auth/oauth-callback
Webhooks: one route per provider. Internal dispatch by event type.
POST /webhooks/stripe
POST /webhooks/slack
POST /webhooks/twilio
POST /webhooks/supabase
Crons. Kebab-case names.
POST /cron/sync-stripe
POST /cron/reconcile-access-codes
POST /cron/process-background-jobs
No URL versioning. Routes live at /api/.... Breaking changes are governed by the schema evolution policy (below), not URL prefixes.
Query string conventions
Simple form. No JSON:API brackets.
?status=active&sort=-createdAt&limit=20&cursor=eyJpZCI6...
Reserved keywords (cannot be used as filter fields):
page, limit, cursor, sort, fields
Sort syntax. Prefix with - for descending.
?sort=createdAt ascending
?sort=-createdAt descending
?sort=role,-createdAt multi-key
Boolean values. String 'true' / 'false'. Zod schemas coerce.
Array values. Repeat the key. Zod schemas coerce to arrays.
?status=active&status=pending
Schemas enforce field allowlists — unknown query params are rejected with a validation error.
Mutation response convention
Always return the entity.
| Method | Status | Body |
|---|---|---|
POST /resources |
201 + Location | Created entity |
PATCH /resources/:id |
200 | Updated entity |
DELETE /resources/:id |
200 | Soft-deleted entity (with deletedAt set) |
POST /resources/:id/actions/:verb |
200 | Resource after action |
204 is reserved for OPTIONS preflight only.
Pagination
Cursor-based, single kind. All paginated lists use cursor pagination. Stable under concurrent inserts, efficient at large offsets.
?cursor=eyJpZCI6...&limit=20
response: { data, pagination: { nextCursor, hasMore } }
Cursors are HMAC-signed (base64-encoded JSON of order-key fields, signed with HMAC-SHA256 + server-only secret). Clients see opaque strings; tampered cursors are rejected as malformed.
Total count opt-in. Routes that need to display "Showing 1-20 of 1,427" declare withTotal: true in their ListOptions:
output: list(userSchema, { withTotal: true })
The total is computed and included only in the first-page response (request with no cursor parameter). Paginated continuations omit it — clients cache the total client-side.
First request: ?limit=20
response: { data, pagination: { nextCursor, hasMore, total: 1427 } }
Continuations: ?cursor=eyJpZCI6...&limit=20
response: { data, pagination: { nextCursor, hasMore } } // no total
Routes without withTotal: true skip the count query entirely (no extra DB work).
Reserved query keywords for pagination: cursor, limit. The page keyword is still reserved (cannot be used as a filter field) to keep the namespace clean.
Webhooks
One route per provider. Internal dispatch by event type.
export const stripeWebhook = defineRoute({
method: 'POST',
path: '/webhooks/stripe',
auth: requires.webhook.stripe(),
output: ok(z.object({ received: z.literal(true) })),
side_effects: 'writes',
audit: { source: 'webhook:stripe' },
idempotency: { keyFrom: 'webhook:stripe-event-id' },
csrf: 'none',
handler: async ({ auth, db }) => {
await StripeService.processEvent(auth.webhook.event, db)
return { received: true }
},
})
Signature verification, raw body parsing, idempotency dedupe — all framework concerns. The auth.webhook.event value is the typed provider event.
Crons
Authenticated by CRON_SECRET bearer token. Vercel cron schedules in apps/api/vercel.json. Skipped on preview deployments.
export const syncStripe = defineRoute({
method: 'POST',
path: '/cron/sync-stripe',
auth: requires.cron(),
output: ok(syncStripeResultSchema),
audit: { source: 'cron:sync-stripe' },
csrf: 'none',
handler: async ({ env }) => {
if (env.isVercelPreview) return { skipped: 'preview deployment' }
return await StripeService.syncAll()
},
})
Each cron declares its own concrete result schema. No shared base. A CronResultBase TypeScript interface documents conventional fields (skipped?, durationMs?, processed?, succeeded?, failed?) authors may use; the schema itself is per-cron.
Public routes
Routes under /public/* use requires.public(). They bypass auth but NOT rate limiting, CSRF, or input validation.
Public POST routes (contact form, unsubscribe) require explicit csrf: 'none' declaration with a comment explaining why. Lint rule flags public POST routes that don't declare csrf.
CAPTCHA is a per-route concern (handler-level), not a framework convention.
Audit context
Every database write must attribute to an actor. Routes that don't have an authenticated user (crons, webhooks, public endpoints) declare the source explicitly.
defineRoute({
...,
audit: { source: 'cron:sync-stripe' },
audit: { source: 'webhook:stripe' },
audit: { source: 'public:contact-form' },
// omitted for authenticated routes — source defaults to 'api', user attribution from auth context
})
AuditSource is a typed registry at apps/api/src/lib/errors/audit-sources.ts. Adding a new cron / webhook / public route adds to the registry.
Framework enforces: any route with auth: requires.public() | requires.cron() | requires.webhook.*() must declare audit.source. Compile error if missing.
Framework wires audit context via AsyncLocalStorage (v1's existing mechanism at apps/api/src/lib/database/audit-context.ts). On entering a write transaction, four Postgres GUCs are set:
set_config('audit.user_id', <userId>, true)
set_config('audit.impersonator_id', <impersonatorId>, true)
set_config('audit.source', <source>, true)
set_config('audit.request_id', <requestId>, true)
Triggers populate audit_log rows reading these GUCs. Authors never touch audit plumbing directly — framework wires it from ctx.auth + the route's declared audit.source + ctx.requestId.
Observability
Every route automatically emits:
- Structured log on entry:
{ requestId, method, path, userId, scope } - Structured log on exit:
{ requestId, status, durationMs } - Tracing span wrapping the request; child spans for DB queries, service calls, external APIs
- 5xx errors → Sentry with full context (request, user, scope, breadcrumb of service calls)
- 4xx errors → INFO log only
Authors never call captureError directly. Throwing RouteError.internal({ cause }) does it. Throwing RouteError.<4xx kind>(...) does not.
Logger
Framework provides ctx.logger with the standard four-level interface:
ctx.logger.debug(message: string, extra?: Record<string, unknown>)
ctx.logger.info(message: string, extra?: Record<string, unknown>)
ctx.logger.warn(message: string, extra?: Record<string, unknown>)
ctx.logger.error(message: string, extra?: Record<string, unknown>)
Each call writes a single JSON line to stdout with auto-attached correlation fields:
{
"ts": "2026-05-11T14:32:00.123Z",
"level": "info",
"msg": "user approved",
"requestId": "...",
"route": "POST /users/:id/actions/approve",
"userId": "...",
"source": "api",
"userId_param": "...", // the "extra" object merged in
}
Vercel auto-collects stdout into searchable logs. No pino / winston / library dependency — framework wraps console.log. Logger is mockable in tests via the test adapter.
If/when log volume or aggregation needs outgrow Vercel logs, the Logger interface is preserved while the implementation swaps to a library (pino, etc.) without touching call sites.
Tracing
Framework provides ctx.trace wrapping Sentry's span API (@sentry/nextjs + @prisma/instrumentation). All DB queries are auto-traced via Prisma instrumentation; external API calls (Stripe, Twilio, Resend) instrumented at the service layer.
const span = ctx.trace.startSpan('compute-eligibility', { resource: 'lockout', id: lockoutId })
try {
return await expensiveComputation()
} finally {
span.end()
}
No standalone OpenTelemetry SDK installed. Sentry's tracing generates OTel-compatible spans at the wire format but ships them to Sentry's UI — sufficient for our single-service architecture. If we ever need vendor-neutral tracing (Honeycomb, Datadog, Tempo), the TraceBuilder interface stays the same while the implementation swaps to standalone OTel SDKs.
Request ID. Framework reads x-req-id from the incoming request; generates a fresh UUID v4 via crypto.randomUUID() if absent. The value is:
- Attached to the response as
x-req-idheader - Included in every structured log emitted by the request
- Set as the root tracing span ID
- Included in the
requestIdfield of 5xx error bodies
UUID v4 is sufficient — chronological ordering comes from log timestamps, not the ID itself. No external library needed.
Idempotency
Declarative. Framework handles deduplication. keyFrom is a typed enum:
idempotency: {
keyFrom: 'webhook:stripe-event-id'
}
idempotency: {
keyFrom: 'webhook:slack-event-id'
}
idempotency: {
keyFrom: 'webhook:supabase-event-id'
}
idempotency: {
keyFrom: 'header:idempotency-key'
}
idempotency: {
keyFrom: 'body:referenceId'
}
Storage:
- Webhook idempotency uses the
WebhookEventtable ((source, eventId)unique constraint, payload, retry tracking). - Header/body idempotency uses the
IdempotencyKeytable.
model IdempotencyKey {
key String @id // client-provided or derived
routePath String @map("route_path") // which route handled it
requestHash String @map("request_hash") // sha256 of method + path + body
responseStatus Int @map("response_status")
responseBody Json? @map("response_body") // null for empty/redirect/file responses
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
@@index([expiresAt]) // cleanup job
@@map("idempotency_keys")
}
TTL 24h. A cleanup cron (/cron/expire-idempotency-keys) deletes rows where expiresAt < now().
Reuse semantics. If a client sends the same idempotency key with a different requestHash, framework rejects with RouteError.conflict({ detail: 'Idempotency key reused with different request body' }) — HTTP 409. Standard Stripe-style behavior; protects against client bugs that mutate request bodies between retries.
IdempotencyKey table is added in the cutover migration. Required for: all webhook routes (handled by the webhook predicate). Recommended for: payment-intent creation, money-in operations, mutating routes exposed to retry-prone clients.
Rate limiting
Global default in framework. Per-route override.
// Global defaults:
{
unauthenticated: { perIp: '60/min' },
authenticated: { perUser: '600/min' },
}
// Per-route override:
defineRoute({
...,
rateLimit: { perIp: '5/min' }, // e.g. password reset, login
})
Implementation: Upstash Redis. Rate-limit response is RouteError.rateLimited({ retryAfter }) with Retry-After header.
CORS
CORS headers are attached by the framework based on the request origin and the route's auth predicate:
- Webhook routes — no CORS headers. Webhook providers don't issue cross-origin requests; they're server-to-server.
- Cron routes — no CORS headers. Same reason.
- Public routes —
Access-Control-Allow-Origin: *,Access-Control-Allow-Methodsincludes the route's method, no credentials. - Authenticated routes —
Access-Control-Allow-Originis the request origin if it matchesALLOWED_ORIGINS(env var, comma-separated); otherwise the header is omitted.Access-Control-Allow-Credentials: true.
OPTIONS preflight requests are handled centrally — framework matches the path against the route registry, checks the requested method, and responds with the correct Allow-* headers + 204. Authors never declare OPTIONS handlers.
Origins disallowed at preflight time get RouteError.forbidden({ detail: 'Origin not allowed' }) 403.
Cache control
Default response header: Cache-Control: no-store. Correct for an auth'd API where responses are user-specific.
Routes serving genuinely cacheable content opt out at declaration time:
output: ok(timezoneSchema, { cache: 'public, max-age=86400' }) // 24h public cache
output: ok(maintenanceSchema, { cache: 'public, max-age=60' }) // 1m public cache
Public, anonymous routes (/public/maps/timezone, etc.) are the main case. Authenticated routes should not override.
CSRF protection
SameSite cookies + double-submit token.
Auth cookie:
mg-sessionset withSameSite=Strict; Secure; HttpOnly; Path=/- Browsers refuse to send this cookie on cross-site requests at the protocol level
Double-submit token (defense in depth):
- Framework sets a
mg-csrfcookie (random 32-byte token,SameSite=Strict; Secure; Path=/, NOTHttpOnlyso JS can read it) on first response to an authenticated client - Frontend client reads
mg-csrfand sends it asX-CSRF-Tokenheader on state-changing requests - Framework rejects state-changing requests where header doesn't match cookie
Policy:
GET,HEAD,OPTIONS: no CSRF checkPOST,PATCH,DELETEwith non-public auth: SameSite cookie + matchingX-CSRF-Tokenheader- Public POST routes: require explicit
csrf: 'none'declaration - Webhook / cron routes:
csrf: 'none'(signature/bearer is the auth)
Mismatch → RouteError.forbidden({ detail: 'CSRF check failed' }).
Frontend client reads the cookie and adds the header automatically. UI authors never see CSRF.
Domain model
Reservations and lockouts are distinct resource families
| Reservations (hourly) | Lockouts (monthly) | |
|---|---|---|
| Booking model | One-off, specific time window | Continuous, recurring billing |
| Payment | Single payment | Recurring subscription |
| Cancellation | Refund logic by timing rules | Proration + immediate or end-of-period |
| Pausing | N/A | Supported |
| Transfer | N/A | Supported between users |
| Insurance | N/A | Lockout-only feature |
| Access codes | Per-reservation | Per-active-lockout, rotated on transfer |
Insurance
Flat resource. lockoutId foreign key.
GET /insurance-policies?lockoutId=xxx list a lockout's insurance history
POST /insurance-policies create (body: { lockoutId, ... })
GET /insurance-policies/:id read
DELETE /insurance-policies/:id cancel (soft delete)
Access codes
Flat resource. reservationId or lockoutId foreign key.
GET /access-codes?reservationId=xxx
GET /access-codes?lockoutId=xxx
POST /access-codes/:id/actions/rotate
File layout
apps/api/
src/
routes/ ← authoring surface — route declarations
users/
list.ts
create.ts
byId.ts ← GET, PATCH, DELETE /users/:id
actions/
approve.ts
ban.ts
send-password-reset.ts
me/
preferences.ts
attribution.ts
reservations/
list.ts
create.ts
byId.ts
actions/
cancel.ts
check-in.ts
no-show.ts
lockouts/
list.ts
create.ts
byId.ts
actions/
transfer.ts
pause.ts
resume.ts
insurance-policies/
list.ts
create.ts
byId.ts
access-codes/
list.ts
byId.ts
actions/
rotate.ts
webhooks/
stripe.ts
slack.ts
twilio.ts
supabase.ts
cron/
sync-stripe.ts
reconcile-access-codes.ts
...
public/
contact.ts
unsubscribe.ts
maps/
timezone.ts
auth/
oauth-callback.ts
permissions/ ← authoring surface — domain "can-do-X" functions
users.ts
reservations.ts
lockouts.ts
payments.ts
...
services/ ← authoring surface — domain services
jobs/ ← authoring surface — background jobs
templates/ ← authoring surface — email templates
lib/ ← infrastructure (uses Next.js + community convention)
http/ ← HTTP framework
index.ts ← public API barrel
define-route.ts
route-error.ts
types.ts
cursor.ts ← HMAC-signed cursor encode/decode
output/ ← ok, created, list, empty, redirect, file, stream, twiml
index.ts
ok.ts
created.ts
list.ts
empty.ts
redirect.ts
file.ts
stream.ts
twiml.ts
middleware/ ← request-pipeline middleware
idempotency.ts
rate-limit.ts
csrf.ts
cors.ts
audit.ts
validation-errors.ts
error-handler.ts
adapters/ ← runtime adapters
next.ts ← Next App Router adapter
test.ts ← in-process for tests
auth/ ← authentication infrastructure
predicates/ ← composable auth predicates
index.ts
user.ts
cron.ts
webhook.ts
composers.ts
owns-resources.ts ← typed registry of owned resources
belongs-to-org-resources.ts ← typed registry of org-scoped resources
supabase/ ← existing v1 Supabase helpers
jwt/ ← existing v1 JWT helpers
errors/ ← typed error registries
index.ts
business-rules.ts ← BusinessRuleKind registry
external-services.ts ← ExternalService registry
audit-sources.ts ← AuditSource registry
observability/ ← cross-cutting observability
logger.ts
tracing.ts
sentry.ts
database/ ← existing — Prisma client, audit-context, transactions
validation/ ← deletes post-cutover (replaced by defineRoute schemas)
security/ ← absorbed by lib/http/middleware/ during Phase 1
analytics/, email/, meta/, calendar/, config/ ← unchanged from v1
app/
api/ ← generated Next route shims (do not edit)
Authoring surface is the top-level routes/, permissions/, services/, jobs/, templates/ directories. Everything under lib/ is infrastructure. This matches Next.js's official guidance (lib/ is the canonical home for shared utilities and data-access code) and the existing codebase's lib/<sub-area>/ convention.
Framework crawls src/routes/ at build time; declarations are wired into Next route shims via codegen. Authors only edit src/routes/.
Frontend client
Generated from the route registry. Lives at packages/api-client/src/generated.ts.
// Usage in app code:
const user = await api.users.byId({ params: { id: 'abc' } })
// ^ typed as User; throws ApiError on 4xx/5xx
const list = await api.users.list({ query: { cursor, limit: 20 } })
// ^ typed as { data: User[], pagination: CursorPagination }
await api.users.actions.approve({
params: { id: 'abc' },
body: { role: 'STAFF' },
})
Client converts RFC 9457 problems to typed ApiErrors:
try {
await api.users.byId({ params: { id } })
} catch (e) {
if (e instanceof ApiError) {
if (e.is('not-found')) { /* ... */ }
if (e.is('forbidden')) { /* ... */ }
if (e.is('validation-failed')) {
e.issues.forEach(issue => /* ... */)
}
}
}
Client retries 429/503 automatically with exponential backoff and Retry-After header support.
The generated generated.ts imports route types from apps/api/src/routes/** to expose end-to-end inferred types. @mg/api-client is consumed via its build output (dist/), and the route-type chain is validated by consumer apps: apps/web exercises useApi/useApiList/useApiMutation against every v2 route in apps/web/src/__smoke__/v2-routes.tsx, so pnpm --filter @mg/web typecheck:quick transitively validates the registry → generated.ts → consumer-hook type flow. A standalone tsc --noEmit against packages/api-client/src/generated.ts is not part of the type-validation gate.
React hooks
Generated alongside the client. Lives at packages/api-client/src/hooks.ts.
const { data, isLoading } = useApi(api.users.byId, { params: { id } })
const { items, pagination, fetchNext, isFetchingNext } = useApiList(api.users.list, { query: filters })
const approve = useApiMutation(api.users.actions.approve, {
onSuccess: () => queryClient.invalidateQueries({ key: api.users.list }),
})
CSRF token cookie reading and X-CSRF-Token header attachment is automatic.
Test helpers
// apps/api/tests/_helpers/invoke.ts
invoke<R extends RouteSpec>(
route: R,
args: {
params?: ParamsOf<R>
query?: QueryOf<R>
body?: BodyOf<R>
auth?: TestAuthContext // testStaff(), testAdmin(), testUser({...})
db?: TransactionClient // optional test DB injection
}
): Promise<{ status: number; body: OutputOf<R> }>
invokeExpectingError<R extends RouteSpec>(
route: R,
args: ...
): Promise<{ status: number; problem: ProblemDetails }>
Usage:
import { getUser } from '@/routes/users/byId'
test('returns 404 for non-existent user', async () => {
const { status, problem } = await invokeExpectingError(getUser, {
params: { id: '00000000-0000-0000-0000-000000000000' },
auth: testStaff(),
})
expect(status).toBe(404)
expect(problem.type).toMatch(/not-found/)
})
test('returns user for staff', async () => {
const user = await db.user.create({ data: ... })
const { status, body } = await invoke(getUser, {
params: { id: user.id },
auth: testStaff(),
})
expect(status).toBe(200)
expect(body).toMatchSchema(userSchema)
expect(body.id).toBe(user.id)
})
Test runtime uses the test-adapter — no Next, no HTTP server, in-process.
Schema evolution policy
No URL versioning. Changes are governed by additive-vs-breaking distinction:
Freely allowed (additive):
- New optional response fields
- New endpoints
- New
BusinessRuleKind/AuditSource/ExternalServicevalues - New output kinds
- Wider input schemas (accept more, reject fewer)
Breaking changes (require deprecation + coordinated frontend update):
- Removing or renaming a response field
- Narrowing an input schema
- Changing an endpoint's output kind
- Removing or renaming an endpoint
Process for breaking changes:
- Add the new field/route/shape alongside the old
- Update frontend to use the new shape
- Deploy both
- Remove the old shape after confirmation (1-7 days)
Never permitted:
- Reusing a field name for a different type or meaning
- Reusing an endpoint path for a different resource
- Silent semantic changes to an existing field