Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
# @suaveplan/api-tenant - Multi-tenant resolution + scoping layer for the Suaveplan API. Ships a canonical Tenant type with role/permission helpers, six resolver strategies plus ChainedTenantResolver, a pluggable TenantCache with TTL + negative-entry cache + request coalescing, fail-closed NullTenantContext, a single-context-key Hono middleware (Hono ContextVariableMap augmentation), requirePlugin/requireFeature fail-closed guards, pluggable tenantRateLimit with bounded MemoryRateLimitStore (LRU eviction), TenantAwarePluginDescriptor with tenantScopes opt-out, and opt-in AsyncLocalStorage threading. Server-tier — runs in Bun and Node.
Multi-tenant resolution + scoping for the Suaveplan API. Server-tier —
runs on Bun and Node, depends on `hono` and `node:async_hooks`.
<!-- begin:auto-docs -->
## Documentation tree
- [cache](./src/cache/cache.md) — The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
- [cache](./src/cache/cache.md) — The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
- [context](./src/context/context.md) — The `context` module ships `createTenantContext(tenant)` — a pure factory that wraps a resolved `Tenant` in an ergonomi…
- [context](./src/context/context.md) — The `context` module ships `createTenantContext(tenant)` — a pure factory that wraps a resolved `Tenant` in an ergonomi…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the seven stable `TENANT.*` failure codes shipped by `@suaveplan/api-tenant` and the c…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the seven stable `TENANT.*` failure codes shipped by `@suaveplan/api-tenant` and the c…
- [lifecycle](./src/lifecycle/lifecycle.md) — The `lifecycle` module ships `TenantLifecycle`, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
- [lifecycle](./src/lifecycle/lifecycle.md) — The `lifecycle` module ships `TenantLifecycle`, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
- [middleware](./src/middleware/middleware.md) — The `middleware` module ships `createTenantMiddleware(options)` — the Hono middleware that resolves the inbound tenant,…
- [middleware](./src/middleware/middleware.md) — The `middleware` module ships `createTenantMiddleware(options)` — the Hono middleware that resolves the inbound tenant,…
- [require-feature](./src/middleware/require-feature.md) — `requireFeature(name)` returns a Hono middleware that gates route access on the boolean at `tenant.settings.features?.[…
- [require-plugin](./src/middleware/require-plugin.md) — `requirePlugin(name)` returns a Hono middleware that gates route access on `tenant.enabledPlugins.includes(name)`. The…
- [rate-limit](./src/rate-limit/rate-limit.md) — The `rate-limit` module ships `tenantRateLimit(options)` — a Hono middleware that throttles requests per resolved tenan…
- [rate-limit](./src/rate-limit/rate-limit.md) — The `rate-limit` module ships `tenantRateLimit(options)` — a Hono middleware that throttles requests per resolved tenan…
- [resolver](./src/resolver/resolver.md) — The `resolver` module ships the six built-in `TenantResolver` implementations consumed by `createTenantMiddleware` plus…
- [resolver](./src/resolver/resolver.md) — The `resolver` module ships the six built-in `TenantResolver` implementations consumed by `createTenantMiddleware` plus…
- [storage](./src/storage/storage.md) — The `storage` module ships the opt-in `AsyncLocalStorage` thread for the resolved tenant. defaults the thread O…
- [storage](./src/storage/storage.md) — The `storage` module ships the opt-in `AsyncLocalStorage` thread for the resolved tenant. defaults the thread O…
- [types](./src/types/tenant.md) — The `tenant` module ships the single canonical `Tenant` interface consumed by every other module in `@suaveplan/api-ten…
- [tenant](./src/types/tenant.md) — The `tenant` module ships the single canonical `Tenant` interface consumed by every other module in `@suaveplan/api-ten…
<!-- end:auto-docs -->
## What you get
- `Tenant` — one canonical entity. `c.set("tenant", tenant)` is the
only context key the middleware writes; the Hono `ContextVariableMap`
augmentation gives `c.get("tenant")` a typed `Tenant` with no cast.
- Six resolver strategies — `HeaderTenantResolver`,
`SubdomainTenantResolver`, `DomainTenantResolver`,
`PathTenantResolver`, `JWTTenantResolver`, `ChainedTenantResolver`.
A `TimeoutTenantResolver` wraps any resolver with a millisecond
timeout that surfaces as `TenantError(TENANT_LOOKUP_TIMEOUT)`.
- `TenantCache.wrap(resolver, options)` — TTL + negative-entry cache
- in-flight request coalescing + LRU eviction at `maxEntries`.
- `createTenantMiddleware({ resolver, required?, excludePaths?,
allowSuspended?, onError?, onResolved? })` — Hono middleware that
applies the status gate (`deleted → TENANT_NOT_FOUND`, `suspended →
TENANT_SUSPENDED`, `pending → TENANT_PENDING`).
- `requirePlugin(name)` / `requireFeature(name)` — fail-closed guards.
No tenant resolved → `TENANT_NOT_FOUND`. Missing entitlement →
`TENANT_INVALID_INPUT`.
- `tenantRateLimit({ maxRequests, windowMs, store? })` with the
pluggable `RateLimitStore` interface; default `MemoryRateLimitStore`
is bounded by `maxEntries` with LRU eviction.
- `TenantAwarePluginDescriptor` + `TenantLifecycle` — seven tenant
lifecycle hooks dispatched through the `HookExecutor` shared with
`@suaveplan/api-plugin`. `tenantScopes: "all" | "explicit" | "none"`
controls how the events fan out.
- Opt-in `AsyncLocalStorage` thread via `withTenantStorage(true)` +
`tenantStorageMiddleware()`; default OFF so `getCurrentTenant()` is
`undefined` and cannot disagree with `c.get("tenant")`.
- `NullTenantContext` — fail-closed sentinel for tenant-less code
paths. Every predicate returns `false` / `undefined`.
- 7 stable `TENANT.*` error codes via `defineCodes`.
## Quick-start
```ts
import { Hono } from "hono";
import {
chainResolvers,
createTenantMiddleware,
headerResolver,
requirePlugin,
subdomainResolver,
tenantRateLimit,
TenantCache,
TenantError,
} from "@suaveplan/api-tenant";
const app = new Hono();
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", (id) => db.tenants.byId(id)),
subdomainResolver("suaveplan.io", (slug) =>
db.tenants.bySlug(slug)
)
),
{ ttlMs: 30_000, negativeTtlMs: 5_000 }
);
app.use("*", createTenantMiddleware({ resolver }));
app.use("*", tenantRateLimit({ maxRequests: 60, windowMs: 60_000 }));
app.get("/payments", requirePlugin("payment"), (c) =>
c.json({ tenant: c.get("tenant").id })
);
app.onError((err, c) => {
if (err instanceof TenantError) {
return c.json(
{ code: err.code, message: err.message },
err.code === "TENANT.NOT_FOUND" ? 404 : 403
);
}
return c.text("Server error", 500);
});
```
## Cache configuration
```ts
import { TenantCache, headerResolver } from "@suaveplan/api-tenant";
const cached = TenantCache.wrap(
headerResolver("x-tenant-id", lookup),
{
ttlMs: 60_000,
negativeTtlMs: 5_000,
maxEntries: 10_000,
keyFn: (c) => c.req.header("x-tenant-id") ?? "",
clock: () => Date.now(),
}
);
```
## Pluggable rate-limit store
```ts
import {
type RateLimitStore,
tenantRateLimit,
} from "@suaveplan/api-tenant";
const redisStore: RateLimitStore = {
async get(key) {
const raw = await redis.get(key);
return raw ? JSON.parse(raw) : undefined;
},
async set(key, value) {
await redis.set(key, JSON.stringify(value), "PX", value.resetAt);
},
async delete(key) {
await redis.del(key);
},
};
app.use("*", tenantRateLimit({
maxRequests: 1000,
windowMs: 60_000,
store: redisStore,
keyPrefix: "rl:v1",
}));
```
## Opt-in AsyncLocalStorage
```ts
import {
getCurrentTenant,
tenantStorageMiddleware,
withTenantStorage,
} from "@suaveplan/api-tenant";
withTenantStorage(true);
app.use("*", tenantStorageMiddleware());
async function deep() {
const tenant = getCurrentTenant();
return tenant?.id ?? "anonymous";
}
```
When `withTenantStorage` is `false` (the default), `getCurrentTenant()`
always returns `undefined`. This is deliberate — it eliminates the
upstream dual-access-path footgun where ALS state could diverge from
`c.get("tenant")`.
## Error codes
`TENANT_NOT_FOUND`, `TENANT_SUSPENDED`, `TENANT_PENDING`,
`TENANT_RESOLUTION_FAILED`, `TENANT_RATE_LIMITED`,
`TENANT_INVALID_INPUT`, `TENANT_LOOKUP_TIMEOUT`. All thrown as
`TenantError extends SuaveplanError<TenantErrorCode>`.
## License
MIT

View File

@@ -0,0 +1,86 @@
# cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware
in-process cache that prevents lookup stampedes. `TenantCache.wrap(resolver,
options)` returns a new `TenantResolver` that holds positive entries for
`ttlMs`, negative entries (no-tenant) for `negativeTtlMs`, evicts the
oldest entry when the bounded `maxEntries` cap is hit, and coalesces
concurrent in-flight lookups for the same key into a single `Promise` so
a burst of requests hitting an unmatched key fires `lookupFn` exactly
once.
The cache is intentionally in-process and bounded — the design trades a
distributed-cache hit-rate for predictable memory + zero deployment
state. Callers wanting Redis or another shared store can keep
`TenantCache.wrap` in front of a custom resolver that reads from there;
the cache wrapper is a separable concern from the persistence layer.
## Features
- TTL for positive entries (`ttlMs`) and negative entries
(`negativeTtlMs`, defaults to `ttlMs / 4`).
- Bounded by `maxEntries` (default 1000), oldest-first eviction
using the natural insertion-order semantics of `Map`.
- In-flight request coalescing via an internal `Map<key, Promise>` so
100 parallel hits for the same key fire `lookupFn` once.
- Pluggable `keyFn(c)` — defaults to `host + path`, falling back to
`x-tenant-id` header, finally to bare path. Override per app.
- Injectable `clock` — `FakeClock` from `@suaveplan/testing` works.
- LRU touch-on-hit — accessing a cached entry re-inserts it so the
oldest-evicted entry is the genuinely least-recently used one.
## Usage: Basic
```ts
import { TenantCache, headerResolver } from "@suaveplan/api-tenant";
const inner = headerResolver("x-tenant-id", (id) => db.tenants.get(id));
const cached = TenantCache.wrap(inner, { ttlMs: 30_000 });
```
## Usage: Advanced
Pinning a deterministic clock and overriding the key derivation for a
subdomain resolver:
```ts
import { TenantCache, subdomainResolver } from "@suaveplan/api-tenant";
import { FakeClock } from "@suaveplan/testing";
const clock = new FakeClock(0);
const cached = TenantCache.wrap(
subdomainResolver("app.com", (slug) => db.tenants.bySlug(slug)),
{
ttlMs: 60_000,
negativeTtlMs: 5_000,
maxEntries: 5_000,
keyFn: (c) => `sub:${c.req.header("host") ?? ""}`,
clock: () => clock.now(),
}
);
```
## API Reference
- `class TenantCache` — static `.wrap(resolver, options): TenantResolver`.
- `class CachedTenantResolver` — concrete class, exposes `.size` and
`.clear()` for tests; not normally constructed directly.
- `interface TenantCacheOptions` — `ttlMs` (required), `negativeTtlMs?`,
`maxEntries?`, `keyFn?`, `clock?`.
- `type CacheClock = () => number`.
## Implementation Notes
`Map` preserves insertion order in JavaScript, so the "oldest" entry
during eviction is the first one yielded by `.keys().next()`. A cache
hit deletes-and-reinserts the entry, promoting it to the most-recent
position — this is full LRU behaviour without a doubly-linked list.
The negative TTL is shorter than the positive TTL by default so a
mistyped tenant slug self-corrects faster than a real one becomes
stale.

View File

@@ -0,0 +1,86 @@
# context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `context` module ships `createTenantContext(tenant)` — a pure
factory that wraps a resolved `Tenant` in an ergonomic helper bag
(`isPluginEnabled`, `getSetting`, `isFeatureEnabled`, `getLimit`) and
the `NullTenantContext` constant used when no tenant resolved. The
middleware does NOT store the helper bag on the Hono context;
`c.set("tenant", tenant)` is the single canonical key per the spec.
Callers needing the helpers construct a context on demand —
`createTenantContext(c.get("tenant"))` — which keeps the
"one-context-key" invariant intact.
`NullTenantContext` is the deliberate suaveplan behaviour flip: every
predicate returns `false` and `getSetting` returns the supplied
default. Upstream's permissive `() => true` allowed tenant-less
requests to silently inherit plugin permissions; the new default
fails closed so the only requests that pass `isPluginEnabled` are
those carrying a real, resolved tenant.
## Features
- `createTenantContext(tenant)` — pure factory, no side effects, no
shared mutable state. Safe to call per request.
- `isPluginEnabled(name)` reads `tenant.enabledPlugins`.
- `getSetting<T>(dotPath, default?)` walks dot-notation through
`tenant.settings`, returning `default` at any missing segment.
- `isFeatureEnabled(name)` reads `tenant.settings.features?.[name]`.
- `getLimit(key)` reads `tenant.settings.limits?.[key]` and returns
`undefined` if not set.
- `NullTenantContext` — frozen, fail-closed implementation.
`isPluginEnabled` always returns `false`; `isFeatureEnabled` always
returns `false`; `getLimit` always returns `undefined`;
`getSetting` always returns `defaultValue` (or `undefined`).
## Usage: Basic
```ts
import { createTenantContext, type Tenant } from "@suaveplan/api-tenant";
const tenant: Tenant = /* … */ undefined as never;
const ctx = createTenantContext(tenant);
if (ctx.isPluginEnabled("payment")) {
/* enable payment routes */
}
```
## Usage: Advanced
Reading deep settings with a typed default and pairing with the
fail-closed null context for tenant-less code paths:
```ts
import {
createTenantContext,
NullTenantContext,
type Tenant,
} from "@suaveplan/api-tenant";
function timezone(t: Tenant | null): string {
const ctx = t ? createTenantContext(t) : NullTenantContext;
return ctx.getSetting<string>("timezone", "UTC") ?? "UTC";
}
```
## API Reference
- `interface TenantContext` — `tenant`, `tenantId`, `isPluginEnabled`,
`getSetting`, `isFeatureEnabled`, `getLimit`.
- `function createTenantContext(tenant: Tenant): TenantContext`.
- `const NullTenantContext: TenantContext` — frozen fail-closed
singleton.
## Implementation Notes
`getSetting` short-circuits on `null`/`undefined` rather than relying
on the optional-chaining operator alone, so a settings tree that
explicitly stores `null` mid-path returns the default instead of
throwing. `NullTenantContext` is `Object.freeze`-d so consumers cannot
mutate the shared singleton — the constant is safe to pass around
without defensive copies.

View File

@@ -0,0 +1,83 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module defines the seven stable `TENANT.*` failure codes
shipped by `@suaveplan/api-tenant` and the concrete `TenantError`
class that carries them. Every public failure path in the package —
resolver lookups, middleware status gating, rate-limit enforcement,
input validation, and lookup timeouts — raises a `TenantError` with
one of these codes so consumers can `switch` on the string value
without depending on a class-instanceof check.
The registry uses `defineCodes("TENANT", [...])` from
`@suaveplan/error`, which freezes the object and produces
`"TENANT.NOT_FOUND"`-style stable identifiers. Each code is sized to a
specific operational outcome so log aggregators and on-call dashboards
can group incidents accurately rather than collapsing every failure
under a single bucket.
## Features
- Exactly seven `TENANT.*` codes: `NOT_FOUND`, `SUSPENDED`, `PENDING`,
`RESOLUTION_FAILED`, `RATE_LIMITED`, `INVALID_INPUT`, `LOOKUP_TIMEOUT`.
- Frozen registry — `Object.isFrozen(TENANT_CODES)` is `true`.
- `TenantError extends SuaveplanError<TenantErrorCode>` — the generic
parameter narrows `.code` from `string` to the discriminated union.
- A duplicate `.tenantCode` field is exposed alongside `.code` so
callers writing structural assertions (`error.tenantCode === ...`)
do not have to widen the parent's type parameter.
- `SuaveplanErrorOptions` flows through to support `cause`, `context`,
redactable payloads, and OTel span-event adapter integration.
## Usage: Basic
```ts
import { TENANT_CODES, TenantError } from "@suaveplan/api-tenant";
throw new TenantError(TENANT_CODES.NOT_FOUND, "Tenant 'acme' not found");
```
## Usage: Advanced
Threading a cause and structured context for OTel ingestion:
```ts
import { TENANT_CODES, TenantError } from "@suaveplan/api-tenant";
try {
await db.query("SELECT * FROM tenants WHERE slug = $1", ["acme"]);
} catch (cause) {
throw new TenantError(
TENANT_CODES.RESOLUTION_FAILED,
"Tenant lookup failed",
{
cause: cause instanceof Error ? cause : new Error(String(cause)),
context: { resolver: "subdomain", input: "acme" },
}
);
}
```
## API Reference
- `TENANT_CODES` — frozen `Record<string, "TENANT.${string}">` mapping
short keys (`NOT_FOUND` etc.) to dotted identifiers.
- `TenantErrorCode` — discriminated string union over
`(typeof TENANT_CODES)[keyof typeof TENANT_CODES]`.
- `class TenantError extends SuaveplanError<TenantErrorCode>` —
constructor `(code, message, options?)`. Public `.tenantCode` field
mirrors `.code`.
## Implementation Notes
The constructor widens visibility to `public` because
`SuaveplanError`'s constructor is `protected` for the base class only.
Every shipped subclass (`PluginError`, `TenantError`, etc.)
follows the same shape so consumers can swap subclasses without
adjusting call sites. The `tenantCode` mirror field avoids forcing
callers to write `error.code as TenantErrorCode` when narrowing.

View File

@@ -0,0 +1,112 @@
# lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `lifecycle` module ships `TenantLifecycle`, a thin dispatcher
that wires the seven tenant lifecycle hooks (`onTenantCreate`,
`onTenantUpdate`, `onTenantDelete`, `onTenantSuspend`,
`onTenantActivate`, `onPluginEnable`, `onPluginDisable`) through the
shared `HookExecutor` from `@suaveplan/api-plugin`. Plugins declare
their interest via a `TenantAwarePluginDescriptor` — a strict superset
of the upstream `PluginDescriptor` adding a `tenantHooks` bag and a
`tenantScopes` opt-out.
`tenantScopes` is the suaveplan upgrade over upstream: a plugin can
declare `"none"` to suppress every tenant lifecycle event (useful for
plugins that own zero per-tenant state), or `"explicit"` to receive
only events for tenants whose `enabledPlugins` list mentions the
plugin name. The default `"all"` matches upstream behaviour.
## Features
- Wraps `HookExecutor` so a hook throwing surfaces as the standard
`PluginError(PLUGIN.HOOK_FAILED)` — no bespoke error class.
- Seven dispatcher methods (`dispatchCreate`, `dispatchUpdate`,
`dispatchDelete`, `dispatchSuspend`, `dispatchActivate`,
`dispatchPluginEnable`, `dispatchPluginDisable`).
- `tenantScopes` opt-out evaluated at dispatch time, not at
registration time — `enabledPlugins` changes during a tenant's
lifetime are honoured without re-registration.
- `contextFactory(pluginName)` indirection — the caller decides how
to build the `PluginContext` (logger, eventBus, signal, services,
config) for each hook invocation.
- `TENANT_LIFECYCLE_HOOK_NAMES` exported as a `readonly tuple` so
consumers can iterate the canonical set.
## Usage: Basic
```ts
import { createHookExecutor } from "@suaveplan/api-plugin";
import { createTenantLifecycle } from "@suaveplan/api-tenant";
const executor = createHookExecutor();
const lifecycle = createTenantLifecycle(executor, (pluginName) =>
pluginContextFor(pluginName)
);
lifecycle.register({
name: "audit",
version: "0.1.0",
tenantHooks: {
async onTenantCreate(ctx, tenant) {
ctx.logger.info("tenant created", { id: tenant.id });
},
},
});
await lifecycle.dispatchCreate(tenant);
```
## Usage: Advanced
Plugin opts out of tenant events except those for tenants that
explicitly enable it:
```ts
import type { TenantAwarePluginDescriptor } from "@suaveplan/api-tenant";
const billing: TenantAwarePluginDescriptor = {
name: "billing",
version: "0.1.0",
tenantScopes: "explicit",
tenantHooks: {
async onTenantSuspend(ctx, tenant, reason) {
await pauseSubscription(tenant.id, reason);
},
async onTenantActivate(ctx, tenant) {
await resumeSubscription(tenant.id);
},
},
};
lifecycle.register(billing);
// Only tenants whose enabledPlugins includes "billing" receive the events.
```
## API Reference
- `class TenantLifecycle` — `register`, `unregister`, seven
`dispatch*` methods.
- `createTenantLifecycle(executor, contextFactory): TenantLifecycle`.
- `interface TenantLifecycleHooks` — `onTenantCreate?, …,
onPluginDisable?`.
- `interface TenantAwarePluginDescriptor<TConfig> extends
PluginDescriptor<TConfig>` — adds `tenantScopes?: "all" |
"explicit" | "none"` and `tenantHooks?: TenantLifecycleHooks`.
- `type TenantScope = "all" | "explicit" | "none"`.
- `const TENANT_LIFECYCLE_HOOK_NAMES: readonly tuple`.
- `type TenantLifecycleHookName` — union of the seven names.
## Implementation Notes
Each registered hook closes over the descriptor name and the
`shouldDispatch` filter so the scope check runs at dispatch time with
the live tenant. The dispatcher methods use `HookExecutor.execute`
(sequential, abort-on-first-throw) rather than `executeAll`
(parallel, swallow rejections) — tenant lifecycle is rare enough that
deterministic ordering and surfaced errors are more valuable than
parallel speed-up.

View File

@@ -0,0 +1,121 @@
# middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `middleware` module ships `createTenantMiddleware(options)` — the
Hono middleware that resolves the inbound tenant, applies the status
gate, and writes the result under the single canonical
`c.set("tenant", tenant)` key. suaveplan deliberately collapses
upstream's three keys (`tenantId`, `tenant`, `tenantContext`) into
one; the helper accessors live in `context.ts` and read from the
same key. The module also augments Hono's `ContextVariableMap` so
`c.get("tenant")` returns a typed `Tenant` with no cast.
Status gating raises typed `TenantError` values: `deleted →
TENANT_NOT_FOUND`, `suspended → TENANT_SUSPENDED` (unless
`allowSuspended`), `pending → TENANT_PENDING`. A throw from the
underlying resolver is wrapped as `TENANT_RESOLUTION_FAILED` with the
original error preserved on `.cause`. An optional `onError(error, c)`
hook lets callers map errors to bespoke responses without losing the
typed contract.
## Features
- Single canonical context key — `c.set("tenant", tenant)` — typed
via the `ContextVariableMap` augmentation.
- Status gate covers all four statuses; `allowSuspended` opt-in lets
read-only paths serve suspended tenants.
- Excluded paths (default `/health, /ready, /docs, /openapi`) bypass
the resolver entirely so liveness probes never block on database
health.
- `onResolved(tenant, c)` callback fires after the gate passes — use
it to attach OTel attributes or per-tenant logger context.
- `onError(error, c)` maps `TenantError` to a `Response`; non-tenant
errors re-throw so the framework's global handler runs.
- `getTenant(c)` and `getTenantId(c)` typed helpers — read the
resolved tenant without `as Tenant` casts.
## Usage: Basic
```ts
import {
chainResolvers,
createTenantMiddleware,
headerResolver,
} from "@suaveplan/api-tenant";
import { Hono } from "hono";
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", (id) =>
db.tenants.get(id)
),
})
);
app.get("/me", (c) => c.json({ tenant: c.get("tenant") }));
```
## Usage: Advanced
Composing a cached chained resolver with an error mapper:
```ts
import {
chainResolvers,
createTenantMiddleware,
headerResolver,
subdomainResolver,
TenantCache,
TenantError,
} from "@suaveplan/api-tenant";
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", lookupById),
subdomainResolver("suaveplan.io", lookupBySlug)
),
{ ttlMs: 30_000 }
);
app.use(
"*",
createTenantMiddleware({
resolver,
allowSuspended: false,
excludePaths: ["/health", "/metrics"],
onError: (error: TenantError, c) =>
c.json({ code: error.code, message: error.message }, 404),
onResolved: (tenant, c) => {
c.header("x-tenant-id", tenant.id);
},
})
);
```
## API Reference
- `createTenantMiddleware(options): MiddlewareHandler`.
- `interface TenantMiddlewareOptions` — `resolver`, `required?`,
`excludePaths?`, `allowSuspended?`, `onError?`, `onResolved?`.
- `getTenant(c): Tenant | undefined`.
- `getTenantId(c): string | undefined`.
- Module augmentation — `declare module "hono" { interface
ContextVariableMap { tenant: Tenant } }`.
## Implementation Notes
Status checks raise BEFORE `c.set` so a suspended tenant never lands
under `c.get("tenant")` — downstream handlers cannot accidentally
serve suspended data. Resolver throws are caught and re-wrapped as
`TENANT_RESOLUTION_FAILED` instead of bubbling up as a raw `Error`
because the framework's global error handler benefits from the
discriminated `code` field. The `tenant: Tenant` ambient declaration
is reachable from anywhere that imports any value from
`@suaveplan/api-tenant`, which is enforced by the side-effect import
in the package root barrel.

View File

@@ -0,0 +1,61 @@
# require-feature
## Purpose
`requireFeature(name)` returns a Hono middleware that gates route
access on the boolean at `tenant.settings.features?.[name]`. The
guard is fail-closed: a request that reached the route without a
resolved tenant raises `TENANT_NOT_FOUND` instead of treating the
missing tenant as a permissive default. The shape mirrors
`requirePlugin` so applications can stack the two guards without
worrying about ordering surprises.
The middleware is intentionally simple: a boolean check against the
`features` map. A real feature-flag engine (percentage rollouts,
multivariate flags, audience targeting) lives in
`@suaveplan/feature-flags`; this guard just reads the slot the
tenant record exposes.
## Features
- Fail-closed on no-tenant — raises `TENANT_NOT_FOUND`.
- Raises `TENANT_INVALID_INPUT` when `features?.[name]` is anything
other than `true` (missing, `false`, or another falsy value).
- Pure — no global state, no side effects.
- Typed via `ContextVariableMap` augmentation, no `as Tenant` cast.
## Usage: Basic
```ts
import { requireFeature } from "@suaveplan/api-tenant";
app.get("/beta/dashboard", requireFeature("beta-dashboard"), handler);
```
## Usage: Advanced
Using a fall-through default to keep the gate boolean while a feature
ramp is in progress:
```ts
import { requireFeature } from "@suaveplan/api-tenant";
// `features.{name} = true` rolls a tenant in.
app.use("/beta/*", requireFeature("beta"));
```
## API Reference
- `requireFeature(featureName: string): MiddlewareHandler`.
- Throws `TenantError(TENANT.NOT_FOUND)` when no tenant resolved.
- Throws `TenantError(TENANT.INVALID_INPUT)` when the feature is
not enabled.
## Implementation Notes
`features?.[name]` is read with optional-chaining so a tenant whose
`settings.features` map is absent (instead of empty) still falls
through to the failure branch rather than crashing. Like
`requirePlugin`, the error code is the shared `TENANT_INVALID_INPUT`
rather than a per-guard code — the seven `TENANT.*` codes are sized
for operational outcomes, not API-specific failure modes.

View File

@@ -0,0 +1,69 @@
# require-plugin
## Purpose
`requirePlugin(name)` returns a Hono middleware that gates route
access on `tenant.enabledPlugins.includes(name)`. The guard is
deliberately fail-closed: a request that reached the route without
a resolved tenant raises `TENANT_NOT_FOUND` rather than silently
passing. This mirrors the `NullTenantContext.isPluginEnabled = false`
default elsewhere in the package — `@suaveplan/api-tenant` never lets
a tenant-less request inherit a tenant-scoped permission.
The middleware is intended to be mounted after `createTenantMiddleware`
in the chain. It does not perform tenant resolution itself; it only
checks the resolved value.
## Features
- Reads `c.get("tenant")` — fully typed via the
`ContextVariableMap` augmentation, no casts.
- Raises `TENANT_NOT_FOUND` with `{ plugin: name }` context when no
tenant is set.
- Raises `TENANT_INVALID_INPUT` with `{ tenantId, plugin }` context
when the plugin is not in `enabledPlugins`.
- Pure middleware — no global state, no logger dependency, safe to
mount per route or globally.
## Usage: Basic
```ts
import { requirePlugin } from "@suaveplan/api-tenant";
app.get("/payments", requirePlugin("payment"), handler);
```
## Usage: Advanced
Stacking with a feature-flag gate so a "payment" plugin can be
enabled without exposing a beta feature:
```ts
import {
requireFeature,
requirePlugin,
} from "@suaveplan/api-tenant";
app.use(
"/payments/*",
requirePlugin("payment"),
requireFeature("payments-v2")
);
```
## API Reference
- `requirePlugin(pluginName: string): MiddlewareHandler`.
- Throws `TenantError(TENANT.NOT_FOUND)` when no tenant resolved.
- Throws `TenantError(TENANT.INVALID_INPUT)` when the plugin is not
enabled.
## Implementation Notes
The error code for "plugin not enabled" is `TENANT_INVALID_INPUT`
rather than a dedicated `TENANT_PLUGIN_DISABLED` because the suaveplan
code budget is fixed at 7 codes and the user-facing failure mode is
"the request asked for something invalid for this tenant" — a 4xx,
not a 5xx. The middleware does not log the failure; that is the
application's choice via `onError` in the upstream `createTenantMiddleware`
or a global Hono error handler.

View File

@@ -0,0 +1,100 @@
# rate-limit
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `rate-limit` module ships `tenantRateLimit(options)` — a Hono
middleware that throttles requests per resolved tenant. The store is
pluggable via the `RateLimitStore` interface; the default
`MemoryRateLimitStore` is bounded by `maxEntries` (default 10 000)
with LRU eviction on overflow so a high-cardinality tenant set
cannot leak memory.
Tenant-less requests pass through untouched — the gate only activates
once `createTenantMiddleware` has populated `c.get("tenant")`.
Exceeding the window returns a 429 with `Retry-After` plus the
standard `X-RateLimit-*` triple. The body carries the
`TENANT.RATE_LIMITED` code so consumers can branch on the typed
identifier rather than the HTTP status alone.
## Features
- Pluggable backing store via `RateLimitStore` interface (`get`,
`set`, `delete`). Adapters for Redis or SQL plug in via the same
shape.
- Default `MemoryRateLimitStore` — bounded by `maxEntries`, LRU
eviction, in-process only.
- Sliding-fixed window — counter resets at `now + windowMs`; the
next request after `resetAt` starts a fresh window.
- 429 response carries `Retry-After`, `X-RateLimit-{Limit,Remaining,
Reset}`, and a JSON body with `code: "TENANT.RATE_LIMITED"`.
- Injectable `clock` — `FakeClock` interop, deterministic tests.
## Usage: Basic
```ts
import { tenantRateLimit } from "@suaveplan/api-tenant";
app.use(
"*",
tenantRateLimit({ maxRequests: 60, windowMs: 60_000 })
);
```
## Usage: Advanced
Plugging in a Redis-backed store and a custom key prefix:
```ts
import {
type RateLimitRecord,
type RateLimitStore,
tenantRateLimit,
} from "@suaveplan/api-tenant";
const redisStore: RateLimitStore = {
async get(key) {
const raw = await redis.get(key);
return raw ? (JSON.parse(raw) as RateLimitRecord) : undefined;
},
async set(key, value) {
await redis.set(key, JSON.stringify(value), "PX", value.resetAt);
},
async delete(key) {
await redis.del(key);
},
};
app.use(
"*",
tenantRateLimit({
maxRequests: 1000,
windowMs: 60_000,
store: redisStore,
keyPrefix: "ratelimit:v1",
})
);
```
## API Reference
- `tenantRateLimit(options): MiddlewareHandler`.
- `interface TenantRateLimitOptions` — `maxRequests`, `windowMs`,
`store?`, `keyPrefix?`, `clock?`.
- `interface RateLimitStore` — `get`, `set`, `delete`.
- `interface RateLimitRecord` — `{ count, resetAt }`.
- `class MemoryRateLimitStore` — public constructor `(maxEntries = 10_000)`,
exposes `.size` for assertions.
## Implementation Notes
`MemoryRateLimitStore.set` deletes-and-reinserts on overwrite to keep
LRU ordering consistent — this guarantees the same key never inflates
the apparent "newness" through repeated updates. The 429 response is
returned (not thrown) so the rest of the middleware chain can short
circuit cleanly; the `TenantError(TENANT_RATE_LIMITED)` instance is
constructed for the message-formatting path and contributes the code
to the conformance gate's "every code raised" check.

View File

@@ -0,0 +1,91 @@
# resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `resolver` module ships the six built-in `TenantResolver`
implementations consumed by `createTenantMiddleware` plus a
`ChainedTenantResolver` that composes them in priority order. Each
resolver reads one piece of the inbound `hono` `Context` (header,
hostname, path, JWT claim) and delegates the actual data fetch to a
caller-supplied `lookupFn` — the package never owns a tenant store,
which keeps the surface usable against in-memory maps, SurrealDB,
Postgres, or anything else.
Resolution returns `null` when no tenant matches. The middleware
treats `null` as "this request is tenant-less" and either fails-closed
or skips the status gate based on the `required` option.
## Features
- `HeaderTenantResolver(headerName, lookupFn)` — one HTTP header.
- `SubdomainTenantResolver(baseDomain, lookupFn)` — strips port and
the literal `www` label.
- `DomainTenantResolver(lookupFn)` — full hostname (minus port) as
the lookup key. Useful for custom-domain mappings.
- `PathTenantResolver(prefix, lookupFn)` — reads the slug immediately
after `prefix`.
- `JWTTenantResolver(claimName, lookupFn)` — reads `c.get("user")`
set by an upstream auth middleware and extracts the configured claim.
- `ChainedTenantResolver(...resolvers)` — invokes children in order,
short-circuits on the first non-null result.
- `TimeoutTenantResolver(inner, timeoutMs)` — raises
`TenantError(TENANT_LOOKUP_TIMEOUT)` when the wrapped resolver does
not settle within `timeoutMs` milliseconds.
- Factory functions (`headerResolver`, `subdomainResolver`, etc.)
for terse call sites; the class form is exposed for callers that
need to subclass.
## Usage: Basic
```ts
import { headerResolver } from "@suaveplan/api-tenant";
const resolver = headerResolver("x-tenant-id", async (id) => db.tenants.get(id));
```
## Usage: Advanced
Composing three strategies into a deterministic priority chain:
```ts
import {
chainResolvers,
headerResolver,
jwtResolver,
subdomainResolver,
} from "@suaveplan/api-tenant";
const lookupBySlug = (slug: string) => db.tenants.bySlug(slug);
const lookupById = (id: string) => db.tenants.byId(id);
const resolver = chainResolvers(
headerResolver("x-tenant-id", lookupById),
jwtResolver("tenantId", lookupById),
subdomainResolver("suaveplan.io", lookupBySlug)
);
```
## API Reference
- `interface TenantResolver { name; resolve(c) }`.
- `type TenantLookupFn = (key: string) => Promise<Tenant | null>`.
- `HeaderTenantResolver`, `SubdomainTenantResolver`,
`DomainTenantResolver`, `PathTenantResolver`, `JWTTenantResolver`,
`ChainedTenantResolver` — classes.
- `headerResolver`, `subdomainResolver`, `domainResolver`,
`pathResolver`, `jwtResolver`, `chainResolvers` — factory functions.
## Implementation Notes
The subdomain resolver intentionally treats `www` as "no subdomain";
the domain resolver treats it as the lookup key. Choose between them
based on whether `www.acme.com` should map to the same tenant as
`acme.com` (`subdomain` chained with a domain fallback) or to a
separate marketing surface (`domain` alone). The JWT resolver narrows
on `typeof tenantId === "string"` so spurious numeric / object claims
fall through to the next resolver in the chain rather than crashing
the lookup.

View File

@@ -0,0 +1,95 @@
# storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `storage` module ships the opt-in `AsyncLocalStorage` thread for
the resolved tenant. defaults the thread OFF — the canonical
access path is `c.get("tenant")` — and exposes `withTenantStorage(true)`
plus the matching `tenantStorageMiddleware()` for applications that
legitimately need to read the tenant from deep async code without
threading the Hono `Context` through every function signature.
This eliminates the upstream dual-access-path footgun where
`c.get("tenant")` and `getCurrentTenant()` could disagree. With ALS
off, `getCurrentTenant()` always returns `undefined`, so any code
that "accidentally" relied on the thread surfaces immediately rather
than silently picking up stale state from a previous request.
## Features
- Opt-in via `withTenantStorage(true)` — defaults to `false`.
- `tenantStorageMiddleware()` — pass-through when ALS off, wraps
`next()` in `tenantStorage.run(tenant, ...)` when ALS on.
- `getCurrentTenant()` / `getCurrentTenantId()` — read the threaded
value, `undefined` when off or outside a scope.
- `runWithTenant(tenant, fn)` — explicit scope helper for background
jobs spawned outside a request lifecycle.
- `isTenantStorageEnabled()` — inspect the toggle (primarily for tests).
- `node:async_hooks` import is bare so bundlers route it to the Node
runtime; safe on Bun (which provides a compatible implementation).
## Usage: Basic
```ts
import {
createTenantMiddleware,
headerResolver,
tenantStorageMiddleware,
withTenantStorage,
getCurrentTenant,
} from "@suaveplan/api-tenant";
withTenantStorage(true);
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", lookup),
})
);
app.use("*", tenantStorageMiddleware());
async function deepCall(): Promise<string> {
return getCurrentTenant()?.id ?? "anonymous";
}
```
## Usage: Advanced
Background job that spawns from a queue worker and needs the tenant
context inside an inner async boundary:
```ts
import { runWithTenant, withTenantStorage } from "@suaveplan/api-tenant";
withTenantStorage(true);
async function processJob(job: { tenant: Tenant; payload: unknown }) {
return runWithTenant(job.tenant, async () => {
await runUserDefinedHandler(job.payload);
});
}
```
## API Reference
- `withTenantStorage(enabled: boolean): void` — toggle.
- `tenantStorageMiddleware(): MiddlewareHandler`.
- `getCurrentTenant(): Tenant | undefined`.
- `getCurrentTenantId(): string | undefined`.
- `runWithTenant<T>(tenant: Tenant, fn: () => T): T`.
- `isTenantStorageEnabled(): boolean`.
## Implementation Notes
The toggle is a module-level boolean rather than a per-instance flag
because `AsyncLocalStorage` itself is a process-wide construct — two
parallel `enabled=true` regions would conflict regardless of how the
flag is stored. The middleware short-circuits in three places (ALS
disabled, no tenant resolved, or non-Hono caller) so the cost when
disabled is two cheap reads plus the call to `next()`. The
`runWithTenant` helper is the one piece that runs `fn` synchronously
when ALS is off, keeping the call signature stable across both modes.

View File

@@ -0,0 +1,96 @@
# tenant
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `tenant` module ships the single canonical `Tenant` interface
consumed by every other module in `@suaveplan/api-tenant`. suaveplan
collapses upstream's `TenantConfig` and `TenantEventConfig` duplicates
into one shape so resolvers, middleware, lifecycle hooks, and consumer
plugins all read from the same record stored under
`c.set("tenant", tenant)`.
The module also exposes the supporting role / permission taxonomy
(`TenantRole`, `TenantRolePermissions`) and the `hasPermission` helper
the request-time middleware and route-guards use to authorise actions.
The owner wildcard `"*"` short-circuits every permission check; every
other role carries an explicit permission list so additions surface in
a single audit-friendly spot rather than being scattered across the
codebase.
## Features
- Single canonical `Tenant` interface (id, name, slug, status, plan,
settings, enabledPlugins, domains, metadata, createdAt, updatedAt).
- `TenantStatus = "active" | "suspended" | "pending" | "deleted"` —
the literal union the middleware status gate switches on.
- `TenantRole = "owner" | "admin" | "member" | "viewer"` plus a
`TenantRolePermissions` map; `owner` carries the wildcard.
- `hasPermission(role, permission)` — pure, no I/O, ~constant time.
- Optional `TenantLimits`, `TenantBranding`, `TenantMember`,
`TenantInvitation` companion types covering common per-tenant
surfaces without committing to a CRUD endpoint here.
## Usage: Basic
```ts
import { hasPermission, type Tenant } from "@suaveplan/api-tenant";
const tenant: Tenant = {
id: "t_01",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: ["payment", "analytics"],
domains: ["acme.suaveplan.io"],
createdAt: new Date(),
updatedAt: new Date(),
};
if (hasPermission("admin", "tenant:update")) {
/* perform update */
}
```
## Usage: Advanced
Building a route guard that pairs role and feature gates:
```ts
import {
hasPermission,
type Tenant,
type TenantRole,
} from "@suaveplan/api-tenant";
function canEditBranding(role: TenantRole, tenant: Tenant): boolean {
return (
hasPermission(role, "settings:update") &&
tenant.settings.features?.["custom-branding"] === true
);
}
```
## API Reference
- `interface Tenant` — canonical entity, single source of truth.
- `type TenantStatus`, `TenantRole`.
- `interface TenantLimits`, `TenantBranding`, `TenantSettings`,
`TenantMember`, `TenantInvitation`.
- `const TenantRolePermissions: Record<TenantRole, readonly string[]>`.
- `function hasPermission(role: TenantRole, permission: string): boolean`.
## Implementation Notes
`TenantRolePermissions` is typed `readonly string[]` so consumers
cannot mutate the shared registry. `owner`'s entry is intentionally a
1-element array containing `"*"` rather than enumerating every action —
the wildcard short-circuits the permission check, eliminating the
maintenance hazard of forgetting to add a new permission to the
owner list. The `settings.features?.[name]` slot is a plain boolean
map; a real feature-flag engine ships separately in
`@suaveplan/feature-flags`.