Initial Commit
This commit is contained in:
188
packages/api-stack/api/api-tenant/README.md
Normal file
188
packages/api-stack/api/api-tenant/README.md
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user