189 lines
8.8 KiB
Markdown
189 lines
8.8 KiB
Markdown
# @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
|
|
|