15 KiB
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, 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"
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, andapp.initialize()is awaited - THEN
recordAppwas called with the OpenAPIHono instance the app holds atthis._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 asksctx.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:
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<OpenAPIHono | undefined>()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 queriesctx.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"