# Capability Spec Delta: api-router (api-router) **Package**: `@suaveplan/api-router` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line. ## ADDED Requirements ### Requirement: Abstract Router interface The package SHALL export the `Router` interface with verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`), a `use(middleware: Middleware)` chain-appender, and a `mount(prefix: string, sub: Router)` composition method. Each verb method MUST accept `(path, options: RouteOptions, handler: RouteHandler)` AND `(path, handler)` (options optional). The interface MUST be generic over `Caps extends CapabilityMap` so handlers receive a typed `RequestContext`. #### Scenario: every verb method is declared - **WHEN** `expectTypeOf()` is checked - **THEN** every HTTP verb is callable with both 2-arg and 3-arg forms #### Scenario: mount prefixes child routes - **WHEN** a parent router `r` mounts child router `c` at prefix `/billing`, AND `c` declares `GET /invoices` - **THEN** the parent's `match("GET", "/billing/invoices")` returns the child's handler **Verified by:** `src/router/router.spec-conformance.test.ts::"Router declares every verb method with overloaded signatures"` AND `src/router/router.test.ts::"mount prefixes child routes"` --- ### Requirement: `import z from "zod"` everywhere Every `.ts` file using Zod MUST use the default-import form. The named-import form (`import z from "zod"`) is forbidden. #### Scenario: no named import of `z` - **WHEN** the test greps every `src/**/*.ts` - **THEN** zero matches for `import z` from `"zod"` **Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"` --- ### Requirement: RouteOptions schema covers the design-doc keys The package SHALL export `routeOptionsSchema: z.ZodSchema` whose inferred type matches `RouteOptions` from `@suaveplan/api-types`. The schema MUST include: `auth?: string`, `summary?: string`, `tags?: readonly string[]`, `request?: { body?, params?, query? }` (each a `z.ZodSchema`), `responses?: Record`, `runtimeValidation` (default `true`), `openapi` (default `true`), `streaming` (default `false`), `csrf?: boolean`, `idempotent?: boolean`, `rateLimit?: { rpm: number } | { rps: number }`. #### Scenario: defaults apply when fields are absent - **WHEN** `routeOptionsSchema.parse({})` is called - **THEN** `runtimeValidation === true` AND `openapi === true` AND `streaming === false` #### Scenario: unknown option keys rejected - **WHEN** `routeOptionsSchema.parse({ badKey: 1 })` is called - **THEN** Zod reports an issue with path `["badKey"]` **Verified by:** `src/route-options/route-options.test.ts::"defaults apply when fields absent"` AND `src/route-options/route-options.spec-conformance.test.ts::"rejects unknown option keys"` --- ### Requirement: Middleware composition with insertion-point guard The package SHALL export `composeMiddleware(list: readonly Middleware[]): Middleware` returning a single middleware that runs each entry in order, threading `next()` correctly. Plugin-supplied middleware MUST only be insertable at the steps-7-8 boundary of the per-request pipeline (design §6). Insertion at an earlier step MUST throw `ROUTER.MIDDLEWARE_INSERTION_DENIED`. #### Scenario: chain runs in order - **WHEN** three middlewares logging "A", "B", "C" are composed AND a request flows through - **THEN** logs appear in order A, B, C, response, C, B, A (wrap-and-unwrap) #### Scenario: insertion before auth rejected - **WHEN** plugin code calls `router.insertMiddleware({ step: "pre-auth", mw })` - **THEN** the call throws `ROUTER.MIDDLEWARE_INSERTION_DENIED` **Verified by:** `src/middleware/compose-middleware.test.ts::"chain runs in declared order"` AND `src/middleware/insertion-point.test.ts::"plugin middleware rejected before auth step"` --- ### Requirement: BlueGreenSlot interface The package SHALL export `BlueGreenSlot` representing a per-plugin mount point with three slot positions: `live` (currently serving), `shadow` (newly imported, not yet serving), `draining` (previous `live`, finishing in-flight requests). The interface MUST expose: `setShadow(handler)`, `flip(): DrainingHandle` (atomic — returns the old live slot as draining), `dispose(slot, opts: { timeoutMs })`. A flip on a slot with no shadow MUST throw `ROUTER.SLOT_FLIP_FAILED`. A `dispose` whose timeout fires before in-flight requests settle MUST throw `ROUTER.SLOT_DISPOSE_TIMEOUT` AND the abort signal MUST be wired on the still-pending requests. #### Scenario: flip is atomic - **WHEN** `setShadow(N+1)` is called AND `flip()` is called - **THEN** the returned draining handle references slot N AND subsequent matches route to N+1 #### Scenario: flip with no shadow rejected - **WHEN** `flip()` is called with `shadow === null` - **THEN** `ROUTER.SLOT_FLIP_FAILED` is thrown **Verified by:** `src/slot/slot.test.ts::"flip atomically promotes shadow and returns draining handle"` AND `src/slot/slot.test.ts::"flip with no shadow throws SLOT_FLIP_FAILED"` --- ### Requirement: Route collision detection The package SHALL export `Router.match(method, url): RouteMatch | null` and `Router.collidesWith(method, path): RouteMatch[]`. Registering a route whose method + path collides with an existing route on the same plugin MUST throw `ROUTER.ROUTE_COLLISION`. Cross-plugin collisions are NOT this package's responsibility — they're caught by the host loader against the manifests. #### Scenario: same-plugin collision rejected - **WHEN** `router.get("/x", h1)` is called twice on the same router - **THEN** the second call throws `ROUTER.ROUTE_COLLISION` **Verified by:** `src/router/router.test.ts::"same-plugin route collision rejected"` --- ### Requirement: Error codes registry frozen The package SHALL export `ROUTER_CODES` via `defineCodes` containing exactly: `ROUTE_COLLISION`, `INVALID_PREFIX`, `MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`. Every code MUST be thrown by a production path. #### Scenario: every code reachable - **WHEN** the test enumerates `ROUTER_CODES` - **THEN** every code has a production-path throw site **Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every ROUTER_CODES entry thrown by production path"` --- ### Requirement: Conformance harness for adapters The package SHALL export `runRouterConformance({ factory, label })` from `./testing`. The harness MUST exercise: every verb method, mount, middleware chain, route collision, slot flip / drain / dispose, request param parsing, error propagation. Every adapter in Stages 4-7 (`api-router-hono`, `api-router-in-memory`, the worker bridge) MUST pass this harness. #### Scenario: harness exercises every required case - **WHEN** the harness is run against a stub Router built from the abstract interface - **THEN** at least one `it(...)` exists for verbs, mount, middleware, collision, slot lifecycle **Verified by:** `src/testing/conformance.test.ts::"harness covers verbs, mount, middleware, collision, slot lifecycle"` --- ### Requirement: Subpath exports Every category (`router`, `request`, `response`, `route-options`, `middleware`, `route-match`, `slot`, `error-codes`, `testing`) SHALL be reachable via its own subpath export. Root barrel uses explicit named re-exports (rule 10). #### Scenario: every documented subpath resolves - **WHEN** the test imports each subpath - **THEN** every import resolves **Verified by:** `tests/subpaths.test.ts::"every documented subpath is importable"` AND `tests/dep-graph.test.ts::"root barrel has no export-star"`