Files
2026-05-27 23:29:53 +01:00

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 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).