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