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:
@@ -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
|
||||
|
||||
86
plugins/document-navigator/docs/api-stack/api/api-tenant/src/cache/cache.md
vendored
Normal file
86
plugins/document-navigator/docs/api-stack/api/api-tenant/src/cache/cache.md
vendored
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user