Initial Commit
This commit is contained in:
74
openspec/archive/2026-05-25-api-router/proposal.md
Normal file
74
openspec/archive/2026-05-25-api-router/proposal.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `@suaveplan/api-router` — abstract Router, Middleware, Request/Response, blue-green slot contract
|
||||
|
||||
**Package**: `@suaveplan/api-router`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 1 (Contracts) — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
Every concrete router adapter (`api-router-hono`, `api-router-in-memory`, the worker-bridge router, future Fastify / Bun-native flavours) needs the same set of contracts: `Router`, `Middleware`, `Request`, `Response`, `RouteOptions`, plus the blue-green slot management interface that lets the host atomically flip from `live=N` to `live=N+1` during hot reload (design §6, §8.1, §10.7). Putting these contracts in `api-router` keeps the abstract surface independent of any one HTTP library so the host (`api-host`) and downstream adapters can be developed in parallel — and so plugin authors can author against a stable router type they import once.
|
||||
|
||||
Per design §8.1: "The router's per-plugin version pointer is the single atomic point that determines which module gets this request." That pointer's contract — `BlueGreenSlot` — lives here.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package. Mostly types but ships some runtime: route-options Zod schemas, the abstract composition helpers (prefix-mounting, middleware-chain builder), and the blue-green slot management *interfaces* (concrete implementation lives in `api-host`).
|
||||
|
||||
- ADDED: `./router` — abstract `Router` interface (verb methods: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`; plus `use(middleware)` and `mount(prefix, sub)`).
|
||||
- ADDED: `./request` — host-side `Request` shape: `method`, `url`, `headers`, `body` (already validated by step 6), `params`, `query`, `tenantId`, `principal`, `requestId`, `span`.
|
||||
- ADDED: `./response` — host-side `Response` builder: `status`, `headers`, `body`, `etag`, `serverTiming`, `streaming` mode toggle.
|
||||
- ADDED: `./route-options` — `RouteOptions` Zod schema covering: `auth` (scope id), `summary`, `tags`, `request` (Zod body/params/query), `responses` (per-status Zod), `runtimeValidation` (default true), `openapi` (default true), `streaming`, `csrf`, `idempotent`, `rateLimit`.
|
||||
- ADDED: `./middleware` — `Middleware` contract: `(req, next) => Response | Promise<Response>`; `composeMiddleware([...])` builder; insertion-point predicate (plugin middleware allowed only between pipeline steps 7-8, never before).
|
||||
- ADDED: `./route-match` — abstract `RouteMatch` shape returned from `Router.match(method, url)`: handler reference + params + plugin-of-record.
|
||||
- ADDED: `./slot` — `RouterSlot` (per-plugin mount point) + `BlueGreenSlot` interface (`live`, `shadow`, `draining` slots; atomic `flip(toShadow)`; `dispose(slot)`). Concrete impl lives in `api-host`.
|
||||
- ADDED: `./error-codes` — frozen `ROUTER_CODES` registry: `ROUTE_COLLISION`, `INVALID_PREFIX`, `MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`.
|
||||
- ADDED: `./testing` — `runRouterConformance(factory)` reusable harness every concrete adapter must pass.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any concrete adapter — `api-router-hono`, `api-router-in-memory`, and the worker bridge each land in their own changes.
|
||||
- HTTP intake / wire parsing — that's the adapter's job. This package's `Request` is the post-parse shape.
|
||||
- OpenAPI generation — `api-openapi` reads `RouteOptions` schemas; this package only exports the schema shape.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| Per-request pipeline (steps 1-11) | §6 |
|
||||
| Plugin middleware allowed only between steps 7-8 | §6 ("That surface was a CVE waiting to happen.") |
|
||||
| `RouteOptions` keys (`auth`, `runtimeValidation`, `openapi`, `streaming`, `csrf`, `idempotent`) | §6 (CSRF, idempotency, streaming), §7 (OpenAPI route example) |
|
||||
| `BlueGreenSlot` semantics (live / shadow / draining) | §8.1, §8.5 |
|
||||
| Coexistence with GraphQL | §10.7 |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `composeMiddleware` builder | Standard pattern; design doc references middleware composition without specifying the constructor — this package owns the canonical builder. |
|
||||
| `runRouterConformance` harness | Conformance suites are explicit Stage-5 deliverable (§13.4); contract package owns the harness signature. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/router/router.test.ts` — verb methods present, `mount(prefix, sub)` rewrites paths, `use(middleware)` appends to chain.
|
||||
- `src/route-options/route-options.test.ts` — Zod accept/reject for every option; defaults verified (`runtimeValidation: true`, `openapi: true`).
|
||||
- `src/middleware/compose-middleware.test.ts` — chain ordering, short-circuit, `next()` skipping, error propagation.
|
||||
- `src/middleware/insertion-point.test.ts` — plugin-supplied middleware accepted only at the steps-7-8 boundary; rejection emits `MIDDLEWARE_INSERTION_DENIED`.
|
||||
- `src/slot/slot.test.ts` — `BlueGreenSlot` state machine: live → shadow set → flip → draining → disposed. Type-level test that `flip` is atomic (returns the old live slot's draining-handle).
|
||||
- `src/route-options/route-options.spec-conformance.test.ts` — `expectTypeOf<RouteOptions>()` matches `@suaveplan/api-types`'s router contract.
|
||||
- `src/testing/conformance.test.ts` — runs the harness against a stub `Router` to verify the harness itself is correct.
|
||||
- `tests/dep-graph.test.ts` — pins deps: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`, `zod` only.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing consumer.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/api-router` to enumerate. No dependents at Stage 1.
|
||||
|
||||
- (none) — [x] no action needed (reason: net-new package; first consumer is `api-host` in Stage 2)
|
||||
145
openspec/archive/2026-05-25-api-router/specs/api-router/spec.md
Normal file
145
openspec/archive/2026-05-25-api-router/specs/api-router/spec.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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<Caps>`.
|
||||
|
||||
#### Scenario: every verb method is declared
|
||||
|
||||
- **WHEN** `expectTypeOf<Router>()` 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<RouteOptions>` 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<number, { body: z.ZodSchema }>`, `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"`
|
||||
72
openspec/archive/2026-05-25-api-router/tasks.md
Normal file
72
openspec/archive/2026-05-25-api-router/tasks.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Tasks — `@suaveplan/api-router`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc §6 (per-request pipeline), §7 (OpenAPI / route options), §8.1 (blue-green slots), §10.7 (GraphQL coexistence)
|
||||
- [x] 1.2 Read `openspec/specs/api-router/spec.md` (does not exist yet)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Source-mapping table — every requirement cites design-doc section
|
||||
- [x] 1.6 Draft `specs/api-router/spec.md`
|
||||
- [x] 1.7 Every requirement has a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/api/api-router/` (`"version": "0.1.0"`, `"sideEffects": false`, deps: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`, `zod`)
|
||||
- [x] 2.2 `src/router/` — abstract `Router` interface + ambient defaults
|
||||
- [x] 2.3 `src/request/` — host-side `Request` type
|
||||
- [x] 2.4 `src/response/` — host-side `Response` builder shape + constructor
|
||||
- [x] 2.5 `src/route-options/` — Zod schema (`import z from "zod"`); inferred type matches `api-types`
|
||||
- [x] 2.6 `src/middleware/` — `Middleware` type + `composeMiddleware` + insertion-point guard
|
||||
- [x] 2.7 `src/route-match/` — `RouteMatch` type
|
||||
- [x] 2.8 `src/slot/` — `RouterSlot` + `BlueGreenSlot` interfaces (no concrete impl)
|
||||
- [x] 2.9 `src/error-codes/` — `ROUTER_CODES`: `ROUTE_COLLISION`, `INVALID_PREFIX`, `MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`
|
||||
- [x] 2.10 `src/testing/` — `runRouterConformance(factory)` harness
|
||||
- [x] 2.11 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.12 `package.json` `exports` — one subpath per category
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests per feature
|
||||
- [x] 3.2 Spec-conformance tests (type-level + Zod inference)
|
||||
- [x] 3.3 Property test on `composeMiddleware` (chain order is preserved)
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words)
|
||||
- [x] 4.2 `README.md` linking every `.md` under `## Modules`
|
||||
- [x] 4.3 No CLAUDE.md update (umbrella covers)
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-router` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-router` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-router` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-router` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics on `Router<Caps>` and `Middleware<Caps>`; `expectTypeOf` assertions
|
||||
- [x] 6.2 Zod wired at `RouteOptions` boundary
|
||||
- [x] 6.3 Every `ROUTER_CODES` entry thrown by a production path
|
||||
- [x] 6.4 `runtimeValidation: true` vs `false` behaviors tested
|
||||
- [x] 6.5 OTel — N/A (instruments emitted by adapters, not the contract)
|
||||
- [x] 6.6 Lifecycle dedup — N/A
|
||||
- [x] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold into `openspec/specs/api-router/spec.md`
|
||||
- [x] 7.2 Move folder to `openspec/archive/<yyyy-mm-dd>-api-router/`
|
||||
- [x] 7.3 Tick `1.3` in umbrella tasks
|
||||
- [x] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user