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`).
|
||||
Reference in New Issue
Block a user