9.6 KiB
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 aPluginDescriptorfrom@suaveplan/api-plugin.TenantPluginOptions(validated viaTenantPluginOptionsSchema):{ 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 configuredTenantStore.
Pluggable TenantStore
TenantStoreinterface —getById(id),getBySlug(slug),getByDomain(domain),listAll(query?),create(input),update(id, patch),delete(id). Default in-memory implementationMemoryTenantStoreships with the package, suitable for tests + small ops. Operators inject SurrealDB / Postgres / Redis-backed stores by implementing the interface.
Service registry slot
tenantServiceregistered viactx.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
onInitinstalls the tenant-resolving middleware onctx.app(composes with@suaveplan/api-tenant'screateTenantMiddleware). The middleware uses the composedChainedTenantResolverwrapped inTenantCache(when configured) and setsc.set("tenant", tenant)per request. Whenrequired: trueand no tenant is resolved, the middleware throwsTENANT.NOT_FOUND(404 via the carriedstatusCode). Whenrequired: false(default), unresolved requests carryc.get("tenant")asundefinedand downstream code falls through toNullTenantContextsemantics viacreateTenantContexthelpers.
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 byrequireRole("admin")from@suaveplan/api-plugin-authPOST /tenants— create, admin-gated, idempotent onslugGET /tenants/:id— read, admin-gatedPATCH /tenants/:id— update, admin-gatedDELETE /tenants/:id— soft-delete (setsstatus: "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>viadefineCodes("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}, histogramtenant_plugin.resolution_duration_ms, countertenant_plugin.cache_total{outcome}(hit / miss / negative_hit), countertenant_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
TenantStoreinterfaceMemoryTenantStoreonly. The SurrealDB implementation belongs in a follow-up@suaveplan/api-plugin-tenant-surrealdbpackage, 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
.surqlfiles. - Tenant invitations / members CRUD — owned by a future
api-plugin-tenant-memberspackage. TheTenantshape carriesenabledPlugins+metadata; member / invitation entities are out of scope here. - Rate-limit override per route — the package wires the package-level
tenantRateLimitfrom@suaveplan/api-tenantifoptions.rateLimitis 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-tenantshipped (Tenant,TenantResolver, 6 strategies,TenantCache,NullTenantContext,createTenantMiddleware).@suaveplan/api-plugin-authshipped (PluginDescriptor + onInit pattern, RBAC middlewarerequireRole).@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-steponInitlifecycle, service resolution, route registration, prefix collision, middleware install.src/middleware/tenant.test.ts— per-request resolution, header > query priority, fail-closed whenrequired, exclude-paths bypass.src/services/tenant-service.test.ts— service surface (resolveFrom, requireFrom, getById, listAll, CRUD when store wired).src/store/memory.test.ts—MemoryTenantStoreCRUD + slug uniqueness.src/routes/routes.test.ts— management route schemas + behaviour with / withoutrequireRoleavailable.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-memoryMeterProvider.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).