Files
suaveplan/openspec/specs/api-plugin-tenant/spec.md
2026-05-27 23:29:53 +01:00

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 concrete it(...) 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> 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"