first commit
This commit is contained in:
121
docs/api-stack/api/api-plugin-tenant/README.md
Normal file
121
docs/api-stack/api/api-plugin-tenant/README.md
Normal 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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
83
docs/api-stack/api/api-plugin-tenant/src/otel/instruments.md
Normal file
83
docs/api-stack/api/api-plugin-tenant/src/otel/instruments.md
Normal 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).
|
||||
@@ -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`.
|
||||
100
docs/api-stack/api/api-plugin-tenant/src/plugin/plugin.md
Normal file
100
docs/api-stack/api/api-plugin-tenant/src/plugin/plugin.md
Normal 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.
|
||||
85
docs/api-stack/api/api-plugin-tenant/src/routes/routes.md
Normal file
85
docs/api-stack/api/api-plugin-tenant/src/routes/routes.md
Normal 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.
|
||||
82
docs/api-stack/api/api-plugin-tenant/src/schemas/options.md
Normal file
82
docs/api-stack/api/api-plugin-tenant/src/schemas/options.md
Normal 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.
|
||||
99
docs/api-stack/api/api-plugin-tenant/src/schemas/routes.md
Normal file
99
docs/api-stack/api/api-plugin-tenant/src/schemas/routes.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
82
docs/api-stack/api/api-plugin-tenant/src/store/memory.md
Normal file
82
docs/api-stack/api/api-plugin-tenant/src/store/memory.md
Normal 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.
|
||||
94
docs/api-stack/api/api-plugin-tenant/src/store/store.md
Normal file
94
docs/api-stack/api/api-plugin-tenant/src/store/store.md
Normal 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.
|
||||
95
docs/api-stack/api/api-plugin-tenant/src/types/types.md
Normal file
95
docs/api-stack/api/api-plugin-tenant/src/types/types.md
Normal 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.
|
||||
Reference in New Issue
Block a user