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,249 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-tenant (add-api-plugin-tenant)
**Package**: `@suaveplan/api-plugin-tenant`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: createTenantPlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createTenantPlugin(options): PluginDescriptor<TenantPluginConfig>`
that validates `options` against `TenantPluginOptionsSchema` (Zod) at the factory
boundary. Invalid input SHALL raise `TenantPluginError(TENANT_PLUGIN_INVALID_INPUT)`
carrying the `ZodError` as `cause`. The returned descriptor SHALL declare
`name: "tenant"`, the resolved `defaults`, and the 4-phase lifecycle hooks
(`onInit`, `onClose`).
#### Scenario: factory produces a valid descriptor
- **WHEN** `createTenantPlugin({ strategies: [{ type: "header", headerName: "x-tenant-id" }], store: new MemoryTenantStore() })` is called
- **THEN** the returned descriptor satisfies `PluginDescriptor<TenantPluginConfig>` with `name === "tenant"` and `onInit` defined
#### Scenario: invalid options raise TENANT_PLUGIN_INVALID_INPUT
- **WHEN** `createTenantPlugin({} as never)` is called (missing `strategies`)
- **THEN** `TenantPluginError(TENANT_PLUGIN_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createTenantPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise TENANT_PLUGIN_INVALID_INPUT"`
---
### Requirement: onInit composes the resolver chain from declared strategies
The descriptor's `onInit(ctx)` hook SHALL compose a `ChainedTenantResolver`
from the strategy specs in priority order, binding each strategy to a lookup
function derived from the configured `TenantStore`. When `options.cache` is
supplied, the chain SHALL be wrapped with `TenantCache.wrap(chain, cache)`.
When no `store` is supplied, the chain SHALL still be built but every lookup
returns `null`, and `onInit` SHALL log a warning.
#### Scenario: header beats query when both present in chain order
- **WHEN** the strategy list is `[{ type: "header", headerName: "x-tenant-id" }, { type: "query", paramName: "tenant" }]` and a request carries BOTH a header and a query parameter
- **THEN** the resolved tenant matches the header value (chain returns on first hit)
#### Scenario: cache wraps the chain when configured
- **WHEN** `options.cache: { ttlMs: 5000 }` is supplied
- **THEN** the resolver's `name` is `cached(chained)` and repeated resolutions for the same key hit the cache (`tenant_plugin.cache_total{outcome: "hit"}` increments)
**Verified by:** `src/plugin/on-init.test.ts::"resolver chain returns first matching strategy (header beats query)"` AND `src/plugin/on-init.test.ts::"cache wraps the chain when configured"`
---
### Requirement: onInit registers the tenantService slot
After resolver construction, `onInit` SHALL register a `tenantService` entry
in the host `ServiceRegistry` exposing `resolveFrom(c)`, `requireFrom(c)`,
`current(c)`, `getById(id)`, `listAll(query?)`, `createTenant(input)`,
`updateTenant(id, patch)`, `deleteTenant(id)`, and `getResolver()`. The
CRUD methods SHALL throw `TenantPluginError(TENANT_PLUGIN_STORE_NOT_CONFIGURED)`
when no `store` option was supplied.
#### Scenario: tenantService accessible to downstream plugins
- **WHEN** `onInit` completes successfully
- **THEN** `ctx.getService("tenantService")` returns the registered service AND `service.getResolver().name` ends with `chained` or `cached(chained)`
#### Scenario: CRUD without store raises STORE_NOT_CONFIGURED
- **WHEN** `createTenantPlugin({ strategies: [...] })` is wired without `store`, and `service.createTenant({...})` is called
- **THEN** `TenantPluginError(TENANT_PLUGIN_STORE_NOT_CONFIGURED)` is thrown
**Verified by:** `src/services/tenant-service.test.ts::"tenantService surface exposes resolver + CRUD"` AND `src/services/tenant-service.test.ts::"CRUD without store raises TENANT_PLUGIN_STORE_NOT_CONFIGURED"`
---
### Requirement: onInit installs the global tenant-resolving middleware
`onInit` SHALL call `ctx.app.use("*", createTenantMiddleware({ resolver,
required, excludePaths, allowSuspended }))` from `@suaveplan/api-tenant` so
every request gets `c.set("tenant", tenant)` populated. When `required: false`
(default) and no tenant resolves, the middleware leaves `c.get("tenant")` as
`undefined` and downstream code falls back to `NullTenantContext` via
`createTenantContext`. When `required: true` and resolution fails, the
middleware raises `TenantError(TENANT.NOT_FOUND)` (404).
#### Scenario: per-request isolation — different headers resolve to different tenants
- **WHEN** two sequential requests carry distinct `x-tenant-id` values
- **THEN** each handler observes `c.get("tenant")` matching the inbound header value
#### Scenario: required=true with no tenant raises NOT_FOUND
- **WHEN** `required: true` and a request lacks every configured strategy
- **THEN** the middleware raises `TenantError(TENANT.NOT_FOUND)` mapping to HTTP 404
#### Scenario: required=false leaves c.get("tenant") undefined for unresolved requests
- **WHEN** `required: false` (default) and a request lacks every configured strategy
- **THEN** the handler runs AND `createTenantContext(c.get("tenant") ?? NullTenantContext.tenant)` defaults to fail-closed checks
**Verified by:** `src/middleware/tenant.test.ts::"per-request isolation across distinct header values"` AND `src/middleware/tenant.test.ts::"required=true with no tenant raises TENANT.NOT_FOUND"` AND `src/middleware/tenant.test.ts::"required=false leaves c.get(\"tenant\") undefined for unresolved requests"`
---
### Requirement: Management routes mounted only when store is configured
When `options.store` is supplied, `onInit` SHALL register five CRUD routes
under `options.routePrefix` (default `/tenants`) via `definePluginRoutes`:
`GET /tenants`, `POST /tenants`, `GET /tenants/:id`, `PATCH /tenants/:id`,
`DELETE /tenants/:id`. Every body / query / response SHALL flow through Zod
schemas. When `store` is absent, the routes SHALL NOT be registered.
#### Scenario: store configured — CRUD routes are mounted
- **WHEN** `createTenantPlugin({ strategies, store: new MemoryTenantStore() })` initializes against an `OpenAPIRegistry`
- **THEN** `registry.getRoutesByPlugin("tenant")` lists 5 routes under `/tenants`
#### Scenario: store absent — CRUD routes are not mounted
- **WHEN** `createTenantPlugin({ strategies })` (no store) initializes
- **THEN** `registry.getRoutesByPlugin("tenant")` is empty
#### Scenario: routePrefix collision raises TENANT_PLUGIN_ROUTE_PREFIX_COLLISION
- **WHEN** another plugin has already registered routes under the same prefix
- **THEN** `TenantPluginError(TENANT_PLUGIN_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix`
**Verified by:** `src/routes/routes.test.ts::"CRUD routes mount when store is configured"` AND `src/routes/routes.test.ts::"no CRUD routes mount when store is absent"` AND `src/plugin/on-init.test.ts::"routePrefix collision raises TENANT_PLUGIN_ROUTE_PREFIX_COLLISION"`
---
### Requirement: Management routes are RBAC-gated via api-plugin-auth
When `requireRole` from `@suaveplan/api-plugin-auth` is available (the auth
plugin's `authService` is registered in the host registry), the CRUD routes
SHALL be wrapped with `requireRole("admin")` so only principals carrying the
`admin` role can hit them. When `authService` is absent, the plugin SHALL
mount the routes WITHOUT the guard AND log a warning identifying the missing
service so operators know to add their own guard.
#### Scenario: admin role is enforced when authService is registered
- **WHEN** `authService` is registered and a request to `POST /tenants` carries `c.get("user")` with `roles: []`
- **THEN** the response is HTTP 403 with `AUTH.INSUFFICIENT_ROLE`
#### Scenario: warning emitted when authService is missing
- **WHEN** `authService` is NOT registered and the plugin initializes with a store
- **THEN** `ctx.logger.warn` is called with a message naming `authService`
**Verified by:** `src/routes/routes.test.ts::"admin role is enforced when authService is registered"` AND `src/plugin/on-init.test.ts::"warning emitted when authService is missing"`
---
### Requirement: MemoryTenantStore implements TenantStore with slug uniqueness
The package SHALL ship `MemoryTenantStore implements TenantStore`. `create`
SHALL reject inputs whose `slug` collides with an existing tenant with
`TenantPluginError(TENANT_PLUGIN_TENANT_SLUG_TAKEN)`. `update(id, patch)`
SHALL raise `TenantPluginError(TENANT_PLUGIN_TENANT_NOT_FOUND)` when the id
is unknown. `delete(id)` SHALL be soft (sets `status: "deleted"`) and
idempotent.
#### Scenario: create succeeds for a fresh slug
- **WHEN** `store.create({ name, slug })` runs against an empty store
- **THEN** the returned tenant carries the supplied slug AND `getById` returns it
#### Scenario: create rejects duplicate slug
- **WHEN** a second `store.create` reuses an existing `slug`
- **THEN** `TenantPluginError(TENANT_PLUGIN_TENANT_SLUG_TAKEN)` is raised
#### Scenario: delete is soft and idempotent
- **WHEN** `delete(id)` is called twice
- **THEN** the second call does not throw AND `getById(id)?.status === "deleted"`
**Verified by:** `src/store/memory.test.ts::"create succeeds for a fresh slug"` AND `src/store/memory.test.ts::"create rejects duplicate slug"` AND `src/store/memory.test.ts::"delete is soft and idempotent"`
---
### Requirement: OTel instruments cover resolution, cache, and management ops
The package SHALL emit `tenant_plugin.resolutions_total{outcome,strategy}`,
`tenant_plugin.resolution_duration_ms` (histogram),
`tenant_plugin.cache_total{outcome}`, and
`tenant_plugin.management_ops_total{op,outcome}` from the production hot
paths. The instruments SHALL be resolved lazily from the global
`MeterProvider` so tests can install an in-memory provider before the package
is imported.
#### Scenario: resolution increments counter and histogram
- **WHEN** the tenant middleware runs on a request that resolves a tenant
- **THEN** `tenant_plugin.resolutions_total{outcome: "resolved"}` increments AND `tenant_plugin.resolution_duration_ms` records a sample
#### Scenario: cache hit increments cache counter
- **WHEN** the same key is resolved twice with caching enabled
- **THEN** the second call increments `tenant_plugin.cache_total{outcome: "hit"}`
#### Scenario: management op records outcome
- **WHEN** `service.createTenant(input)` succeeds
- **THEN** `tenant_plugin.management_ops_total{op: "create", outcome: "success"}` increments
**Verified by:** `src/otel/instruments.test.ts::"resolution increments counter and histogram"` AND `src/otel/instruments.test.ts::"cache hit increments cache counter"` AND `src/otel/instruments.test.ts::"management op records outcome"`
---
### Requirement: ServiceRegistry augmentation declares tenantService slot
The package SHALL declare `tenantService: TenantService` on
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via a
side-effect-imported module-augmentation file. Importing
`@suaveplan/api-plugin-tenant` SHALL be sufficient to make
`ctx.getService("tenantService")` resolve to `TenantService | undefined`
without any cast.
#### Scenario: tenantService key types as TenantService
- **WHEN** a consumer imports `@suaveplan/api-plugin-tenant` and calls `ctx.getService("tenantService")`
- **THEN** the return type narrows to `TenantService | undefined` at compile time
**Verified by:** `src/api-plugin-tenant.spec-conformance.test.ts::"tenantService key resolves to TenantService type via augmentation"`
---
### Requirement: Side-effect registration of error codes + augmentation from index
`src/index.ts` SHALL side-effect-import `./error-codes/error-codes.js` and
`./services/registry-augmentation.js` so a consumer importing the package
barrel gets the code registry surfaced AND the `ServiceRegistry` slot typed
without authoring a subpath import.
#### Scenario: importing the barrel surfaces error codes + registry slot
- **WHEN** a consumer does `import { createTenantPlugin } from "@suaveplan/api-plugin-tenant"`
- **THEN** `TENANT_PLUGIN_CODES.INVALID_INPUT` is reachable AND `ctx.getService("tenantService")` types correctly
**Verified by:** `src/api-plugin-tenant.spec-conformance.test.ts::"barrel side-effect imports surface error codes and registry slot"`
<!-- markdownlint-enable MD024 -->