first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# @suaveplan/api-plugin-tenant - Multi-tenancy plugin for @suaveplan/api. Exposes @suaveplan/api-tenant's resolver chain (header / subdomain / domain / path / JWT / query / cookie) as a first-class definePlugin slot, wires TenantCache, installs the per-request resolving middleware, registers a typed tenantService in the ServiceRegistry, and (when a TenantStore is configured) mounts admin-gated CRUD routes. Server-tier. Composes with @suaveplan/api-plugin-auth's requireRole when present for RBAC on the management routes.
Multi-tenancy plugin for `@suaveplan/api`. Wraps
`@suaveplan/api-tenant`'s resolver chain (header / subdomain / domain /
path / JWT / query / cookie) as a first-class `definePlugin` slot,
wires `TenantCache`, installs the per-request resolving middleware,
registers a typed `tenantService` in the host `ServiceRegistry`, and
(when a `TenantStore` is configured) mounts admin-gated CRUD routes
under a configurable prefix.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-tenant` and the concrete `TenantPluginError` class th…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-tenant` and the concrete `TenantPluginError` class th…
- [middleware](./src/middleware/tenant.md) — Provides `buildTenantPluginMiddleware`, the Hono middleware factory the plugin installs on `ctx.app` during `onInit`. T…
- [tenant](./src/middleware/tenant.md) — Provides `buildTenantPluginMiddleware`, the Hono middleware factory the plugin installs on `ctx.app` during `onInit`. T…
- [otel](./src/otel/instruments.md) — Lazily resolves the OpenTelemetry instruments (`Counter`, `Histogram`) the plugin emits from its production paths. The…
- [instruments](./src/otel/instruments.md) — Lazily resolves the OpenTelemetry instruments (`Counter`, `Histogram`) the plugin emits from its production paths. The…
- [plugin](./src/plugin/plugin.md) — Exposes `createTenantPlugin`, the factory that returns a typed `PluginDescriptor` consumable by the `@suaveplan/api` or…
- [build-resolver](./src/plugin/build-resolver.md) — Composes the strategy specs from the plugin's options into a single `TenantResolver` chain, optionally wrapped with `Te…
- [plugin](./src/plugin/plugin.md) — Exposes `createTenantPlugin`, the factory that returns a typed `PluginDescriptor` consumable by the `@suaveplan/api` or…
- [routes](./src/routes/routes.md) — Builds the five OpenAPI route entries `createTenantPlugin` mounts when a `TenantStore` is configured: `GET /`, `POST /`…
- [routes](./src/routes/routes.md) — Builds the five OpenAPI route entries `createTenantPlugin` mounts when a `TenantStore` is configured: `GET /`, `POST /`…
- [options](./src/schemas/options.md) — Defines the Zod schema validated at the `createTenantPlugin` boundary plus the strategy-spec discriminated-union schema…
- [routes](./src/schemas/routes.md) — Defines the Zod request/response schemas the five management routes mount when the plugin is configured with a `TenantS…
- [registry-augmentation](./src/services/registry-augmentation.md) — Side-effect module that declares the `tenantService` slot on `@suaveplan/api-plugin`'s `ServiceRegistry` interface so c…
- [tenant-service](./src/services/tenant-service.md) — Defines the `TenantService` interface registered into the host `ServiceRegistry` by `createTenantPlugin` and the factor…
- [store](./src/store/store.md) — Defines the canonical `TenantStore` persistence contract every `@suaveplan/api-plugin-tenant` backend honours. The inte…
- [memory](./src/store/memory.md) — Ships the default `MemoryTenantStore` — a synchronous in-memory implementation of the `TenantStore` interface suitable…
- [store](./src/store/store.md) — Defines the canonical `TenantStore` persistence contract every `@suaveplan/api-plugin-tenant` backend honours. The inte…
- [types](./src/types/types.md) — Defines the public option shapes accepted by `createTenantPlugin` and the resolved-config shape stored on the returned…
- [types](./src/types/types.md) — Defines the public option shapes accepted by `createTenantPlugin` and the resolved-config shape stored on the returned…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-tenant
```
## Quick start
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createTenantPlugin, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
const app = defineApp({
plugins: [
createAuthPlugin({ betterAuth: { /* ... */ } }),
createTenantPlugin({
strategies: [
{ type: "header", headerName: "x-tenant-id" },
{ type: "cookie", cookieName: "tenant" },
{ type: "subdomain", baseDomain: "app.example.com" },
{ type: "jwt", claimName: "tenantId" },
{ type: "path", prefix: "/t/" },
{ type: "query", paramName: "tenant" },
],
cache: { ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 10_000 },
required: false,
store: new MemoryTenantStore(),
}),
],
});
```
## What you get
- **Seven resolver strategies** composed in priority order — first
non-null wins. The chain is wrapped with `TenantCache` when a
`cache` config is supplied.
- **Per-request `c.set("tenant", tenant)`** via the inner
`createTenantMiddleware` from `@suaveplan/api-tenant`. Status gating
(`deleted` / `suspended` / `pending`) is enforced before the handler
runs.
- **`tenantService` registry slot** — `resolveFrom`, `requireFrom`,
`current`, `getById`, `listAll`, `createTenant`, `updateTenant`,
`deleteTenant`, `getResolver`.
- **Five CRUD routes** mounted when `store` is supplied:
`GET /tenants`, `POST /tenants`, `GET /tenants/:id`,
`PATCH /tenants/:id`, `DELETE /tenants/:id`. Every body and query
flows through a strict Zod schema, so the unified OpenAPI 3.1 doc
matches the runtime surface.
- **Optional admin RBAC** — when `@suaveplan/api-plugin-auth` is
loaded, the CRUD routes are gated by `requireRole("admin")`. Without
it, the plugin logs a warning so operators do not silently expose
admin endpoints.
- **OpenTelemetry instruments** — `tenant_plugin.resolutions_total`,
`tenant_plugin.resolution_duration_ms`, `tenant_plugin.cache_total`,
`tenant_plugin.management_ops_total`.
- **Seven stable `TENANT_PLUGIN.*` error codes** — surfaced via
`TenantPluginError extends SuaveplanError`.
## Modules
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md)
- [`types/types.md`](./src/types/types.md)
- [`store/store.md`](./src/store/store.md)
- [`store/memory.md`](./src/store/memory.md)
- [`schemas/options.md`](./src/schemas/options.md)
- [`schemas/routes.md`](./src/schemas/routes.md)
- [`services/tenant-service.md`](./src/services/tenant-service.md)
- [`services/registry-augmentation.md`](./src/services/registry-augmentation.md)
- [`middleware/tenant.md`](./src/middleware/tenant.md)
- [`otel/instruments.md`](./src/otel/instruments.md)
- [`plugin/build-resolver.md`](./src/plugin/build-resolver.md)
- [`plugin/plugin.md`](./src/plugin/plugin.md)
- [`routes/routes.md`](./src/routes/routes.md)
## Error codes
`TENANT_PLUGIN.INVALID_INPUT`, `STORE_NOT_CONFIGURED`,
`SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `TENANT_NOT_FOUND`,
`TENANT_SLUG_TAKEN`, `RESOLVER_BUILD_FAILED`.
## License
MIT

