# Capability Spec Delta: api (enterprise-api) **Package**: `@suaveplan/api` **Status**: PROPOSED ## 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"`