Initial Commit
This commit is contained in:
192
openspec/archive/2026-05-19-add-api-plugin-tenant/proposal.md
Normal file
192
openspec/archive/2026-05-19-add-api-plugin-tenant/proposal.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Add @suaveplan/api-plugin-tenant — Tenant resolution as a first-class api-plugin
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-tenant` (NEW, server-tier)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Spec delta**: ./specs/api-plugin-tenant/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/api-tenant` (resolver chain, `TenantCache`, `Tenant`,
|
||||
`NullTenantContext`) as a runner-agnostic library. Apps composed with the
|
||||
`@suaveplan/api` orchestrator currently have to wire that library by hand —
|
||||
build the resolver chain, wrap with `TenantCache`, install the middleware,
|
||||
remember to register the rate-limit middleware, and (if they expose tenants
|
||||
operationally) hand-author the CRUD routes.
|
||||
|
||||
This package wraps all of that into a single `definePlugin` consumer so apps
|
||||
get tenant resolution + optional tenant management by adding one entry to
|
||||
their plugins array. The plugin is the canonical exemplar of the tenant arm
|
||||
of the 3-way plug-and-play contract (resolver wiring + `tenantService` slot
|
||||
+ optional CRUD routes when a store is configured).
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package at `0.1.0`. ADDED:
|
||||
|
||||
### Plugin factory + descriptor surface
|
||||
|
||||
- `createTenantPlugin(options: TenantPluginOptions): PluginDescriptor<TenantPluginConfig>` —
|
||||
Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`.
|
||||
- `TenantPluginOptions` (validated via `TenantPluginOptionsSchema`):
|
||||
`{ strategies: TenantStrategySpec[]; cache?: TenantCacheConfig; required?: boolean; excludePaths?: string[]; allowSuspended?: boolean; routePrefix?: string (default "/tenants"); store?: TenantStore; logger?: LoggerLike; rateLimit?: { maxRequests: number; windowMs: number; store?: RateLimitStore } }`.
|
||||
- `TenantStrategySpec` — discriminated union covering all 6 resolver strategies:
|
||||
`{ type: "header"; headerName: string }`, `{ type: "subdomain"; baseDomain: string }`,
|
||||
`{ type: "domain" }`, `{ type: "path"; prefix: string }`,
|
||||
`{ type: "jwt"; claimName: string }`, `{ type: "query"; paramName: string }`,
|
||||
`{ type: "cookie"; cookieName: string }`. The plugin composes the lookup function
|
||||
from the configured `TenantStore`.
|
||||
|
||||
### Pluggable `TenantStore`
|
||||
|
||||
- `TenantStore` interface — `getById(id)`, `getBySlug(slug)`, `getByDomain(domain)`,
|
||||
`listAll(query?)`, `create(input)`, `update(id, patch)`, `delete(id)`. Default
|
||||
in-memory implementation `MemoryTenantStore` ships with the package, suitable
|
||||
for tests + small ops. Operators inject SurrealDB / Postgres / Redis-backed
|
||||
stores by implementing the interface.
|
||||
|
||||
### Service registry slot
|
||||
|
||||
- `tenantService` registered via `ctx.registerService("tenantService", ...)`.
|
||||
Surface: `{ resolveFrom(c): Promise<TenantContext>; requireFrom(c): TenantContext;
|
||||
current(c): TenantContext; getById(id): Promise<Tenant | null>; listAll(query?):
|
||||
Promise<Tenant[]>; createTenant(input): Promise<Tenant>; updateTenant(id, patch):
|
||||
Promise<Tenant>; deleteTenant(id): Promise<void>; getResolver(): TenantResolver }`.
|
||||
- ServiceRegistry module augmentation declares the slot so
|
||||
`ctx.getService("tenantService")` types correctly.
|
||||
|
||||
### Global middleware
|
||||
|
||||
- `onInit` installs the tenant-resolving middleware on `ctx.app` (composes with
|
||||
`@suaveplan/api-tenant`'s `createTenantMiddleware`). The middleware uses the
|
||||
composed `ChainedTenantResolver` wrapped in `TenantCache` (when configured)
|
||||
and sets `c.set("tenant", tenant)` per request. When `required: true` and no
|
||||
tenant is resolved, the middleware throws `TENANT.NOT_FOUND` (404 via the
|
||||
carried `statusCode`). When `required: false` (default), unresolved requests
|
||||
carry `c.get("tenant")` as `undefined` and downstream code falls through to
|
||||
`NullTenantContext` semantics via `createTenantContext` helpers.
|
||||
|
||||
### Management routes (mounted only when `store` is supplied)
|
||||
|
||||
When `options.store` is configured, the plugin mounts five OpenAPI routes under
|
||||
`options.routePrefix` (default `/tenants`):
|
||||
|
||||
- `GET /tenants` — list, gated by `requireRole("admin")` from `@suaveplan/api-plugin-auth`
|
||||
- `POST /tenants` — create, admin-gated, idempotent on `slug`
|
||||
- `GET /tenants/:id` — read, admin-gated
|
||||
- `PATCH /tenants/:id` — update, admin-gated
|
||||
- `DELETE /tenants/:id` — soft-delete (sets `status: "deleted"`), admin-gated
|
||||
|
||||
Every route body / query / response is Zod-validated via `@hono/zod-openapi`.
|
||||
Without `requireRole` from `api-plugin-auth` available in the registry, the
|
||||
plugin still mounts the routes but logs a warning that they will run without
|
||||
RBAC (operators are expected to add their own guard via Hono middleware).
|
||||
|
||||
### Errors + observability
|
||||
|
||||
- `TenantPluginError extends SuaveplanError<TenantPluginErrorCode>` via
|
||||
`defineCodes("TENANT_PLUGIN", [...])`. Codes: `INVALID_INPUT`,
|
||||
`STORE_NOT_CONFIGURED`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`,
|
||||
`TENANT_NOT_FOUND`, `TENANT_SLUG_TAKEN`, `RESOLVER_BUILD_FAILED`.
|
||||
- OTel: counter `tenant_plugin.resolutions_total{outcome,strategy}`, histogram
|
||||
`tenant_plugin.resolution_duration_ms`, counter `tenant_plugin.cache_total{outcome}`
|
||||
(hit / miss / negative_hit), counter `tenant_plugin.management_ops_total{op,outcome}`.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./services/tenant-service`, `./middleware/tenant`,
|
||||
`./store/memory`, `./store/store`, `./routes/routes`,
|
||||
`./schemas/options`, `./schemas/routes`, `./error-codes/error-codes`,
|
||||
`./otel/instruments`, `./types/types`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Frontend UI** — backend only.
|
||||
- **SurrealDB store implementation** — the plugin ships the `TenantStore` interface
|
||||
+ `MemoryTenantStore` only. The SurrealDB implementation belongs in a follow-up
|
||||
`@suaveplan/api-plugin-tenant-surrealdb` package, parallel to how Better-Auth
|
||||
ships separately from its SurrealDB adapter.
|
||||
- **Migrations / schema DDL** — there is no fixed schema in this package (every
|
||||
store implements its own persistence). The SurrealDB plugin (follow-up) would
|
||||
own `.surql` files.
|
||||
- **Tenant invitations / members CRUD** — owned by a future `api-plugin-tenant-members`
|
||||
package. The `Tenant` shape carries `enabledPlugins` + `metadata`; member /
|
||||
invitation entities are out of scope here.
|
||||
- **Rate-limit override per route** — the package wires the package-level
|
||||
`tenantRateLimit` from `@suaveplan/api-tenant` if `options.rateLimit` is set.
|
||||
Per-route overrides are application code.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This package is **genesis-original** (no upstream port — the upstream
|
||||
`packages/server/api-plugin-tenant` mixes member + invitation CRUD into a single
|
||||
package; genesis splits those into follow-ups). Design intent draws from:
|
||||
|
||||
- `../suaveplan/enterprise/api-plugin-tenant.md` — enterprise audit (118 lines).
|
||||
- `@suaveplan/api-tenant` shipped (`Tenant`, `TenantResolver`, 6 strategies,
|
||||
`TenantCache`, `NullTenantContext`, `createTenantMiddleware`).
|
||||
- `@suaveplan/api-plugin-auth` shipped (PluginDescriptor + onInit pattern, RBAC
|
||||
middleware `requireRole`).
|
||||
- `@suaveplan/api-plugin` + `@suaveplan/api-openapi` + `@suaveplan/api`
|
||||
(ServiceRegistry, `definePluginRoutes`, host slots).
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| Plugin wiring via `definePlugin()` | enterprise L37 | verbatim |
|
||||
| Zod boundary validation | enterprise L39 | verbatim |
|
||||
| 6 resolver strategies exposed | task spec + api-tenant docs | wraps existing library |
|
||||
| `tenantService` registry slot | task spec | new wiring |
|
||||
| Per-request `c.set("tenant", ctx)` | task spec + api-tenant middleware | composes existing |
|
||||
| Management routes when `store` supplied | task spec | new |
|
||||
| Admin-gated management routes via `requireRole` | enterprise L40 + task spec | uses api-plugin-auth |
|
||||
| Pluggable `TenantStore` | enterprise L4 / task spec | default in-memory |
|
||||
| OTel spans + counters | enterprise L44 | wired to api-tenant resolver chain |
|
||||
| 7 stable `TENANT_PLUGIN_*` codes | enterprise L39 | `defineCodes` |
|
||||
| Subpath exports per feature | AGENTS.md §6 | new |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% coverage threshold | Genesis-wide policy |
|
||||
| `@suaveplan/testing/runner` wrapper for tests | AGENTS.md §7 mandatory |
|
||||
| `NullTenantContext` fail-closed when unresolved | api-tenant already enforces this |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta carries a `**Verified by:**` line pointing
|
||||
at concrete `it(...)` titles. Test files:
|
||||
|
||||
- `src/plugin/plugin.test.ts` — factory boundary + Zod validation.
|
||||
- `src/plugin/on-init.test.ts` — 6-step `onInit` lifecycle, service resolution,
|
||||
route registration, prefix collision, middleware install.
|
||||
- `src/middleware/tenant.test.ts` — per-request resolution, header > query
|
||||
priority, fail-closed when `required`, exclude-paths bypass.
|
||||
- `src/services/tenant-service.test.ts` — service surface (resolveFrom,
|
||||
requireFrom, getById, listAll, CRUD when store wired).
|
||||
- `src/store/memory.test.ts` — `MemoryTenantStore` CRUD + slug uniqueness.
|
||||
- `src/routes/routes.test.ts` — management route schemas + behaviour with /
|
||||
without `requireRole` available.
|
||||
- `src/schemas/options.test.ts` — Zod schema accept/reject.
|
||||
- `src/error-codes/error-codes.test.ts` — every code throwable + reachable.
|
||||
- `src/otel/instruments.test.ts` — counters/histograms populated, real
|
||||
in-memory `MeterProvider`.
|
||||
- `tests/integration/full-boot.test.ts` — end-to-end with a real Hono app +
|
||||
registry + resolver chain + management routes.
|
||||
- `src/api-plugin-tenant.spec-conformance.test.ts` — type-level, side-effect,
|
||||
augmentation pins.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-tenant` to enumerate.
|
||||
|
||||
No existing dependents — net-new package. Will be consumed by app composition
|
||||
(`quickstart`, `defineApp`) and operator-side plugin arrays.
|
||||
|
||||
### Catalog additions required
|
||||
|
||||
None — every dep already pinned in the root catalog
|
||||
(`@hono/zod-openapi`, `@opentelemetry/api`, `hono`, `tslib`, `zod`).
|
||||
@@ -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 -->
|
||||
69
openspec/archive/2026-05-19-add-api-plugin-tenant/tasks.md
Normal file
69
openspec/archive/2026-05-19-add-api-plugin-tenant/tasks.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Tasks — Add @suaveplan/api-plugin-tenant
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-tenant.md` end-to-end
|
||||
- [x] 1.2 Read `@suaveplan/api-tenant` README + `index.ts` (wrapped library)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table
|
||||
- [x] 1.6 Draft `specs/api-plugin-tenant/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes/error-codes.ts` — 7 `TENANT_PLUGIN_*` codes via `defineCodes`
|
||||
- [x] 2.2 `src/types/types.ts` — `TenantPluginOptions`, `TenantPluginConfig`, `TenantStrategySpec`
|
||||
- [x] 2.3 `src/store/store.ts` — `TenantStore` interface (canonical contract)
|
||||
- [x] 2.4 `src/store/memory.ts` — `MemoryTenantStore` default in-memory implementation
|
||||
- [x] 2.5 `src/schemas/options.ts` — Zod schemas for `TenantPluginOptions`
|
||||
- [x] 2.6 `src/schemas/routes.ts` — Zod request/response schemas for CRUD routes
|
||||
- [x] 2.7 `src/services/tenant-service.ts` — `TenantService` interface + factory
|
||||
- [x] 2.8 `src/services/registry-augmentation.ts` — module augmentation for `tenantService`
|
||||
- [x] 2.9 `src/middleware/tenant.ts` — middleware install wrapping `@suaveplan/api-tenant`
|
||||
- [x] 2.10 `src/routes/routes.ts` — `buildTenantRoutes()` returning `PluginRouteEntry[]`
|
||||
- [x] 2.11 `src/plugin/plugin.ts` — `createTenantPlugin` factory + 6-step `onInit`
|
||||
- [x] 2.12 `src/otel/instruments.ts` — counters + histogram via real `MeterProvider`
|
||||
- [x] 2.13 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.14 Sibling `.md` for every implementation file
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests per feature module — every code path covered
|
||||
- [x] 3.2 `src/api-plugin-tenant.spec-conformance.test.ts` — type / observability / augmentation
|
||||
- [x] 3.3 `tests/integration/full-boot.test.ts` — end-to-end resolver chain + CRUD
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }` (or higher for integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples)
|
||||
- [x] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 No CLAUDE.md update needed (CLAUDE.md does not enumerate `api-plugin-*` packages individually)
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome clean
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-tenant`
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-tenant` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-tenant` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-tenant`
|
||||
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts` — exit 0
|
||||
|
||||
## 6. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` where literal-type claims are made
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (options + every CRUD body)
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviours tested on and off (`required: true/false`)
|
||||
- [x] 6.5 OTel instruments emitted from production paths, asserted with real `MeterProvider`
|
||||
- [x] 6.6 Cache hit/miss tested via `TenantCache`
|
||||
- [x] 6.7 No retroactive ticking
|
||||
- [x] 6.8 No `export *` in feature-module barrels
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-tenant/spec.md`
|
||||
- [x] 7.2 Move change folder to `openspec/archive/2026-05-19-add-api-plugin-tenant/`
|
||||
Reference in New Issue
Block a user