12 KiB
Capability Spec: api-plugin-tenant
Package: @suaveplan/api-plugin-tenant
Status: SHIPPED (folded from add-api-plugin-tenant on 2026-05-19)
Every requirement carries a
**Verified by:**line pointing at the concreteit(...)titles in the package's test suite.
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>withname === "tenant"andonInitdefined
Scenario: invalid options raise TENANT_PLUGIN_INVALID_INPUT
- WHEN
createTenantPlugin({} as never)is called (missingstrategies) - THEN
TenantPluginError(TENANT_PLUGIN_INVALID_INPUT)is thrown withcause 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
nameiscached(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
onInitcompletes successfully - THEN
ctx.getService("tenantService")returns the registered service ANDservice.getResolver().nameends withchainedorcached(chained)
Scenario: CRUD without store raises STORE_NOT_CONFIGURED
- WHEN
createTenantPlugin({ strategies: [...] })is wired withoutstore, andservice.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-idvalues - THEN each handler observes
c.get("tenant")matching the inbound header value
Scenario: required=true with no tenant raises NOT_FOUND
- WHEN
required: trueand 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 anOpenAPIRegistry - 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 withcontext.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
authServiceis registered and a request toPOST /tenantscarriesc.get("user")withroles: [] - THEN the response is HTTP 403 with
AUTH.INSUFFICIENT_ROLE
Scenario: warning emitted when authService is missing
- WHEN
authServiceis NOT registered and the plugin initializes with a store - THEN
ctx.logger.warnis called with a message namingauthService
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
getByIdreturns it
Scenario: create rejects duplicate slug
- WHEN a second
store.createreuses an existingslug - 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 ANDtenant_plugin.resolution_duration_msrecords 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-tenantand callsctx.getService("tenantService") - THEN the return type narrows to
TenantService | undefinedat 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_INPUTis reachable ANDctx.getService("tenantService")types correctly
Verified by: src/api-plugin-tenant.spec-conformance.test.ts::"barrel side-effect imports surface error codes and registry slot"