# 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` — 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; requireFrom(c): TenantContext; current(c): TenantContext; getById(id): Promise; listAll(query?): Promise; createTenant(input): Promise; updateTenant(id, patch): Promise; deleteTenant(id): Promise; 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` 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`).