Files
2026-05-27 23:29:53 +01:00

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, 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_<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.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<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 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:

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 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"