# Capability Spec: api **Package**: `@suaveplan/api` **Status**: ACTIVE ## ADDED Requirements ### Requirement: SuaveApp composes pre-built dependencies, not creates them `SuaveApp` constructor SHALL accept `{ pluginRegistry: PluginRegistry; openAPIRegistry?: OpenAPIRegistry; middlewareChain?: MiddlewareChain; tenantResolver?: TenantResolver; migrationRunner?: MigrationRunner; config: AppConfig; logger?: LoggerLike }`. It SHALL NOT internally construct any of those — callers (or `AppBuilder`) wire them. Dependency inversion eliminates upstream's deep import coupling. #### Scenario: SuaveApp does not instantiate PluginRegistry - **WHEN** `new SuaveApp({ pluginRegistry: stubRegistry, config })` is constructed - **THEN** `app.registry === stubRegistry` (no internal reconstruction) **Verified by:** `src/app/app.test.ts::"composes injected pluginRegistry without reconstruction"` AND `src/app/app.test.ts::"missing pluginRegistry raises API_INVALID_CONFIG"` --- ### Requirement: AppBuilder.tenant(resolver) actually wires into middleware When `AppBuilder.tenant(resolver).build()` is called, the produced `SuaveApp` SHALL register `createTenantMiddleware({ resolver, ... })` from `@suaveplan/api-tenant` into the middleware chain at `MiddlewareOrder.TENANT`. This is a deliberate fix for the upstream bug where the resolver is plumbed but silently dropped. #### Scenario: tenant resolver reaches the middleware chain - **WHEN** `defineApp().tenant(stubResolver).build()` is invoked and the middleware chain is inspected - **THEN** an entry with `meta.name === "tenant"` exists and its resolver === stubResolver **Verified by:** `src/builder/builder.test.ts::"tenant(resolver) wires createTenantMiddleware"` AND `src/builder/builder.test.ts::"no tenant() call leaves chain without tenant middleware"` --- ### Requirement: AppState FSM transitions are monotonic `app.state` SHALL move through `created → initializing → ready → running → stopping → stopped`. Calling `initialize()` from any state other than `created` SHALL raise `ApiError(API_INVALID_STATE)`. `start()` from `created` SHALL auto-initialize first. `stop()` from non-`running` SHALL be a no-op (idempotent). #### Scenario: double-initialize raises API_INVALID_STATE - **WHEN** `app.initialize()` is awaited, then `app.initialize()` is awaited again - **THEN** the second call rejects with `ApiError(API_INVALID_STATE)` #### Scenario: stop is idempotent from stopped - **WHEN** `app.stop()` is called twice in succession from `running` - **THEN** the second call resolves without error and leaves state at `stopped` **Verified by:** `src/app/app.test.ts::"double initialize raises API_INVALID_STATE"` AND `src/app/app.test.ts::"stop is idempotent from stopped"` AND `src/app/app.test.ts::"start from created auto-initializes"` --- ### Requirement: Config loader resolves file → env-file → env-vars `loadConfig(options)` SHALL merge in the order: defaults → file (`.ts` → `.js` → `.json` extensions tried in that order) → environment-specific file (`{name}.{env}.json`) → environment variables (`SUAVEPLAN_` parsed into nested object via `_` → `.`). Later sources override earlier. Yaml and toml file formats SHALL NOT be supported (upstream advertised them but threw at runtime — genesis drops the union entries). #### Scenario: env var overrides file - **WHEN** `suaveplan.config.json` contains `server.port: 3000` and `SUAVEPLAN_SERVER_PORT=4000` is set, then `loadConfig()` is awaited - **THEN** the result has `server.port: 4000` #### Scenario: yaml extension not tried - **WHEN** `loadConfig({ configName: "suaveplan.config" })` is called and only `suaveplan.config.yaml` exists - **THEN** the result is the defaults-only config (yaml is not loaded) **Verified by:** `src/config/loader.test.ts::"env var overrides file"` AND `src/config/loader.test.ts::"env-specific file overrides base file"` AND `src/config/loader.test.ts::"yaml extension is not attempted"` --- ### Requirement: validateConfig raises typed API_INVALID_CONFIG `validateConfig(input)` SHALL parse against `AppConfigSchema` and throw `ApiError(API_INVALID_CONFIG)` with the ZodError as `cause` on failure. `tryValidateConfig(input)` SHALL return `{success: false, errors: ConfigValidationErrorDetail[]}` on failure (no throw). `validatePluginConfig(schema, input, pluginName)` SHALL throw `ApiError(API_INVALID_CONFIG)` with `context.pluginName` on failure. #### Scenario: validateConfig with missing required field throws - **WHEN** `validateConfig({})` is called (missing required `name` + `version` + `openapi`) - **THEN** an `ApiError(API_INVALID_CONFIG)` is thrown with `cause instanceof ZodError` **Verified by:** `src/config/validator.test.ts::"validateConfig throws on missing fields"` AND `src/config/validator.test.ts::"tryValidateConfig returns errors on missing fields"` AND `src/config/validator.test.ts::"validatePluginConfig carries pluginName context"` --- ### Requirement: MiddlewareChain insertBefore/insertAfter raise on missing target `chain.insertBefore(name, m)` and `chain.insertAfter(name, m)` SHALL raise `ApiError(API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND)` when no middleware with the named target exists. Upstream silently falls back to `push` — genesis fails closed so callers learn about the mismatch immediately. #### Scenario: insertBefore on missing name raises - **WHEN** `chain.insertBefore("auth", myMiddleware)` is called and no "auth" middleware exists - **THEN** `ApiError(API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND)` is thrown #### Scenario: insertBefore on existing name splices - **WHEN** "cors" exists at index 1 and `chain.insertBefore("cors", myMiddleware)` is called - **THEN** `myMiddleware` is at index 1, "cors" is at index 2 **Verified by:** `src/middleware/chain.test.ts::"insertBefore raises on missing target"` AND `src/middleware/chain.test.ts::"insertBefore splices on existing target"` AND `src/middleware/chain.test.ts::"insertAfter raises on missing target"` --- ### Requirement: Error handler passes full SuaveplanError statusCode through `createErrorHandler()` SHALL respond with `error.statusCode` as the HTTP status — full range, not a closed enum. SuaveplanError subclasses with statusCode 409, 422, 503, 504, etc. SHALL respond with those codes. ZodError → 400 with `ValidationError.fromZodError(err).toJSON()`. HTTPException → its status. Unknown → 500 with `INTERNAL_ERROR`. #### Scenario: 422 SuaveplanError responds 422 - **WHEN** a route throws a `SuaveplanError` with `statusCode: 422` and the error handler runs - **THEN** the response status is 422 #### Scenario: ZodError responds 400 - **WHEN** a route throws a `ZodError` and the error handler runs - **THEN** the response status is 400 with `error.code === "VALIDATION_ERROR"` in the body **Verified by:** `src/middleware/error-handler.test.ts::"422 SuaveplanError responds 422"` AND `src/middleware/error-handler.test.ts::"ZodError responds 400 VALIDATION_ERROR"` AND `src/middleware/error-handler.test.ts::"unknown error responds 500 INTERNAL_ERROR"` --- ### Requirement: CORS conditionally exposes X-RateLimit-* headers `createCors(options)` SHALL only include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` in the default `exposeHeaders` when `options.withRateLimitHeaders === true`. Default omits them. #### Scenario: default omits rate-limit headers - **WHEN** `createCors({})` is built and its exposeHeaders is inspected - **THEN** none of `X-RateLimit-Limit/Remaining/Reset` is present #### Scenario: withRateLimitHeaders adds them - **WHEN** `createCors({ withRateLimitHeaders: true })` is built - **THEN** all three rate-limit headers are present **Verified by:** `src/middleware/cors.test.ts::"default omits rate-limit headers"` AND `src/middleware/cors.test.ts::"withRateLimitHeaders adds them"` --- ### Requirement: Health routes are configurable `SuaveApp` SHALL accept `healthRoutes?: { healthPath?: string; readyPath?: string; livePath?: string; enabled?: boolean }`. Default matches upstream (`/health` + `/ready` enabled, no `/live`). `enabled: false` SHALL skip registration entirely. #### Scenario: custom health path - **WHEN** `new SuaveApp({ ..., healthRoutes: { healthPath: "/healthz" } })` is built and `/healthz` is GET'd - **THEN** the response is the standard health payload #### Scenario: disabled skips registration - **WHEN** `new SuaveApp({ ..., healthRoutes: { enabled: false } })` is built and `/health` is GET'd - **THEN** the response is 404 (route not registered) **Verified by:** `src/app/health-routes.test.ts::"custom healthPath responds"` AND `src/app/health-routes.test.ts::"enabled false skips registration"` --- ### Requirement: No global singletons exported The package SHALL NOT export `globalApp`, `getGlobalApp`, `setGlobalApp`, `clearGlobalApp`, `globalEventBus`, or any other module-level mutable state. Each `SuaveApp` instance is independent; multi-app deployments compose freely. #### Scenario: no global accessor in barrel - **WHEN** the package barrel is inspected for `setGlobalApp` and `getGlobalApp` - **THEN** neither symbol is exported **Verified by:** `src/app/app.spec-conformance.test.ts::"no global singleton exports"` --- ### Requirement: Optional single RequestContext AsyncLocalStorage When `app.config.useAsyncLocalStorage === true`, the orchestrator SHALL wrap every request handler in `requestStorage.run({ app, requestId, tenant?, user? }, next)`. `getCurrentRequestContext()` SHALL return that bag from any depth. Default is `false` — no ALS scope created. Replaces upstream's 3 separate ALS instances with 1. #### Scenario: ALS off → getCurrentRequestContext undefined - **WHEN** `useAsyncLocalStorage: false` is set and a request runs - **THEN** `getCurrentRequestContext()` called from inside the handler returns `undefined` #### Scenario: ALS on → context threads through async stack - **WHEN** `useAsyncLocalStorage: true` and a request runs - **THEN** `getCurrentRequestContext()` returns `{ app, requestId, tenant?, user? }` matching what was set up by the middleware **Verified by:** `src/app/request-context.test.ts::"ALS off keeps getCurrentRequestContext undefined"` AND `src/app/request-context.test.ts::"ALS on threads context through async stack"` --- ### Requirement: Every API_* code is thrown from a production path `API_CODES` SHALL define exactly 5 codes (`API_INVALID_CONFIG`, `API_INVALID_STATE`, `API_MIDDLEWARE_INSERT_TARGET_NOT_FOUND`, `API_SHUTDOWN_TIMEOUT`, `API_SERVER_START_FAILED`). Every code SHALL be raised by at least one production call site. **Verified by:** `src/api.spec-conformance.test.ts::"every API_* code is thrown from a production path"` --- ### Requirement: Server-tier package shape with integration tests against all 4 siblings The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration tests SHALL exercise: (a) `SuaveApp` boot with a real `PluginRegistry` containing 3 plugins with deps, (b) tenant middleware wiring through `AppBuilder.tenant`, (c) OpenAPI registry doc emission, (d) migration runner invocation before plugin init. #### Scenario: full boot integration test - **WHEN** `tests/integration/full-boot.test.ts` builds an app with all 4 siblings wired (real registry / real openapi / real tenant / real migrations against an in-memory sqlite) and `.start()` is awaited - **THEN** the app reaches state `running` with all plugins ready and the spec doc is served **Verified by:** `tests/integration/full-boot.test.ts::"boots with all 4 siblings wired"` AND `tests/dep-graph.test.ts::"dependencies match the allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"` --- ### Requirement: SuaveApp seeds canonical host services before plugin init `SuaveApp.initialize()` SHALL call `this._registry.seedServices(...)` immediately before `this._registry.initialize()`. The seeded map SHALL include `app` (the OpenAPIHono instance), `openAPIRegistry`, `logger`, `eventBus`, `clock` always; AND optionally `migrationRunner` (when supplied via `SuaveAppOptions.migrations`) AND `db` (when supplied via the new `SuaveAppOptions.db`). After init, every plugin's `ctx.getService("app")` / `ctx.getService("openAPIRegistry")` / etc. SHALL resolve to the corresponding instance with typed inference via the ServiceRegistry module augmentation. #### Scenario: plugin resolves seeded app via ctx.getService - **WHEN** a SuaveApp is built with a plugin whose `onInit(ctx) { recordApp(ctx.getService("app")) }` runs, and `app.initialize()` is awaited - **THEN** `recordApp` was called with the OpenAPIHono instance the app holds at `this._hono` (referential equality, not a wrapper) #### Scenario: missing migrationRunner option leaves the slot undefined - **WHEN** `new SuaveApp({ ...options /* no migrationRunner */ })` is built and a plugin asks `ctx.getService("migrationRunner")` - **THEN** the result is `undefined` **Verified by:** `src/app/seed-services.test.ts::"plugin resolves seeded app via ctx.getService"` AND `src/app/seed-services.test.ts::"openAPIRegistry / logger / eventBus / clock are seeded"` AND `src/app/seed-services.test.ts::"missing migrationRunner leaves slot undefined"` --- ### Requirement: ServiceRegistry module augmentation declares canonical host slots The package SHALL ship a side-effect-importable module at `./services/registry-augmentation` that declares: ```ts declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { app: OpenAPIHono; openAPIRegistry: OpenAPIRegistry; migrationRunner?: MigrationRunner; db?: SurrealAdapter; logger: LoggerLike; eventBus: EventBusLike; clock: Clock; } } ``` Consumers SHALL get full type inference on `ctx.getService("app")` (returns `OpenAPIHono | undefined`), etc. The augmentation SHALL be auto-loaded by the package root barrel (`src/index.ts` side-effect imports it) so any consumer of `@suaveplan/api` picks it up without an explicit import. Subpath consumers of `@suaveplan/api/services/registry-augmentation` get it directly. #### Scenario: ctx.getService("app") type-narrows to OpenAPIHono | undefined - **WHEN** `expectTypeOf(ctx.getService("app")).toEqualTypeOf()` is asserted in the conformance test - **THEN** the type check passes **Verified by:** `src/services/registry-augmentation.spec-conformance.test.ts::"every host slot type-narrows correctly via expectTypeOf"` AND `src/services/registry-augmentation.test.ts::"side-effect import auto-loads via root barrel"` --- ### Requirement: SuaveAppOptions.db is optional `SuaveAppOptions` SHALL accept an optional `db?: SurrealAdapter` field. When supplied, it's seeded into `ServiceRegistry["db"]`; when omitted, the slot stays `undefined`. The constructor SHALL store it on `this._db` for later seeding. No change to existing options. #### Scenario: db option seeded when provided - **WHEN** `new SuaveApp({ ...config, db: surrealAdapter })` is built and a plugin queries `ctx.getService("db")` - **THEN** the result is `surrealAdapter` (referential) **Verified by:** `src/app/options.test.ts::"db option seeded when provided"` AND `src/app/options.test.ts::"omitting db leaves the slot undefined"`