Skip to content

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

  1. Routes are declarations. A route is a typed value built with defineRoute({ ... }). The framework compiles declarations to runtime handlers. Authors never see Request/Response outside the framework directory.

  2. 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.

  3. RFC 9457 for errors. Error responses use application/problem+json with { type, title, status, detail, instance, ... }. The type field is the canonical machine-readable error identifier.

  4. 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.

  5. 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.

  6. Errors thrown, not returned. Routes throw RouteError.<kind>(...). Framework catches and serializes. Unknown exceptions become 500 + Sentry.

  7. Portable runtime. defineRoute returns a RouteSpec decoupled 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.

  8. 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.kindRouteError 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) when VERCEL_ENV=preview
  • http://localhost:* when NODE_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() — wraps stripe.webhooks.constructEvent(rawBody, signature, secret) from the official Stripe SDK. Populates ctx.auth.webhook.event as a typed Stripe.Event.
  • requires.webhook.slack() — wraps verifySlackSignature() helper at apps/api/src/lib/slack.ts (HMAC-SHA256 of v0:<timestamp>:<rawBody>).
  • requires.webhook.twilio() — wraps validateRequest() from the official twilio package (HMAC-SHA1 of URL + sorted POST fields).
  • requires.webhook.supabase() — checks x-webhook-signature header against SUPABASE_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_RULES
    
  • ExternalService'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-id header
  • Included in every structured log emitted by the request
  • Set as the root tracing span ID
  • Included in the requestId field 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 WebhookEvent table ((source, eventId) unique constraint, payload, retry tracking).
  • Header/body idempotency uses the IdempotencyKey table.
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 routesAccess-Control-Allow-Origin: *, Access-Control-Allow-Methods includes the route's method, no credentials.
  • Authenticated routesAccess-Control-Allow-Origin is the request origin if it matches ALLOWED_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-session set with SameSite=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-csrf cookie (random 32-byte token, SameSite=Strict; Secure; Path=/, NOT HttpOnly so JS can read it) on first response to an authenticated client
  • Frontend client reads mg-csrf and sends it as X-CSRF-Token header on state-changing requests
  • Framework rejects state-changing requests where header doesn't match cookie

Policy:

  • GET, HEAD, OPTIONS: no CSRF check
  • POST, PATCH, DELETE with non-public auth: SameSite cookie + matching X-CSRF-Token header
  • 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 / ExternalService values
  • 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:

  1. Add the new field/route/shape alongside the old
  2. Update frontend to use the new shape
  3. Deploy both
  4. 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