12 KiB
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, thenapp.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 fromrunning - 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_<KEY_PATH> 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.jsoncontainsserver.port: 3000andSUAVEPLAN_SERVER_PORT=4000is set, thenloadConfig()is awaited - THEN the result has
server.port: 4000
Scenario: yaml extension not tried
- WHEN
loadConfig({ configName: "suaveplan.config" })is called and onlysuaveplan.config.yamlexists - 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<T>(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 requiredname+version+openapi) - THEN an
ApiError(API_INVALID_CONFIG)is thrown withcause 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
myMiddlewareis 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
SuaveplanErrorwithstatusCode: 422and the error handler runs - THEN the response status is 422
Scenario: ZodError responds 400
- WHEN a route throws a
ZodErrorand 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/Resetis 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/healthzis GET'd - THEN the response is the standard health payload
Scenario: disabled skips registration
- WHEN
new SuaveApp({ ..., healthRoutes: { enabled: false } })is built and/healthis 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
setGlobalAppandgetGlobalApp - 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: falseis set and a request runs - THEN
getCurrentRequestContext()called from inside the handler returnsundefined
Scenario: ALS on → context threads through async stack
- WHEN
useAsyncLocalStorage: trueand 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.tsbuilds 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
runningwith 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"