Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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