Initial Commit
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user