Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
<!-- markdownlint-disable MD024 -->
# 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_<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"`
<!-- markdownlint-enable MD024 -->