View File

@@ -0,0 +1,92 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/api-plugin-tenant`
and the concrete `TenantPluginError` class that wraps each code with a
typed `SuaveplanError` discriminant. Consumers branch programmatically
on `error.code === TENANT_PLUGIN_CODES.<NAME>` instead of matching on
free-form message strings, which keeps error handling stable across
plugin upgrades and supports i18n via the `@suaveplan/error` resolver
hook.
## Features
- Seven `TENANT_PLUGIN.*` codes covering invalid input, missing store
configuration, missing service registry slots, route prefix collision,
tenant-not-found, slug uniqueness violation, and resolver build
failures.
- `TenantPluginError extends SuaveplanError<TenantPluginErrorCode>` so
`instanceof TenantPluginError` narrows `code` to the literal union
type without a cast.
- The constructor is `public` (the base class constructor is
`protected`), so package code AND consumer code may both construct
errors directly when extending the plugin.
## Usage: Basic Example
```ts
import {
TENANT_PLUGIN_CODES,
TenantPluginError,
} from "@suaveplan/api-plugin-tenant";
throw new TenantPluginError(
TENANT_PLUGIN_CODES.INVALID_INPUT,
"expected a non-empty strategies array"
);
```
## Usage: Advanced Example
```ts
import {
TENANT_PLUGIN_CODES,
TenantPluginError,
} from "@suaveplan/api-plugin-tenant";
try {
await service.createTenant({ name: "Acme", slug: "acme" });
} catch (err) {
if (
err instanceof TenantPluginError &&
err.code === TENANT_PLUGIN_CODES.TENANT_SLUG_TAKEN
) {
return c.json(
{ error: "slug-taken", retry: false },
409
);
}
throw err;
}
```
## API Reference
- `TENANT_PLUGIN_CODES` — frozen record of `INVALID_INPUT`,
`STORE_NOT_CONFIGURED`, `SERVICE_NOT_AVAILABLE`,
`ROUTE_PREFIX_COLLISION`, `TENANT_NOT_FOUND`, `TENANT_SLUG_TAKEN`,
`RESOLVER_BUILD_FAILED`. Every value is a string of the form
`TENANT_PLUGIN.<NAME>`.
- `type TenantPluginErrorCode` — string literal union derived from the
values of `TENANT_PLUGIN_CODES`.
- `class TenantPluginError extends SuaveplanError<TenantPluginErrorCode>`
— constructor `(code, message, options?)`. `options.cause` carries the
underlying `Error` or `ZodError`; `options.context` carries
`{ statusCode, ...extras }` consumed by route error handlers.
## Implementation Notes
The registry is built with `defineCodes("TENANT_PLUGIN", [...])` from
`@suaveplan/error` so the namespace prefix is added once and every value
inherits the same shape. Adding a code requires four edits: register it
in this file, document it in the `Features` list, throw it from at least
one production code path (the `conformance` script fails the
build otherwise), and add a regression test in
`error-codes.test.ts`. The `tenantPluginCode` field on the error
instance is redundant with `code` but kept for symmetry with the
canonical `AuthError.authCode` field on `@suaveplan/api-plugin-auth`.

View File

@@ -0,0 +1,86 @@
# `tenant`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `buildTenantPluginMiddleware`, the Hono middleware factory
the plugin installs on `ctx.app` during `onInit`. The factory composes
`createTenantMiddleware` from `@suaveplan/api-tenant` (the canonical
resolver + status-gate implementation) with an outer wrapper that
records OTel resolution timing and outcome for every request. The
result is a single middleware that handles per-request tenant
resolution, fail-closed behaviour, and observability.
## Features
- Delegates the core resolution + status-gate (`deleted` /
`suspended` / `pending`) to the well-tested
`@suaveplan/api-tenant` middleware.
- Wraps every invocation with `performance.now()` timing so the OTel
histogram captures real latency, including the inner middleware's
work.
- Records `tenant_plugin.resolutions_total{outcome, strategy}` with
outcomes `resolved`, `missing`, `error` so dashboards can break
down failures by strategy.
- Returns the inner middleware's response verbatim — no shape changes
— so error handlers see the same `TenantError` they would see if
they called `createTenantMiddleware` directly.
## Usage: Basic Example
```ts
import { buildTenantPluginMiddleware } from "@suaveplan/api-plugin-tenant";
import { headerResolver } from "@suaveplan/api-tenant";
import { Hono } from "hono";
const resolver = headerResolver("x-tenant-id", lookupFn);
const app = new Hono();
app.use("*", buildTenantPluginMiddleware({
resolver,
required: false,
allowSuspended: false,
}));
```
## Usage: Advanced Example
```ts
import { buildTenantPluginMiddleware } from "@suaveplan/api-plugin-tenant";
import { chainResolvers, headerResolver, subdomainResolver, TenantCache } from "@suaveplan/api-tenant";
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", store.byId),
subdomainResolver("app.example.com", store.bySlug)
),
{ ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 10_000 }
);
app.use("*", buildTenantPluginMiddleware({
resolver,
required: true,
allowSuspended: false,
excludePaths: ["/health", "/ready", "/openapi.json", "/public"],
}));
```
## API Reference
- `interface BuildTenantPluginMiddlewareOptions` — `{ resolver,
required, allowSuspended, excludePaths? }`.
- `function buildTenantPluginMiddleware(options): MiddlewareHandler` —
returns a Hono `MiddlewareHandler` ready for `app.use("*", ...)`.
## Implementation Notes
Timing is recorded for every code path including the rethrow path so
the histogram captures slow failures (network errors in the store,
slow JWT verifications, etc.). The outer wrapper does not call
`next()` directly — that responsibility stays inside
`createTenantMiddleware`, which also owns the short-circuit for
`excludePaths`. Splitting responsibilities this way means a future
upgrade of `@suaveplan/api-tenant` (e.g. adding sampling or distributed
tracing) lands automatically without changes to the plugin.

View File

@@ -0,0 +1,83 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazily resolves the OpenTelemetry instruments (`Counter`, `Histogram`)
the plugin emits from its production paths. The bundle is built on
first call and cached; tests call `resetTenantPluginInstruments` after
installing a fresh `MeterProvider` so subsequent calls rebind to the
test exporter. This pattern lets the package work seamlessly in
production (where the global provider is the OTel SDK) and under test
(where the global provider is an `InMemoryMetricExporter`).
## Features
- Four instruments: `resolutionsTotal` (counter),
`resolutionDurationMs` (histogram), `cacheTotal` (counter),
`managementOpsTotal` (counter).
- Lazy initialisation — the meter is resolved on first call so test
polyfills installed before the package loads bind correctly.
- `resetTenantPluginInstruments` clears the cache so a re-bind is
forced; primarily useful in test setup hooks.
## Usage: Basic Example
```ts
import { getTenantPluginInstruments } from "@suaveplan/api-plugin-tenant";
const instruments = getTenantPluginInstruments();
instruments.resolutionsTotal.add(1, { outcome: "resolved", strategy: "header" });
instruments.resolutionDurationMs.record(12.4, { strategy: "header" });
```
## Usage: Advanced Example
```ts
import {
getTenantPluginInstruments,
resetTenantPluginInstruments,
} from "@suaveplan/api-plugin-tenant";
import { metrics } from "@opentelemetry/api";
import { InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics";
// In test setup before the package's other modules load:
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetTenantPluginInstruments();
// Later — assert the counters that were emitted:
await provider.forceFlush();
const collected = exporter.getMetrics();
const resolutions = collected[0]?.scopeMetrics[0]?.metrics.find(
(m) => m.descriptor.name === "tenant_plugin.resolutions_total"
);
console.assert(resolutions !== undefined);
```
## API Reference
- `interface TenantPluginInstruments` — `{ resolutionsTotal: Counter,
resolutionDurationMs: Histogram, cacheTotal: Counter,
managementOpsTotal: Counter }`.
- `function getTenantPluginInstruments(): TenantPluginInstruments` —
returns the cached bundle, building it on first call.
- `function resetTenantPluginInstruments(): void` — drops the cache.
## Implementation Notes
The lazy pattern is essential — calling `metrics.getMeter("...")` at
module top-level binds to whichever `MeterProvider` was installed at
import time. Because Vitest's `setupFiles` run before `src/` modules
import, installing the in-memory provider there guarantees the test
bundle binds correctly. Production callers that install their OTel SDK
late (e.g. behind a feature flag) call `resetTenantPluginInstruments`
once the SDK is up so the plugin rebinds. The instrument names use
`tenant_plugin.` rather than `tenant.` so dashboards can cleanly
distinguish the plugin's emissions from the underlying
`@suaveplan/api-tenant` library's instruments (if any are added later).

View File

@@ -0,0 +1,87 @@
# `build-resolver`
## Purpose
Composes the strategy specs from the plugin's options into a single
`TenantResolver` chain, optionally wrapped with `TenantCache`. Also
contributes two `TenantResolver` implementations
(`QueryTenantResolver`, `CookieTenantResolver`) that
`@suaveplan/api-tenant` does not yet ship — the plugin adds them so
the seven-strategy surface the proposal advertises is complete.
## Features
- Strategy-to-resolver mapping with exhaustive discriminated-union
narrowing — adding a new strategy is a compile error until both
`makeResolver` and `makeLookupFn` are updated.
- Per-strategy lookup-fn binding (`header`/`jwt`/`query`/`cookie` →
`store.getById`; `subdomain`/`path` → `store.getBySlug`; `domain` →
`store.getByDomain`).
- Optional `TenantCache.wrap` with cache-outcome
instrumentation — every resolution increments
`tenant_plugin.cache_total{outcome: "hit" | "miss" |
"negative_hit"}`.
- `QueryTenantResolver` and `CookieTenantResolver` implement
`TenantResolver` directly so they slot into
`chainResolvers(...)` with no special-casing.
## Usage: Basic Example
```ts
import { buildTenantResolver, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
const store = new MemoryTenantStore();
const resolver = buildTenantResolver({
strategies: [{ type: "header", headerName: "x-tenant-id" }],
store,
cache: undefined,
});
console.log(resolver.name); // "chained"
```
## Usage: Advanced Example
```ts
import { buildTenantResolver, MemoryTenantStore, CookieTenantResolver } from "@suaveplan/api-plugin-tenant";
import { chainResolvers } from "@suaveplan/api-tenant";
const store = new MemoryTenantStore();
const resolver = buildTenantResolver({
strategies: [
{ type: "header", headerName: "x-tenant-id" },
{ type: "cookie", cookieName: "tenant" },
{ type: "subdomain", baseDomain: "app.example.com" },
{ type: "query", paramName: "tenant" },
],
store,
cache: { ttlMs: 30_000, negativeTtlMs: 5_000, maxEntries: 5_000 },
});
console.log(resolver.name); // "cached(chained)"
// QueryTenantResolver and CookieTenantResolver can also be used standalone.
const cookieOnly = new CookieTenantResolver("tenant", (id) => store.getById(id));
const compound = chainResolvers(cookieOnly);
```
## API Reference
- `interface BuildTenantResolverOptions` — `{ strategies, store, cache }`.
- `function buildTenantResolver(options): TenantResolver` — composes
the chain.
- `class QueryTenantResolver implements TenantResolver` — constructor
`(paramName, lookupFn)`; `name === "query"`.
- `class CookieTenantResolver implements TenantResolver` — constructor
`(cookieName, lookupFn)`; `name === "cookie"`.
## Implementation Notes
The cache-outcome instrumentation lives in the outer wrapper rather
than inside `TenantCache` itself so the latter's
`@suaveplan/api-tenant` package stays metrics-free (callers compose
their own observability). The wrapper keeps a `Set<string>` of seen
keys to distinguish first-hit from cache-hit; this set grows
indefinitely under heavy churn — operators with very large key spaces
should configure the cache's `maxEntries` and rely on
`TenantCache`'s LRU eviction. The cookie parser is intentionally
minimal — operators with signed-cookie or domain-bound-cookie
requirements implement a dedicated `TenantResolver`.

View File

@@ -0,0 +1,100 @@
# `plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `createTenantPlugin`, the factory that returns a typed
`PluginDescriptor` consumable by the `@suaveplan/api` orchestrator's
`PluginRegistry`. The descriptor's `onInit` executes the canonical
six-step lifecycle: resolve host services, build the resolver chain,
register the `tenantService` slot, install the resolving middleware,
optionally mount the CRUD routes (gated by admin RBAC when
`@suaveplan/api-plugin-auth` is loaded), and install the optional
per-tenant rate-limit middleware.
## Features
- Single-call factory `createTenantPlugin(options)` returning a
`PluginDescriptor<TenantPluginConfig>`. Zod-validated boundary —
invalid options raise `TenantPluginError(INVALID_INPUT)` carrying
the `ZodError`.
- Six-step `onInit` matching the suaveplanlug-and-play contract
(resolver chain, service registration, middleware install, routes,
rate-limit, logging).
- Dynamic, optional integration with `@suaveplan/api-plugin-auth`'s
`requireRole("admin")` so management routes are RBAC-gated when the
auth plugin is loaded; logs a warning when it is not so operators
do not silently expose admin endpoints.
- Idempotent `onClose` clears the captured state so plugin restarts
release references cleanly.
## Usage: Basic Example
```ts
import { createTenantPlugin, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
const plugin = createTenantPlugin({
strategies: [{ type: "header", headerName: "x-tenant-id" }],
store: new MemoryTenantStore(),
});
// Register with @suaveplan/api's PluginRegistry alongside any other plugins.
await registry.register(plugin);
```
## Usage: Advanced Example
```ts
import { createTenantPlugin, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { defineApp } from "@suaveplan/api";
const app = defineApp({
plugins: [
createAuthPlugin({ betterAuth: { /* ... */ } }),
createTenantPlugin({
strategies: [
{ type: "header", headerName: "x-tenant-id" },
{ type: "cookie", cookieName: "tenant" },
{ type: "subdomain", baseDomain: "app.example.com" },
{ type: "jwt", claimName: "tenantId" },
{ type: "path", prefix: "/t/" },
{ type: "query", paramName: "tenant" },
],
cache: { ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 10_000 },
required: true,
allowSuspended: false,
excludePaths: ["/health", "/ready", "/openapi.json"],
routePrefix: "/admin/tenants",
store: new MemoryTenantStore(),
rateLimit: { maxRequests: 1000, windowMs: 60_000 },
}),
],
});
```
## API Reference
- `function createTenantPlugin(options: TenantPluginOptions):
PluginDescriptor<TenantPluginConfig>` — Zod-validated factory. The
returned descriptor declares `name: "tenant"`, version `0.1.0`, and
the four lifecycle hooks the registry exercises.
## Implementation Notes
Auth-plugin integration is a dynamic import inside `onInit` so the
runtime gracefully degrades when the auth plugin is not on the
classpath; the `peerDependenciesMeta.optional` flag in `package.json`
mirrors this so installers do not warn about a missing peer. The
`hasService("authService")` guard is checked before the dynamic
import to avoid the import cost on every boot when auth is not in use.
Route registration uses `definePluginRoutes` so the OpenAPI 3.1
document stays in lock-step with the runtime routing surface; in
contexts without an `openAPIRegistry` (rare, but supported for
embedded use cases) the routes mount on Hono directly with a logged
warning. State captured between phases lives in a closure variable
rather than on the `PluginContext` because the registry rebuilds the
context per hook invocation.

View File

@@ -0,0 +1,85 @@
# `routes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the five OpenAPI route entries `createTenantPlugin` mounts when a
`TenantStore` is configured: `GET /`, `POST /`, `GET /:id`, `PATCH /:id`,
and `DELETE /:id`, each paired with a Hono handler that delegates to
the `TenantService`. Every body, query, and response shape flows
through the Zod schemas from `schemas/routes.ts` so the unified
OpenAPI 3.1 document the host serves stays in lock-step with the
runtime behaviour.
## Features
- Five-route CRUD surface returned as `PluginRouteEntry[]` ready for
`definePluginRoutes`.
- Per-handler Zod validation — bodies, query parameters, and path
params are validated explicitly with `safeParse` so the error
shape is consistent across all five handlers.
- Domain → wire projection via `toResponse` so `Date` fields serialise
as ISO strings and optional `plan` / `metadata` keys are omitted
rather than serialised as `null`.
- `TenantPluginError` instances are translated to JSON `{ success:
false, error: { code, message } }` with the carried `statusCode` so
callers see 404 / 409 / 500 as appropriate.
- Soft-delete success response is `{ success: true }`.
## Usage: Basic Example
```ts
import { buildTenantRoutes, createTenantService, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
import { headerResolver } from "@suaveplan/api-tenant";
const store = new MemoryTenantStore();
const resolver = headerResolver("x-tenant-id", (id) => store.getById(id));
const service = createTenantService({ resolver, store });
const entries = buildTenantRoutes(service);
console.log(entries.length); // 5
```
## Usage: Advanced Example
```ts
import { buildTenantRoutes, createTenantService, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
import { headerResolver } from "@suaveplan/api-tenant";
import { definePluginRoutes, createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { OpenAPIHono } from "@hono/zod-openapi";
const store = new MemoryTenantStore();
const resolver = headerResolver("x-tenant-id", (id) => store.getById(id));
const service = createTenantService({ resolver, store });
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({ info: { title: "demo", version: "0.0.1" } });
const entries = buildTenantRoutes(service);
// Caller is responsible for prefixing the paths and applying any RBAC middleware.
const prefixed = entries.map((e) => ({
...e,
route: { ...e.route, path: `/tenants${e.route.path === "/" ? "" : e.route.path}` },
}));
definePluginRoutes(prefixed, { app, registry, pluginName: "tenant" });
```
## API Reference
- `function buildTenantRoutes(service: TenantService):
PluginRouteEntry[]` — returns five entries. Path templates use
`/{id}` for the resource routes (OpenAPI style), which Hono accepts
via the `app.openapi(route, handler)` integration in
`definePluginRoutes`.
## Implementation Notes
The handlers validate inputs even though `definePluginRoutes` already
wires `c.req.valid(...)` because we want the same handlers to be
runnable on a Hono-only mount (no OpenAPI registry) without losing
boundary validation. Errors are JSON-formatted with explicit status
codes so consumers using a thin error handler do not need to know
plugin-internal error shapes. The list response shape (`{ items,
total }`) is deliberately minimal; consumers needing cursor-based
pagination wire their own variant or subclass the schema.

View File

@@ -0,0 +1,82 @@
# `options`
## Purpose
Defines the Zod schema validated at the `createTenantPlugin` boundary
plus the strategy-spec discriminated-union schema each strategy is
parsed against. Boundary validation catches configuration mistakes at
plugin-construction time — long before a request hits the resolver
chain — and surfaces them as `TenantPluginError(INVALID_INPUT)` with
the original `ZodError` carried as `cause` for developer feedback.
## Features
- `TenantStrategySpecSchema` — discriminated union over the seven
strategy variants. Catches typos in the `type` field, missing
required parameters per variant, and empty strings.
- `TenantPluginOptionsSchema` — full options object validated as a
unit. Applies defaults for `required` (`false`), `allowSuspended`
(`false`), and `routePrefix` (`"/tenants"`).
- Custom `z.custom` validators for `store`, `logger`, and the optional
rate-limit `store` so duck-typing accepts any conforming object
without the schema needing to know the full implementation surface.
- `routePrefix` regex enforces leading `/`, no trailing `/` (unless
the prefix is exactly `/`), and a conservative character set so the
resulting Hono routes are predictable.
## Usage: Basic Example
```ts
import { TenantPluginOptionsSchema } from "@suaveplan/api-plugin-tenant";
const parsed = TenantPluginOptionsSchema.parse({
strategies: [{ type: "header", headerName: "x-tenant-id" }],
});
console.log(parsed.routePrefix); // "/tenants" (default)
```
## Usage: Advanced Example
```ts
import {
TenantPluginOptionsSchema,
TenantStrategySpecSchema,
} from "@suaveplan/api-plugin-tenant";
const strategyResult = TenantStrategySpecSchema.safeParse({
type: "cookie",
cookieName: "tenant",
});
if (!strategyResult.success) {
console.error(strategyResult.error.message);
}
const optionsResult = TenantPluginOptionsSchema.safeParse({
strategies: [],
});
console.assert(!optionsResult.success); // strategies cannot be empty
```
## API Reference
- `TenantStrategySpecSchema: z.ZodDiscriminatedUnion` — accepts any of
the seven strategy specs.
- `TenantPluginOptionsSchema: z.ZodObject` — accepts the full options
object; `parse` throws `ZodError`, `safeParse` returns a discriminated
result.
- `type TenantPluginOptionsInput = z.input<typeof TenantPluginOptionsSchema>` —
pre-default-application input shape.
- `type TenantPluginOptionsResolved = z.output<typeof TenantPluginOptionsSchema>` —
post-default-application output shape.
## Implementation Notes
The `betterAuth`-style escape hatches present in
`@suaveplan/api-plugin-auth`'s options schema are not needed here
because the plugin owns its full surface. The `store`, `logger`, and
`rateLimit.store` slots use `z.custom` rather than a full structural
schema so the validator does not need to enumerate every method —
duck-typing on the few methods callers will actually hit is enough.
This keeps the schema small and the error messages targeted: if a
caller passes a partial store missing `create`, the error names
`getById/create/update/delete` and points to the call site.

View File

@@ -0,0 +1,99 @@
# `routes`
## Purpose
Defines the Zod request/response schemas the five management routes
mount when the plugin is configured with a `TenantStore`. Every body,
query, and response shape flows through these schemas so the OpenAPI
3.1 document published at `/openapi.json` is in lock-step with the
runtime behaviour — operators and SDKs generated from the spec see the
same surface the runtime enforces.
## Features
- `TenantResponseSchema` — wire-shape projection of the domain
`Tenant` (Dates serialised as ISO strings).
- `TenantListResponseSchema` — paginated list response (`items` +
`total`).
- `CreateTenantBodySchema` / `UpdateTenantBodySchema` — strict
body schemas; unknown keys are rejected.
- `TenantIdParamSchema` / `ListTenantsQuerySchema` — path-param and
query schemas with coercion where appropriate (`limit`, `offset`
are coerced from query string numerals).
- Slug validation regex: lowercase alphanumeric + hyphen, starting with
an alphanumeric. Keeps URLs predictable and prevents subtle
case-folding bugs in path-prefix resolution.
## Usage: Basic Example
```ts
import { CreateTenantBodySchema } from "@suaveplan/api-plugin-tenant";
const result = CreateTenantBodySchema.safeParse({
name: "Acme",
slug: "acme",
});
if (!result.success) throw new Error(result.error.message);
```
## Usage: Advanced Example
```ts
import {
CreateTenantBodySchema,
ListTenantsQuerySchema,
TenantResponseSchema,
} from "@suaveplan/api-plugin-tenant";
// Query coercion — limit/offset arrive as strings from the URL.
const query = ListTenantsQuerySchema.parse({
status: "active",
limit: "50",
offset: "0",
});
console.log(query.limit); // number, not string
// Round-trip a Tenant through the response schema.
const wire = TenantResponseSchema.parse({
id: "tenant_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: ["billing"],
domains: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Reject invalid slug at the boundary.
const bad = CreateTenantBodySchema.safeParse({
name: "X",
slug: "ACME!", // uppercase + punctuation
});
console.assert(!bad.success);
```
## API Reference
- `TenantResponseSchema` — single-tenant wire shape.
- `TenantListResponseSchema` — `{ items: TenantResponse[]; total: number }`.
- `CreateTenantBodySchema` — strict object; `name`, `slug` required.
- `UpdateTenantBodySchema` — strict object; all fields optional.
- `TenantIdParamSchema` — `{ id: string }`.
- `ListTenantsQuerySchema` — `{ status?, limit?, offset? }` with
numeric coercion on `limit` and `offset`.
- `DeleteTenantResponseSchema` — `{ success: true }`.
- Type aliases (`CreateTenantBody`, `UpdateTenantBody`, etc.) are
exported for handler signatures.
## Implementation Notes
`strict()` is applied to `CreateTenantBodySchema`, `UpdateTenantBodySchema`,
and the nested settings shape so unknown keys are rejected instead of
silently dropped. This matters because the same shape round-trips
through OpenAPI codegen on the client side — unknown keys on a strict
schema imply the client has drifted, and the right behaviour is to fail
loudly. The slug regex is intentionally conservative so URLs survive
percent-encoding, CDN normalisation, and case-insensitive DNS lookups
without ambiguity.

View File

@@ -0,0 +1,78 @@
# `registry-augmentation`
## Purpose
Side-effect module that declares the `tenantService` slot on
`@suaveplan/api-plugin`'s `ServiceRegistry` interface so consumers
typed against the augmented registry see `ctx.getService("tenantService")`
narrow to `TenantService | undefined` without any cast. Importing the
package barrel (`@suaveplan/api-plugin-tenant`) is sufficient — the
barrel side-effect-imports this file. Consumers that prefer the
explicit dependency may import this subpath directly.
## Features
- Declares the `tenantService: TenantService` slot.
- Re-declares the host-side slots (`app`, `openAPIRegistry`, `logger`,
`eventBus`, `clock`) so the plugin's `onInit(ctx)` resolves them
with full typing even when the consumer has not imported
`@suaveplan/api`. TypeScript merges duplicate declarations, so this
is safe alongside `@suaveplan/api`'s own augmentation.
- Pure type-level — zero runtime cost; the file emits no JavaScript
beyond an empty module.
## Usage: Basic Example
```ts
// Importing the barrel installs the augmentation.
import "@suaveplan/api-plugin-tenant";
declare const ctx: import("@suaveplan/api-plugin").PluginContext;
const service = ctx.getService("tenantService");
// ^? TenantService | undefined
```
## Usage: Advanced Example
```ts
// Explicit subpath import — useful when the consumer wants to avoid
// loading the rest of the package barrel (rare; primarily for type-only
// declaration files).
import type {} from "@suaveplan/api-plugin-tenant/services/tenant-service";
import "@suaveplan/api-plugin-tenant/services/registry-augmentation" with { type: "macro" };
declare const ctx: import("@suaveplan/api-plugin").PluginContext;
const service = ctx.getService("tenantService");
if (service !== undefined) {
const list = await service.listAll({ status: "active" });
console.log(list.length);
}
```
## API Reference
This file contributes no runtime exports. It augments
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface
with:
- `tenantService: TenantService`
- `app: OpenAPIHono`
- `openAPIRegistry: OpenAPIRegistry`
- `logger: LoggerLike`
- `eventBus: EventBusLike`
- `clock: Clock`
## Implementation Notes
The re-declaration of host-side slots is deliberate. TypeScript's
declaration merging means each consumer file ends up with the union of
every augmentation it transitively imports. Without the re-declaration
here, a unit-test file that imports only `@suaveplan/api-plugin-tenant`
(but not `@suaveplan/api`) would type `ctx.getService("app")` as
`never`, which would force every test to cast. The re-declaration is
safe because the slot shapes match exactly — TypeScript treats the
merged declaration as one. The `authService` slot from
`@suaveplan/api-plugin-auth` is intentionally NOT re-declared because
its shape would need a runtime import of `better-auth`'s `Auth` type,
which this plugin does not (and should not) depend on. Consumers that
need typed `authService` import `@suaveplan/api-plugin-auth` directly.

View File

@@ -0,0 +1,88 @@
# `tenant-service`
## Purpose
Defines the `TenantService` interface registered into the host
`ServiceRegistry` by `createTenantPlugin` and the factory that builds
the canonical implementation. Downstream plugins and route handlers
consume the service to resolve the current tenant, exercise CRUD
through the configured `TenantStore`, or fetch the composed
`TenantResolver` for diagnostics. Centralising every tenant operation
behind one service keeps consumers decoupled from the resolver chain's
shape.
## Features
- Request-context accessors — `resolveFrom`, `requireFrom`, `current`
— that hide the difference between a tenant set by the middleware
and one resolved on-demand.
- CRUD methods that throw `TenantPluginError(STORE_NOT_CONFIGURED)`
with a clear message when the plugin was constructed without a
store.
- OTel instrumentation: every CRUD method records
`tenant_plugin.management_ops_total{op, outcome}` for success AND
failure so dashboards capture the full picture.
- `getResolver()` exposes the composed resolver so callers can hand it
to ad-hoc middleware (e.g. WebSocket upgrade handlers that need
tenant context outside the HTTP middleware chain).
- `current(c)` falls back to `NullTenantContext` when no tenant is set,
preserving the fail-closed default.
## Usage: Basic Example
```ts
import { createTenantService, MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
import { headerResolver } from "@suaveplan/api-tenant";
const store = new MemoryTenantStore();
const resolver = headerResolver("x-tenant-id", (id) => store.getById(id));
const service = createTenantService({ resolver, store });
const created = await service.createTenant({ name: "Acme", slug: "acme" });
console.log(created.id);
```
## Usage: Advanced Example
```ts
import { createTenantService } from "@suaveplan/api-plugin-tenant";
import { chainResolvers, headerResolver, subdomainResolver } from "@suaveplan/api-tenant";
const resolver = chainResolvers(
headerResolver("x-tenant-id", (id) => store.getById(id)),
subdomainResolver("app.example.com", (slug) => store.getBySlug(slug))
);
const service = createTenantService({ resolver, store });
app.get("/me", (c) => {
// Already set by middleware — use `current`.
const ctx = service.current(c);
return c.json({ tenantId: ctx.tenantId, isBetaUser: ctx.isFeatureEnabled("betaUI") });
});
app.post("/internal/diagnostic", async (c) => {
// Re-resolve from the inbound headers (header > subdomain).
const ctx = await service.resolveFrom(c);
return c.json({ resolverName: service.getResolver().name, tenantId: ctx.tenantId });
});
```
## API Reference
- `interface TenantService` — see the eight methods declared in
`tenant-service.ts`.
- `interface CreateTenantServiceOptions` — `{ resolver, store? }`.
- `function createTenantService(options): TenantService` — pure
factory; no IO at construction time.
## Implementation Notes
`resolveFrom(c)` re-runs the resolver chain even when the middleware
has already set `c.get("tenant")` — callers use it when they need a
fresh lookup (e.g. revalidating a long-lived WebSocket connection
after a tenant patch). `current(c)` is the cheap accessor that just
projects what middleware already stored. `requireFrom(c)` throws
`TenantPluginError(TENANT_NOT_FOUND)` so route handlers can rely on a
truthy `TenantContext` without nil-checks. Every CRUD failure path
records the `failure` outcome BEFORE re-throwing so dashboards see
the failure even when the consumer swallows the error upstream.

View File

@@ -0,0 +1,82 @@
# `memory`
## Purpose
Ships the default `MemoryTenantStore` — a synchronous in-memory
implementation of the `TenantStore` interface suitable for tests,
ephemeral demos, and very small ops deployments where a database is
overkill. The store enforces slug uniqueness, performs soft-deletes
(by flipping `status` to `"deleted"` rather than evicting the row),
and assigns monotonic ids so test snapshots stay stable.
## Features
- Slug uniqueness enforced on `create` and `update`; collisions raise
`TenantPluginError(TENANT_SLUG_TAKEN)` (HTTP 409).
- Missing-id updates raise `TenantPluginError(TENANT_NOT_FOUND)`
(HTTP 404).
- `delete` is soft (sets `status: "deleted"`) and idempotent — calling
twice or against a missing id is a no-op.
- `listAll` supports optional `status` filter plus `limit` / `offset`
pagination.
- `clear()` and `size` test helpers for assertion convenience.
## Usage: Basic Example
```ts
import { MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
const store = new MemoryTenantStore();
const tenant = await store.create({ name: "Acme", slug: "acme" });
console.log(tenant.id); // "tenant_1"
console.log(await store.getBySlug("acme"));
```
## Usage: Advanced Example
```ts
import { MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
const store = new MemoryTenantStore();
await store.create({
name: "Acme",
slug: "acme",
domains: ["acme.example.com"],
enabledPlugins: ["billing", "vault"],
settings: { features: { betaUI: true } },
});
await store.create({ name: "Bravo", slug: "bravo", status: "pending" });
const active = await store.listAll({ status: "active", limit: 50 });
const byDomain = await store.getByDomain("acme.example.com");
await store.delete(byDomain!.id);
const after = await store.getById(byDomain!.id);
console.assert(after?.status === "deleted");
```
## API Reference
- `class MemoryTenantStore implements TenantStore` — constructor takes
no arguments.
- `getById(id)` / `getBySlug(slug)` / `getByDomain(domain)` — `Promise<Tenant | null>`.
- `listAll(query?)` — `Promise<readonly Tenant[]>`; optional filter on
`status`, `limit`, `offset`.
- `create(input)` — `Promise<Tenant>`; throws `TENANT_SLUG_TAKEN` on
duplicate slug.
- `update(id, patch)` — `Promise<Tenant>`; throws `TENANT_NOT_FOUND`
for unknown ids or `TENANT_SLUG_TAKEN` if the patched slug collides.
- `delete(id)` — `Promise<void>`; soft + idempotent.
- `size` — `number` of stored entries (deleted included).
- `clear()` — `void`; drop everything and reset the id sequence.
## Implementation Notes
The id sequence is monotonic and starts at 1 so test fixtures stay
deterministic across runs (no `crypto.randomUUID` calls). Backing
storage is a single `Map<string, Tenant>` so all operations are O(n)
for `listAll` and `getBySlug` / `getByDomain` — perfectly fine for the
test scale this store targets. Operators running thousands of tenants
should swap in the SurrealDB or Postgres implementations from the
companion packages. The store does NOT broadcast change events; the
plugin emits its own OTel counter via `tenantService` instead.

View File

@@ -0,0 +1,94 @@
# `store`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical `TenantStore` persistence contract every
`@suaveplan/api-plugin-tenant` backend honours. The interface is
deliberately store-agnostic — there are no SurrealDB / Postgres /
Redis-specific affordances — so the same plugin instance works against
in-memory tests, SurrealDB production deployments, or any custom
implementation the operator wires in. CRUD + slug + domain lookup are
the only required operations.
## Features
- Asynchronous everywhere so synchronous in-memory implementations
share the same shape as IO-bound backends.
- Lookup-by-id, by-slug, and by-domain — matching the three lookup keys
the bundled resolver strategies expect (`getById` for header / JWT /
query / cookie; `getBySlug` for path / subdomain; `getByDomain` for
domain).
- `listAll` accepts an optional filter (`status`, `limit`, `offset`)
but does not mandate any specific pagination semantics — backends are
free to ignore or augment.
- Soft-delete semantics are left to the implementation; the bundled
`MemoryTenantStore` flips `status` to `"deleted"`.
## Usage: Basic Example
```ts
import type { TenantStore } from "@suaveplan/api-plugin-tenant";
declare const store: TenantStore;
const tenant = await store.getById("tenant_42");
const created = await store.create({ name: "Acme", slug: "acme" });
```
## Usage: Advanced Example
```ts
import type {
CreateTenantInput,
ListTenantsQuery,
TenantStore,
UpdateTenantInput,
} from "@suaveplan/api-plugin-tenant";
import type { Tenant } from "@suaveplan/api-tenant";
class SurrealTenantStore implements TenantStore {
public constructor(private readonly db: { query: <T>(sql: string, vars: Record<string, unknown>) => Promise<T[]> }) {}
public async getById(id: string): Promise<Tenant | null> {
const rows = await this.db.query<Tenant>(
"SELECT * FROM tenant WHERE id = $id",
{ id }
);
return rows[0] ?? null;
}
public async getBySlug(slug: string): Promise<Tenant | null> { /* ... */ return null; }
public async getByDomain(domain: string): Promise<Tenant | null> { /* ... */ return null; }
public async listAll(query?: ListTenantsQuery): Promise<readonly Tenant[]> { /* ... */ return []; }
public async create(input: CreateTenantInput): Promise<Tenant> { /* ... */ throw new Error("todo"); }
public async update(id: string, patch: UpdateTenantInput): Promise<Tenant> { /* ... */ throw new Error("todo"); }
public async delete(id: string): Promise<void> { /* ... */ }
}
```
## API Reference
- `interface TenantStore` — `getById`, `getBySlug`, `getByDomain`,
`listAll`, `create`, `update`, `delete`. All return `Promise`.
- `interface CreateTenantInput` — required `name` and `slug`; every
other `Tenant` field is optional (defaults are applied by the store
implementation).
- `interface UpdateTenantInput` — all fields optional; the store
applies the patch atomically.
- `interface ListTenantsQuery` — optional `status` filter, `limit`,
and `offset`.
## Implementation Notes
Stores that enforce uniqueness on `slug` (the bundled
`MemoryTenantStore` does) raise `TenantPluginError(TENANT_SLUG_TAKEN)`
on collision so the plugin's CRUD route handler can surface 409. Stores
that do not enforce uniqueness must document the behaviour — concurrent
creates that collide silently are the most common source of bugs in
multi-tenant deployments. The follow-up `api-plugin-tenant-surrealdb`
package will ship a SurrealDB-backed `TenantStore` with the schema and
migrations co-located, mirroring the structure of
`@suaveplan/better-auth-surrealdb` for the auth plugin.

View File

@@ -0,0 +1,95 @@
# `types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public option shapes accepted by `createTenantPlugin` and
the resolved-config shape stored on the returned `PluginDescriptor`. The
file also declares `TenantStrategySpec`, the discriminated union the
plugin uses to map each entry in the operator's strategies array to a
concrete `TenantResolver` implementation. Keeping all option types in a
single module makes the plugin's public contract trivial to audit.
## Features
- `TenantStrategySpec` — discriminated union covering all seven
resolver strategies (header, subdomain, domain, path, JWT, query,
cookie). Each variant carries only the configuration the matching
resolver needs (header name, base domain, path prefix, claim name,
query parameter name, cookie name).
- `TenantPluginOptions` — caller-supplied input. Every field except
`strategies` is optional.
- `TenantPluginConfig` — resolved-after-Zod shape with defaults
applied. The plugin's `defaults` reads this shape.
- `TenantCacheConfig` — passthrough for `TenantCache.wrap` options
(`ttlMs`, `negativeTtlMs`, `maxEntries`).
- `TenantRateLimitConfig` — passthrough for the `tenantRateLimit`
middleware (`maxRequests`, `windowMs`, `store`, `keyPrefix`).
## Usage: Basic Example
```ts
import type { TenantPluginOptions } from "@suaveplan/api-plugin-tenant";
const options: TenantPluginOptions = {
strategies: [
{ type: "header", headerName: "x-tenant-id" },
{ type: "subdomain", baseDomain: "app.example.com" },
],
};
```
## Usage: Advanced Example
```ts
import { MemoryTenantStore } from "@suaveplan/api-plugin-tenant";
import type { TenantPluginOptions } from "@suaveplan/api-plugin-tenant";
const options: TenantPluginOptions = {
strategies: [
{ type: "header", headerName: "x-tenant-id" },
{ type: "cookie", cookieName: "tenant" },
{ type: "jwt", claimName: "tenantId" },
{ type: "path", prefix: "/t/" },
{ type: "query", paramName: "tenant" },
{ type: "subdomain", baseDomain: "app.example.com" },
{ type: "domain" },
],
cache: { ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 10_000 },
required: false,
allowSuspended: false,
excludePaths: ["/public"],
routePrefix: "/admin/tenants",
store: new MemoryTenantStore(),
rateLimit: { maxRequests: 1000, windowMs: 60_000 },
};
```
## API Reference
- `interface TenantStrategySpec` — seven-variant union; the `type`
discriminant is one of `"header" | "subdomain" | "domain" | "path" |
"jwt" | "query" | "cookie"`.
- `interface TenantPluginOptions` — see fields above.
- `interface TenantPluginConfig` — resolved variant with defaults
applied (`required` defaults to `false`, `routePrefix` to
`"/tenants"`, `allowSuspended` to `false`).
- `interface TenantCacheConfig` — `ttlMs` is required; `negativeTtlMs`
and `maxEntries` are optional.
- `interface TenantRateLimitConfig` — `maxRequests` and `windowMs` are
required; `store` and `keyPrefix` are optional.
## Implementation Notes
The strategy union is intentionally closed (no `string`-typed escape
hatch) so the plugin can exhaustively narrow inside `makeResolver`
without a runtime registry. Operators with bespoke needs implement
`TenantResolver` directly and bypass the plugin's strategies array,
mounting their middleware via `app.use("*", ...)` ahead of the plugin's.
The defaults follow the principle of least surprise: `required` is
`false` so unauthenticated / unmatched requests can still hit
non-tenanted routes, and `allowSuspended` is `false` so suspended
tenants fail closed unless the operator explicitly opts in.