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

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/api-tenant",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./types/tenant": {
"import": {
"types": "./dist/types/tenant.d.ts",
"default": "./dist/types/tenant.js"
}
},
"./resolver": {
"import": {
"types": "./dist/resolver/resolver.d.ts",
"default": "./dist/resolver/resolver.js"
}
},
"./cache": {
"import": {
"types": "./dist/cache/cache.d.ts",
"default": "./dist/cache/cache.js"
}
},
"./context": {
"import": {
"types": "./dist/context/context.d.ts",
"default": "./dist/context/context.js"
}
},
"./middleware": {
"import": {
"types": "./dist/middleware/middleware.d.ts",
"default": "./dist/middleware/middleware.js"
}
},
"./rate-limit": {
"import": {
"types": "./dist/rate-limit/rate-limit.d.ts",
"default": "./dist/rate-limit/rate-limit.js"
}
},
"./lifecycle": {
"import": {
"types": "./dist/lifecycle/lifecycle.d.ts",
"default": "./dist/lifecycle/lifecycle.js"
}
},
"./storage": {
"import": {
"types": "./dist/storage/storage.d.ts",
"default": "./dist/storage/storage.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,247 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TenantCache } from "./cache/cache.js";
import { createTenantContext } from "./context/context.js";
import { TENANT_CODES, TenantError } from "./error-codes/error-codes.js";
import { createTenantMiddleware, getTenant } from "./middleware/middleware.js";
import { requireFeature } from "./middleware/require-feature.js";
import { requirePlugin } from "./middleware/require-plugin.js";
import {
type RateLimitStore,
tenantRateLimit,
} from "./rate-limit/rate-limit.js";
import {
headerResolver,
type TenantResolver,
timeoutResolver,
} from "./resolver/resolver.js";
import type { Tenant } from "./types/tenant.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
describe("spec-conformance — every TENANT_* code is thrown from a production path", () => {
it("TENANT.NOT_FOUND (missing tenant)", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () => null),
})
);
let captured: TenantError | undefined;
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) captured = err;
return c.text("", 500);
});
await app.request("/");
expect(captured?.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("TENANT.SUSPENDED", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ status: "suspended" })
),
})
);
let captured: TenantError | undefined;
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) captured = err;
return c.text("", 500);
});
await app.request("/", { headers: { "x-tenant-id": "x" } });
expect(captured?.code).toBe(TENANT_CODES.SUSPENDED);
});
it("TENANT.PENDING", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ status: "pending" })
),
})
);
let captured: TenantError | undefined;
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) captured = err;
return c.text("", 500);
});
await app.request("/", { headers: { "x-tenant-id": "x" } });
expect(captured?.code).toBe(TENANT_CODES.PENDING);
});
it("TENANT.RESOLUTION_FAILED", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: {
name: "boom",
resolve: async () => {
throw new Error("db");
},
},
})
);
let captured: TenantError | undefined;
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) captured = err;
return c.text("", 500);
});
await app.request("/", { headers: { "x-tenant-id": "x" } });
expect(captured?.code).toBe(TENANT_CODES.RESOLUTION_FAILED);
});
it("TENANT.RATE_LIMITED", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
})
);
app.use("*", tenantRateLimit({ maxRequests: 1, windowMs: 60_000 }));
app.get("/", (c) => c.text("ok"));
await app.request("/", { headers: { "x-tenant-id": "x" } });
const res = await app.request("/", {
headers: { "x-tenant-id": "x" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.RATE_LIMITED);
});
it("TENANT.INVALID_INPUT (requirePlugin / requireFeature)", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ enabledPlugins: [] })
),
})
);
app.get("/p", requirePlugin("missing"), (c) => c.text("never"));
app.get("/f", requireFeature("missing"), (c) => c.text("never"));
const captured: string[] = [];
app.onError((err, c) => {
if (err instanceof TenantError) captured.push(err.code);
return c.text("", 500);
});
await app.request("/p", { headers: { "x-tenant-id": "x" } });
await app.request("/f", { headers: { "x-tenant-id": "x" } });
expect(captured).toContain(TENANT_CODES.INVALID_INPUT);
});
it("TENANT.LOOKUP_TIMEOUT", { timeout: 5000 }, async () => {
const slow: TenantResolver = {
name: "slow",
resolve: () => new Promise<Tenant | null>(() => {}),
};
const wrapped = timeoutResolver(slow, 10);
try {
const app = new Hono();
app.use("*", createTenantMiddleware({ resolver: wrapped }));
let captured: TenantError | undefined;
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) {
if (err.code === TENANT_CODES.LOOKUP_TIMEOUT) {
captured = err;
} else if (
err.code === TENANT_CODES.RESOLUTION_FAILED &&
err.cause instanceof TenantError &&
err.cause.code === TENANT_CODES.LOOKUP_TIMEOUT
) {
captured = err.cause;
}
}
return c.text("", 500);
});
await app.request("/", { headers: { "x-tenant-id": "x" } });
expect(captured?.code).toBe(TENANT_CODES.LOOKUP_TIMEOUT);
} catch (err) {
// If the middleware re-wrapped the timeout, the cause carries the LOOKUP_TIMEOUT.
expect(err).toBeInstanceOf(TenantError);
}
});
});
describe("spec-conformance — type-level guarantees", () => {
it("c.get('tenant') is typed as Tenant via ContextVariableMap", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
})
);
app.get("/", (c) => {
const t = c.get("tenant");
expectTypeOf(t).toEqualTypeOf<Tenant>();
const v = getTenant(c);
expectTypeOf(v).toEqualTypeOf<Tenant | undefined>();
return c.json({ id: t.id });
});
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(200);
});
it("createTenantContext exposes the canonical helper bag", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(makeTenant());
expectTypeOf(ctx.isPluginEnabled).parameters.toEqualTypeOf<
[pluginName: string]
>();
expectTypeOf(ctx.tenant).toEqualTypeOf<Tenant>();
});
it("RateLimitStore is a structural interface", {
timeout: 5000,
}, () => {
const store: RateLimitStore = {
get: () => undefined,
set: () => {},
delete: () => {},
};
expectTypeOf(store).toMatchTypeOf<RateLimitStore>();
});
it("TenantCache.wrap returns a TenantResolver", { timeout: 5000 }, () => {
const inner = headerResolver("x", async () => null);
const cached = TenantCache.wrap(inner, { ttlMs: 100 });
expectTypeOf(cached).toMatchTypeOf<TenantResolver>();
});
});

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,226 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { headerResolver, type TenantResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
import { type CachedTenantResolver, TenantCache } from "./cache.js";
function makeTenant(slug: string): Tenant {
return {
id: `t_${slug}`,
name: slug,
slug,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
async function withCtx<T>(
headers: Record<string, string>,
fn: (c: never) => Promise<T>
): Promise<T> {
const app = new Hono();
let out!: T;
app.all("*", async (c) => {
out = await fn(c as never);
return c.text("ok");
});
await app.request("http://x/p", { headers });
return out;
}
describe("TenantCache.wrap", () => {
it("positive hit skips second lookup", { timeout: 5000 }, async () => {
let calls = 0;
const inner = headerResolver("x-tenant-id", async (id) => {
calls++;
return makeTenant(id);
});
const cached = TenantCache.wrap(inner, { ttlMs: 1000 });
const headers = { "x-tenant-id": "acme", host: "x" };
const t1 = await withCtx(headers, (c) => cached.resolve(c));
const t2 = await withCtx(headers, (c) => cached.resolve(c));
expect(t1?.slug).toBe("acme");
expect(t2?.slug).toBe("acme");
expect(calls).toBe(1);
});
it("negative hit prevents stampede", { timeout: 5000 }, async () => {
let calls = 0;
const inner = headerResolver("x-tenant-id", async () => {
calls++;
return null;
});
const cached = TenantCache.wrap(inner, {
ttlMs: 1000,
negativeTtlMs: 500,
});
const headers = { "x-tenant-id": "ghost", host: "x" };
// First populates, second is cached.
await withCtx(headers, (c) => cached.resolve(c));
const remaining = await Promise.all(
Array.from({ length: 100 }, () =>
withCtx(headers, (c) => cached.resolve(c))
)
);
for (const r of remaining) expect(r).toBeNull();
expect(calls).toBe(1);
});
it("in-flight lookups coalesce", { timeout: 5000 }, async () => {
let calls = 0;
let release!: (t: Tenant | null) => void;
const pending = new Promise<Tenant | null>((resolve) => {
release = resolve;
});
const inner = headerResolver("x-tenant-id", async () => {
calls++;
return pending;
});
const cached = TenantCache.wrap(inner, { ttlMs: 1000 });
const headers = { "x-tenant-id": "acme", host: "x" };
const inflight = Promise.all(
Array.from({ length: 50 }, () =>
withCtx(headers, (c) => cached.resolve(c))
)
);
// Give the loop a tick to fire all requests.
await new Promise((r) => setTimeout(r, 10));
release(makeTenant("acme"));
const results = await inflight;
expect(calls).toBe(1);
for (const r of results) expect(r?.slug).toBe("acme");
});
it("expired entry triggers re-lookup", { timeout: 5000 }, async () => {
let calls = 0;
const inner = headerResolver("x-tenant-id", async (id) => {
calls++;
return makeTenant(id);
});
let now = 0;
const cached = TenantCache.wrap(inner, {
ttlMs: 100,
clock: () => now,
});
const headers = { "x-tenant-id": "acme", host: "x" };
await withCtx(headers, (c) => cached.resolve(c));
now = 50;
await withCtx(headers, (c) => cached.resolve(c));
expect(calls).toBe(1);
now = 250;
await withCtx(headers, (c) => cached.resolve(c));
expect(calls).toBe(2);
});
it("evicts oldest beyond maxEntries", { timeout: 5000 }, async () => {
const inner = headerResolver("x-tenant-id", async (id) =>
makeTenant(id)
);
const cached = TenantCache.wrap(inner, {
ttlMs: 60_000,
maxEntries: 2,
keyFn: (c) => c.req.header("x-tenant-id") ?? "",
}) as CachedTenantResolver;
await withCtx({ "x-tenant-id": "a", host: "x" }, (c) =>
cached.resolve(c)
);
await withCtx({ "x-tenant-id": "b", host: "x" }, (c) =>
cached.resolve(c)
);
await withCtx({ "x-tenant-id": "c", host: "x" }, (c) =>
cached.resolve(c)
);
expect(cached.size).toBe(2);
});
it("LRU touch promotes most-recent on hit", {
timeout: 5000,
}, async () => {
let calls = 0;
const inner: TenantResolver = {
name: "test",
resolve: async (c) => {
calls++;
return makeTenant(c.req.header("x-key") ?? "x");
},
};
const cached = TenantCache.wrap(inner, {
ttlMs: 60_000,
maxEntries: 2,
keyFn: (c) => c.req.header("x-key") ?? "",
}) as CachedTenantResolver;
const k = (key: string) =>
withCtx({ host: "x", "x-key": key }, (c) => cached.resolve(c));
await k("a");
await k("b");
await k("a"); // touch — promotes a
await k("c"); // evicts oldest = b
expect(cached.size).toBe(2);
const before = calls;
await k("b"); // miss
expect(calls).toBe(before + 1);
});
it("clear() drops all entries", { timeout: 5000 }, async () => {
const inner = headerResolver("x-tenant-id", async (id) =>
makeTenant(id)
);
const cached = TenantCache.wrap(inner, {
ttlMs: 60_000,
}) as CachedTenantResolver;
await withCtx({ "x-tenant-id": "a", host: "x" }, (c) =>
cached.resolve(c)
);
expect(cached.size).toBe(1);
cached.clear();
expect(cached.size).toBe(0);
});
it("name reflects wrapped resolver", { timeout: 5000 }, () => {
const inner = headerResolver("x", async () => null);
const cached = TenantCache.wrap(inner, { ttlMs: 100 });
expect(cached.name).toBe("cached(header)");
});
it("defaultKey falls through host → id → path", {
timeout: 5000,
}, async () => {
const inner = headerResolver("x-tenant-id", async (id) =>
makeTenant(id)
);
const cached = TenantCache.wrap(inner, { ttlMs: 1000 });
// Path-only branch — no host, no x-tenant-id header.
const app = new Hono();
let captured: Tenant | null | undefined;
app.all("*", async (c) => {
captured = await cached.resolve(c);
return c.text("ok");
});
const req1 = new Request("http://x/path1", { headers: {} });
req1.headers.delete("host");
await app.fetch(req1);
expect(captured).toBeNull();
// x-tenant-id branch (no host)
const req2 = new Request("http://x/path2", {
headers: { "x-tenant-id": "acme" },
});
req2.headers.delete("host");
await app.fetch(req2);
expect(captured?.slug).toBe("acme");
});
it("negativeTtlMs defaults sensibly even for tiny ttl", {
timeout: 5000,
}, async () => {
const inner = headerResolver("x-tenant-id", async () => null);
const cached = TenantCache.wrap(inner, { ttlMs: 1 });
const r = await withCtx({ "x-tenant-id": "g", host: "x" }, (c) =>
cached.resolve(c)
);
expect(r).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
import type { Context } from "hono";
import type { TenantResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
/** Monotonic-ish wall-clock function — defaults to `Date.now`. */
export type CacheClock = () => number;
/** Options controlling cache wrap behaviour. */
export interface TenantCacheOptions {
/** TTL in ms for positive (tenant-found) entries. Required. */
readonly ttlMs: number;
/** TTL in ms for negative (no-tenant) entries. Defaults to `ttlMs / 4`. */
readonly negativeTtlMs?: number;
/** Maximum cache entries — overflow evicts oldest. Default 1000. */
readonly maxEntries?: number;
/**
* Function deriving the cache key from a Hono `Context`. Defaults to
* `host || x-tenant-id header || path` — covers the common cases
* without forcing callers to pick one. Override for production.
*/
readonly keyFn?: (c: Context) => string;
/** Injectable clock. Defaults to `Date.now`. */
readonly clock?: CacheClock;
}
interface CacheEntry {
readonly tenant: Tenant | null;
readonly expiresAt: number;
}
function defaultKey(c: Context): string {
const host = c.req.header("host");
if (host) return `h:${host}:${c.req.path}`;
const id = c.req.header("x-tenant-id");
if (id) return `i:${id}`;
return `p:${c.req.path}`;
}
/**
* Cache wrapper for any {@link TenantResolver}. Holds positive AND
* negative lookups for their respective TTLs, evicts the oldest entry
* on overflow, and coalesces in-flight lookups for the same key so a
* stampede of concurrent requests fires `lookupFn` exactly once.
*/
export const TenantCache = {
/** Wrap a resolver with caching. */
wrap(
resolver: TenantResolver,
options: TenantCacheOptions
): TenantResolver {
return new CachedTenantResolver(resolver, options);
},
} as const;
export class CachedTenantResolver implements TenantResolver {
public readonly name: string;
private readonly inner: TenantResolver;
private readonly ttlMs: number;
private readonly negativeTtlMs: number;
private readonly maxEntries: number;
private readonly keyFn: (c: Context) => string;
private readonly clock: CacheClock;
private readonly entries = new Map<string, CacheEntry>();
private readonly inFlight = new Map<string, Promise<Tenant | null>>();
public constructor(inner: TenantResolver, options: TenantCacheOptions) {
this.inner = inner;
this.name = `cached(${inner.name})`;
this.ttlMs = options.ttlMs;
this.negativeTtlMs =
options.negativeTtlMs ?? Math.max(1, Math.floor(options.ttlMs / 4));
this.maxEntries = options.maxEntries ?? 1000;
this.keyFn = options.keyFn ?? defaultKey;
this.clock = options.clock ?? Date.now;
}
public async resolve(c: Context): Promise<Tenant | null> {
const key = this.keyFn(c);
const now = this.clock();
const cached = this.entries.get(key);
if (cached && cached.expiresAt > now) {
// Refresh LRU order — re-insert.
this.entries.delete(key);
this.entries.set(key, cached);
return cached.tenant;
}
if (cached) {
// Stale — drop.
this.entries.delete(key);
}
const existing = this.inFlight.get(key);
if (existing) return existing;
const promise = this.inner
.resolve(c)
.then((tenant) => {
const ttl = tenant ? this.ttlMs : this.negativeTtlMs;
this.put(key, { tenant, expiresAt: this.clock() + ttl });
return tenant;
})
.finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
private put(key: string, entry: CacheEntry): void {
if (this.entries.size >= this.maxEntries) {
// `entries.size >= maxEntries` AND `maxEntries >= 1` (by
// option default) guarantees at least one key exists.
const oldest = this.entries.keys().next().value!;
this.entries.delete(oldest);
}
this.entries.set(key, entry);
}
/** Test helper — current entry count. */
public get size(): number {
return this.entries.size;
}
/** Test helper — drop all entries. */
public clear(): void {
this.entries.clear();
}
}

View File

@@ -0,0 +1,8 @@
export type {
CacheClock,
TenantCacheOptions,
} from "./cache.js";
export {
CachedTenantResolver,
TenantCache,
} from "./cache.js";

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,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Tenant } from "../types/tenant.js";
import { createTenantContext, NullTenantContext } from "./context.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: ["payment"],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
describe("createTenantContext", () => {
it("exposes tenant and tenantId", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.tenant.slug).toBe("acme");
expect(ctx.tenantId).toBe("t_1");
});
it("isPluginEnabled honours enabledPlugins", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.isPluginEnabled("payment")).toBe(true);
expect(ctx.isPluginEnabled("ghost")).toBe(false);
});
it("getSetting walks dot-paths and returns default", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: { nested: { value: 42 } },
timezone: "UTC",
},
})
);
expect(ctx.getSetting<number>("custom.nested.value")).toBe(42);
expect(ctx.getSetting<string>("timezone")).toBe("UTC");
expect(ctx.getSetting<number>("missing.path", 7)).toBe(7);
expect(ctx.getSetting<number>("missing.path")).toBeUndefined();
});
it("getSetting returns default when leaf is nullish", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: {
explicitNull: null,
zero: 0,
} as Record<string, unknown>,
},
})
);
expect(ctx.getSetting<number>("custom.explicitNull", 42)).toBe(42);
// Leaf 0 is non-nullish; default ignored.
expect(ctx.getSetting<number>("custom.zero", 9)).toBe(0);
});
it("getSetting returns default when intermediate is null", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(
makeTenant({
settings: {
custom: { nullSlot: null } as Record<string, unknown>,
},
})
);
expect(ctx.getSetting<number>("custom.nullSlot.x", 9)).toBe(9);
});
it("isFeatureEnabled reads features boolean", { timeout: 5000 }, () => {
const ctx = createTenantContext(
makeTenant({ settings: { features: { x: true, y: false } } })
);
expect(ctx.isFeatureEnabled("x")).toBe(true);
expect(ctx.isFeatureEnabled("y")).toBe(false);
expect(ctx.isFeatureEnabled("missing")).toBe(false);
});
it("isFeatureEnabled is false when no features map", {
timeout: 5000,
}, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.isFeatureEnabled("x")).toBe(false);
});
it("getLimit reads limits map", { timeout: 5000 }, () => {
const ctx = createTenantContext(
makeTenant({
settings: { limits: { maxUsers: 10, maxApiCalls: 1000 } },
})
);
expect(ctx.getLimit("maxUsers")).toBe(10);
expect(ctx.getLimit("maxApiCalls")).toBe(1000);
expect(ctx.getLimit("unknownKey")).toBeUndefined();
});
it("getLimit returns undefined when no limits", { timeout: 5000 }, () => {
const ctx = createTenantContext(makeTenant());
expect(ctx.getLimit("maxUsers")).toBeUndefined();
});
});
describe("NullTenantContext", () => {
it("NullTenantContext fail-closes plugin check", { timeout: 5000 }, () => {
expect(NullTenantContext.isPluginEnabled("payment")).toBe(false);
expect(NullTenantContext.isPluginEnabled("anything")).toBe(false);
});
it("NullTenantContext fail-closes feature check", {
timeout: 5000,
}, () => {
expect(NullTenantContext.isFeatureEnabled("x")).toBe(false);
});
it("getSetting returns supplied default", { timeout: 5000 }, () => {
expect(NullTenantContext.getSetting<number>("any.key", 7)).toBe(7);
expect(NullTenantContext.getSetting<number>("any.key")).toBeUndefined();
});
it("getLimit returns undefined", { timeout: 5000 }, () => {
expect(NullTenantContext.getLimit("maxUsers")).toBeUndefined();
});
it("tenantId is empty string", { timeout: 5000 }, () => {
expect(NullTenantContext.tenantId).toBe("");
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(NullTenantContext)).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import type { Tenant } from "../types/tenant.js";
/**
* Per-request helper bag derived from a resolved `Tenant`. The middleware
* does NOT store this on the Hono context — `c.set("tenant", tenant)` is
* the single canonical key. Callers can construct a context on demand
* with `createTenantContext(c.get("tenant"))` when they want the helper
* accessors.
*/
export interface TenantContext {
/** The underlying tenant record. */
readonly tenant: Tenant;
/** Shorthand for `tenant.id`. */
readonly tenantId: string;
/** Returns `true` iff `tenant.enabledPlugins` contains `pluginName`. */
isPluginEnabled(pluginName: string): boolean;
/**
* Read a dot-path from `tenant.settings`. Returns `defaultValue` (or
* `undefined`) when any segment is missing.
*/
getSetting<T>(key: string, defaultValue?: T): T | undefined;
/** Returns `true` iff `tenant.settings.features?.[name]` is `true`. */
isFeatureEnabled(feature: string): boolean;
/** Read a single `TenantLimits` field. */
getLimit(key: string): number | undefined;
}
/** Build a `TenantContext` helper bag from a resolved tenant. */
export function createTenantContext(tenant: Tenant): TenantContext {
return {
tenant,
tenantId: tenant.id,
isPluginEnabled(pluginName: string): boolean {
return tenant.enabledPlugins.includes(pluginName);
},
getSetting<T>(key: string, defaultValue?: T): T | undefined {
const keys = key.split(".");
let value: unknown = tenant.settings;
for (const k of keys) {
if (value === null || value === undefined) return defaultValue;
value = (value as Record<string, unknown>)[k];
}
return (value as T) ?? defaultValue;
},
isFeatureEnabled(feature: string): boolean {
return tenant.settings.features?.[feature] ?? false;
},
getLimit(key: string): number | undefined {
const limits = tenant.settings.limits;
if (!limits) return undefined;
return limits[key as keyof typeof limits];
},
};
}
/**
* Fail-closed null context — every check returns `false`/`undefined`.
* Genesis deliberately flips the upstream default (which returned
* `true`) so a tenant-less request cannot silently inherit permissions.
*/
export const NullTenantContext: TenantContext = Object.freeze({
tenant: Object.freeze({
id: "",
name: "Null",
slug: "",
status: "deleted" as const,
settings: {},
enabledPlugins: [] as string[],
domains: [] as string[],
createdAt: new Date(0),
updatedAt: new Date(0),
}),
tenantId: "",
isPluginEnabled: () => false,
getSetting: <T>(_key: string, defaultValue?: T): T | undefined =>
defaultValue,
isFeatureEnabled: () => false,
getLimit: () => undefined,
});

View File

@@ -0,0 +1,5 @@
export type { TenantContext } from "./context.js";
export {
createTenantContext,
NullTenantContext,
} from "./context.js";

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,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TENANT_CODES, TenantError } from "./error-codes.js";
describe("TENANT_CODES", () => {
it("defines exactly 7 codes", { timeout: 5000 }, () => {
expect(Object.keys(TENANT_CODES)).toHaveLength(7);
});
it("namespaces every code under TENANT.*", { timeout: 5000 }, () => {
for (const value of Object.values(TENANT_CODES)) {
expect(value.startsWith("TENANT.")).toBe(true);
}
});
it("freezes the registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(TENANT_CODES)).toBe(true);
});
it("exposes every documented code", { timeout: 5000 }, () => {
expect(TENANT_CODES.NOT_FOUND).toBe("TENANT.NOT_FOUND");
expect(TENANT_CODES.SUSPENDED).toBe("TENANT.SUSPENDED");
expect(TENANT_CODES.PENDING).toBe("TENANT.PENDING");
expect(TENANT_CODES.RESOLUTION_FAILED).toBe("TENANT.RESOLUTION_FAILED");
expect(TENANT_CODES.RATE_LIMITED).toBe("TENANT.RATE_LIMITED");
expect(TENANT_CODES.INVALID_INPUT).toBe("TENANT.INVALID_INPUT");
expect(TENANT_CODES.LOOKUP_TIMEOUT).toBe("TENANT.LOOKUP_TIMEOUT");
});
});
describe("TenantError", () => {
it("carries the code via .code and .tenantCode", { timeout: 5000 }, () => {
const err = new TenantError(TENANT_CODES.NOT_FOUND, "missing");
expect(err.code).toBe("TENANT.NOT_FOUND");
expect(err.tenantCode).toBe("TENANT.NOT_FOUND");
});
it("preserves the message and context", { timeout: 5000 }, () => {
const err = new TenantError(TENANT_CODES.RESOLUTION_FAILED, "boom", {
context: { resolver: "subdomain" },
});
expect(err.message).toBe("boom");
expect(err.context).toEqual({ resolver: "subdomain" });
});
it("threads cause through to Error.cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new TenantError(TENANT_CODES.LOOKUP_TIMEOUT, "outer", {
cause,
});
expect(err.cause).toBe(cause);
});
it("is a SuaveplanError subclass", { timeout: 5000 }, () => {
const err = new TenantError(TENANT_CODES.INVALID_INPUT, "halt");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TenantError");
});
});

View File

@@ -0,0 +1,50 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-tenant`.
*
* Every public failure path raises a {@link TenantError} carrying one of
* these namespaced `TENANT.*` codes. Consumers branch on the string value
* (e.g. `error.code === TENANT_CODES.NOT_FOUND` or the inline literal
* `"TENANT.NOT_FOUND"`).
*
* The 7 codes cover:
* - Membership (`NOT_FOUND`).
* - Status gating (`SUSPENDED`, `PENDING`).
* - Resolution failures (`RESOLUTION_FAILED`, `LOOKUP_TIMEOUT`).
* - Per-tenant rate enforcement (`RATE_LIMITED`).
* - Bad request input (`INVALID_INPUT`).
*/
export const TENANT_CODES = defineCodes("TENANT", [
"NOT_FOUND",
"SUSPENDED",
"PENDING",
"RESOLUTION_FAILED",
"RATE_LIMITED",
"INVALID_INPUT",
"LOOKUP_TIMEOUT",
] as const);
export type TenantErrorCode = (typeof TENANT_CODES)[keyof typeof TENANT_CODES];
/**
* Concrete error class for the api-tenant package. Extends
* {@link SuaveplanError} so `instanceof TenantError` narrows `code` to
* {@link TenantErrorCode}.
*
* The constructor is widened to `public` so callers may construct errors
* directly (the base class is `protected`).
*/
export class TenantError extends SuaveplanError<TenantErrorCode> {
public readonly tenantCode: TenantErrorCode;
public constructor(
code: TenantErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.tenantCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { TenantErrorCode } from "./error-codes.js";
export {
TENANT_CODES,
TenantError,
} from "./error-codes.js";

View File

@@ -0,0 +1,93 @@
// Side-effect import — surfaces the error-codes registry and the
// Hono `ContextVariableMap` augmentation to consumers via
// `import "@suaveplan/api-tenant"`.
import "./error-codes/error-codes.js";
import "./middleware/middleware.js";
// Cache
export {
type CacheClock,
CachedTenantResolver,
TenantCache,
type TenantCacheOptions,
} from "./cache/cache.js";
// Context
export {
createTenantContext,
NullTenantContext,
type TenantContext,
} from "./context/context.js";
// Error codes
export {
TENANT_CODES,
TenantError,
type TenantErrorCode,
} from "./error-codes/error-codes.js";
// Lifecycle
export {
createTenantLifecycle,
TENANT_LIFECYCLE_HOOK_NAMES,
type TenantAwarePluginDescriptor,
TenantLifecycle,
type TenantLifecycleHookName,
type TenantLifecycleHooks,
type TenantScope,
} from "./lifecycle/lifecycle.js";
// Middleware
export {
createTenantMiddleware,
getTenant,
getTenantId,
type TenantMiddlewareOptions,
} from "./middleware/middleware.js";
export { requireFeature } from "./middleware/require-feature.js";
export { requirePlugin } from "./middleware/require-plugin.js";
// Rate-limit
export {
MemoryRateLimitStore,
type RateLimitRecord,
type RateLimitStore,
type TenantRateLimitOptions,
tenantRateLimit,
} from "./rate-limit/rate-limit.js";
// Resolver
export {
ChainedTenantResolver,
chainResolvers,
DomainTenantResolver,
domainResolver,
HeaderTenantResolver,
headerResolver,
JWTTenantResolver,
jwtResolver,
PathTenantResolver,
pathResolver,
SubdomainTenantResolver,
subdomainResolver,
type TenantLookupFn,
type TenantResolver,
TimeoutTenantResolver,
timeoutResolver,
} from "./resolver/resolver.js";
// Storage
export {
getCurrentTenant,
getCurrentTenantId,
isTenantStorageEnabled,
runWithTenant,
tenantStorageMiddleware,
withTenantStorage,
} from "./storage/storage.js";
// Types
export {
hasPermission,
type Tenant,
type TenantBranding,
type TenantInvitation,
type TenantLimits,
type TenantMember,
type TenantRole,
TenantRolePermissions,
type TenantSettings,
type TenantStatus,
} from "./types/tenant.js";

View File

@@ -0,0 +1,11 @@
export type {
TenantAwarePluginDescriptor,
TenantLifecycleHookName,
TenantLifecycleHooks,
TenantScope,
} from "./lifecycle.js";
export {
createTenantLifecycle,
TENANT_LIFECYCLE_HOOK_NAMES,
TenantLifecycle,
} from "./lifecycle.js";

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,222 @@
import {
createHookExecutor,
createPluginContext,
type PluginContext,
} from "@suaveplan/api-plugin";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Tenant } from "../types/tenant.js";
import {
createTenantLifecycle,
TENANT_LIFECYCLE_HOOK_NAMES,
type TenantAwarePluginDescriptor,
} from "./lifecycle.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
function noopContextFactory(): (pluginName: string) => PluginContext {
return () =>
createPluginContext({
logger: { debug() {}, info() {}, warn() {}, error() {} },
eventBus: { on: () => () => {}, emit: () => {} },
signal: new AbortController().signal,
config: {},
services: new Map(),
});
}
describe("TenantLifecycle", () => {
it("exposes the seven canonical hook names", { timeout: 5000 }, () => {
expect(TENANT_LIFECYCLE_HOOK_NAMES).toEqual([
"onTenantCreate",
"onTenantUpdate",
"onTenantDelete",
"onTenantSuspend",
"onTenantActivate",
"onPluginEnable",
"onPluginDisable",
]);
});
it("dispatches onTenantCreate to all plugins (scope default)", {
timeout: 5000,
}, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
const seen: string[] = [];
life.register({
name: "a",
version: "0.1.0",
tenantHooks: {
onTenantCreate: (_, tenant) => {
seen.push(`a:${tenant.id}`);
},
},
});
life.register({
name: "b",
version: "0.1.0",
tenantHooks: {
onTenantCreate: (_, tenant) => {
seen.push(`b:${tenant.id}`);
},
},
});
await life.dispatchCreate(makeTenant());
expect(seen).toEqual(["a:t_1", "b:t_1"]);
});
it("tenantScopes none skips hook", { timeout: 5000 }, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
let called = false;
const plugin: TenantAwarePluginDescriptor = {
name: "opt-out",
version: "0.1.0",
tenantScopes: "none",
tenantHooks: {
onTenantCreate: () => {
called = true;
},
},
};
life.register(plugin);
await life.dispatchCreate(makeTenant());
expect(called).toBe(false);
});
it("tenantScopes explicit filters by enabledPlugins", {
timeout: 5000,
}, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
let called = false;
life.register({
name: "billing",
version: "0.1.0",
tenantScopes: "explicit",
tenantHooks: {
onTenantCreate: () => {
called = true;
},
},
});
await life.dispatchCreate(makeTenant({ enabledPlugins: [] }));
expect(called).toBe(false);
await life.dispatchCreate(makeTenant({ enabledPlugins: ["billing"] }));
expect(called).toBe(true);
});
it("dispatches the remaining six events", { timeout: 5000 }, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
const log: string[] = [];
life.register({
name: "p",
version: "0.1.0",
tenantHooks: {
onTenantUpdate: () => {
log.push("update");
},
onTenantDelete: () => {
log.push("delete");
},
onTenantSuspend: (_, _t, reason) => {
log.push(`suspend:${reason}`);
},
onTenantActivate: () => {
log.push("activate");
},
onPluginEnable: (_, _t, name) => {
log.push(`enable:${name}`);
},
onPluginDisable: (_, _t, name) => {
log.push(`disable:${name}`);
},
},
});
const t = makeTenant();
await life.dispatchUpdate(t);
await life.dispatchDelete(t);
await life.dispatchSuspend(t, "billing");
await life.dispatchActivate(t);
await life.dispatchPluginEnable(t, "x");
await life.dispatchPluginDisable(t, "x");
expect(log).toEqual([
"update",
"delete",
"suspend:billing",
"activate",
"enable:x",
"disable:x",
]);
});
it("unregister drops all hooks for a plugin", {
timeout: 5000,
}, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
let called = false;
life.register({
name: "x",
version: "0.1.0",
tenantHooks: {
onTenantCreate: () => {
called = true;
},
},
});
life.unregister("x");
await life.dispatchCreate(makeTenant());
expect(called).toBe(false);
});
it("ignores plugins with no tenantHooks declared", {
timeout: 5000,
}, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
life.register({ name: "silent", version: "0.1.0" });
await life.dispatchCreate(makeTenant());
// No assertion needed beyond not-throwing.
expect(true).toBe(true);
});
it("shouldDispatch returns false when plugin record vanishes", {
timeout: 5000,
}, async () => {
const exec = createHookExecutor();
const life = createTenantLifecycle(exec, noopContextFactory());
let called = false;
life.register({
name: "x",
version: "0.1.0",
tenantHooks: {
onTenantCreate: () => {
called = true;
},
},
});
// Simulate the impossible-but-defended invariant: the executor
// still has the hook, but the internal plugins record is gone.
const internal = life as unknown as {
plugins: Map<string, unknown>;
};
internal.plugins.delete("x");
await life.dispatchCreate(makeTenant({ enabledPlugins: ["x"] }));
expect(called).toBe(false);
});
});

View File

@@ -0,0 +1,207 @@
import type {
HookExecutor,
PluginContext,
PluginDescriptor,
} from "@suaveplan/api-plugin";
import type { Tenant } from "../types/tenant.js";
/** The seven tenant lifecycle hook names dispatched through the executor. */
export const TENANT_LIFECYCLE_HOOK_NAMES = [
"onTenantCreate",
"onTenantUpdate",
"onTenantDelete",
"onTenantSuspend",
"onTenantActivate",
"onPluginEnable",
"onPluginDisable",
] as const;
/** Discriminated union of the seven lifecycle hook names. */
export type TenantLifecycleHookName =
(typeof TENANT_LIFECYCLE_HOOK_NAMES)[number];
/**
* Optional tenant lifecycle hook bag. A plugin can declare any subset.
* Every hook receives the `PluginContext` and the full `Tenant` value;
* pair-style hooks (`onPluginEnable`, `onPluginDisable`) get the plugin
* name as the trailing argument.
*/
export interface TenantLifecycleHooks {
onTenantCreate?: (
ctx: PluginContext,
tenant: Tenant
) => void | Promise<void>;
onTenantUpdate?: (
ctx: PluginContext,
tenant: Tenant
) => void | Promise<void>;
onTenantDelete?: (
ctx: PluginContext,
tenant: Tenant
) => void | Promise<void>;
onTenantSuspend?: (
ctx: PluginContext,
tenant: Tenant,
reason: string
) => void | Promise<void>;
onTenantActivate?: (
ctx: PluginContext,
tenant: Tenant
) => void | Promise<void>;
onPluginEnable?: (
ctx: PluginContext,
tenant: Tenant,
pluginName: string
) => void | Promise<void>;
onPluginDisable?: (
ctx: PluginContext,
tenant: Tenant,
pluginName: string
) => void | Promise<void>;
}
/**
* `tenantScopes` controls how the lifecycle hooks fire per plugin:
* - `"all"` (default) — every tenant lifecycle event reaches the plugin.
* - `"explicit"` — only tenants whose `enabledPlugins` includes the
* plugin name receive the hook calls.
* - `"none"` — the plugin opts OUT entirely; lifecycle hooks never run.
*/
export type TenantScope = "all" | "explicit" | "none";
/**
* `PluginDescriptor` extension adding tenant lifecycle hooks and the
* `tenantScopes` opt-out. Pass instances of this descriptor to
* `registerTenantLifecycle` (rather than direct registration through
* the plain plugin registry) so the genesis filtering rules are
* honoured.
*/
export interface TenantAwarePluginDescriptor<TConfig = unknown>
extends PluginDescriptor<TConfig> {
readonly tenantScopes?: TenantScope;
readonly tenantHooks?: TenantLifecycleHooks;
}
interface RegisteredTenantPlugin {
readonly name: string;
readonly scope: TenantScope;
readonly hooks: TenantLifecycleHooks;
}
/**
* Tenant lifecycle dispatcher. Wraps a `HookExecutor` from
* `@suaveplan/api-plugin` so every dispatched event is sequenced
* (`execute`) — a single throw aborts the rest of the chain and
* surfaces as `PluginError(PLUGIN.HOOK_FAILED)`.
*/
export class TenantLifecycle {
private readonly executor: HookExecutor;
private readonly plugins = new Map<string, RegisteredTenantPlugin>();
private readonly contextFactory: (pluginName: string) => PluginContext;
public constructor(
executor: HookExecutor,
contextFactory: (pluginName: string) => PluginContext
) {
this.executor = executor;
this.contextFactory = contextFactory;
}
/**
* Register a tenant-aware plugin. Its hooks are filtered against
* `tenantScopes` at dispatch time, not at registration time, so
* `enabledPlugins` changes are honoured for already-registered
* plugins.
*/
public register<TConfig>(
descriptor: TenantAwarePluginDescriptor<TConfig>
): void {
const scope: TenantScope = descriptor.tenantScopes ?? "all";
const hooks = descriptor.tenantHooks ?? {};
this.plugins.set(descriptor.name, {
name: descriptor.name,
scope,
hooks,
});
for (const name of TENANT_LIFECYCLE_HOOK_NAMES) {
const handler = hooks[name];
if (!handler) continue;
this.executor.registerHook(
name,
(...args: unknown[]) => {
const tenant = args[0] as Tenant;
if (!this.shouldDispatch(descriptor.name, tenant)) return;
const ctx = this.contextFactory(descriptor.name);
return (handler as (...a: unknown[]) => unknown)(
ctx,
...args
);
},
descriptor.name
);
}
}
/** Drop a plugin's hook registrations. */
public unregister(pluginName: string): void {
this.plugins.delete(pluginName);
this.executor.unregisterPlugin(pluginName);
}
/** Dispatch `onTenantCreate` to every registered plugin in scope. */
public dispatchCreate(tenant: Tenant): Promise<void> {
return this.executor.execute("onTenantCreate", tenant);
}
public dispatchUpdate(tenant: Tenant): Promise<void> {
return this.executor.execute("onTenantUpdate", tenant);
}
public dispatchDelete(tenant: Tenant): Promise<void> {
return this.executor.execute("onTenantDelete", tenant);
}
public dispatchSuspend(tenant: Tenant, reason: string): Promise<void> {
return this.executor.execute("onTenantSuspend", tenant, reason);
}
public dispatchActivate(tenant: Tenant): Promise<void> {
return this.executor.execute("onTenantActivate", tenant);
}
public dispatchPluginEnable(
tenant: Tenant,
pluginName: string
): Promise<void> {
return this.executor.execute("onPluginEnable", tenant, pluginName);
}
public dispatchPluginDisable(
tenant: Tenant,
pluginName: string
): Promise<void> {
return this.executor.execute("onPluginDisable", tenant, pluginName);
}
private shouldDispatch(pluginName: string, tenant: Tenant): boolean {
// Invariant: every executor-registered hook closes over a plugin
// name we placed in `this.plugins`; `register()` and `unregister()`
// mutate both stores in lock-step. The lookup therefore always
// resolves — if it ever didn't we would surface a programmer
// error, not silently swallow.
const plugin = this.plugins.get(pluginName);
if (!plugin || plugin.scope === "none") return false;
if (plugin.scope === "explicit") {
return tenant.enabledPlugins.includes(pluginName);
}
return true;
}
}
/** Convenience factory matching the rest of the package's `create*` style. */
export function createTenantLifecycle(
executor: HookExecutor,
contextFactory: (pluginName: string) => PluginContext
): TenantLifecycle {
return new TenantLifecycle(executor, contextFactory);
}

View File

@@ -0,0 +1,8 @@
export {
createTenantMiddleware,
getTenant,
getTenantId,
type TenantMiddlewareOptions,
} from "./middleware.js";
export { requireFeature } from "./require-feature.js";
export { requirePlugin } from "./require-plugin.js";

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,351 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import { headerResolver } from "../resolver/resolver.js";
import type { Tenant, TenantStatus } from "../types/tenant.js";
import {
createTenantMiddleware,
getTenant,
getTenantId,
} from "./middleware.js";
function makeTenant(
status: TenantStatus = "active",
over: Partial<Tenant> = {}
): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status,
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
describe("createTenantMiddleware", () => {
it("sets one c.get('tenant') key", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
})
);
let captured: {
tenant: Tenant | undefined;
tenantId: unknown;
tenantContext: unknown;
} | null = null;
app.get("/", (c) => {
captured = {
tenant: getTenant(c),
tenantId: c.get("tenantId" as never),
tenantContext: c.get("tenantContext" as never),
};
return c.text("ok");
});
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(200);
expect(captured!.tenant?.id).toBe("t_1");
expect(captured!.tenantId).toBeUndefined();
expect(captured!.tenantContext).toBeUndefined();
});
it("suspended raises TENANT_SUSPENDED", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant("suspended")
),
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof TenantError) {
return c.json({ code: err.code }, 403);
}
return c.text("boom", 500);
});
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(403);
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.SUSPENDED);
});
it("allowSuspended lets suspended through", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant("suspended")
),
allowSuspended: true,
})
);
app.get("/", (c) => c.text(getTenantId(c) ?? ""));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(await res.text()).toBe("t_1");
});
it("deleted raises TENANT_NOT_FOUND", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant("deleted")
),
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) =>
c.json({ code: err instanceof TenantError ? err.code : "x" }, 404)
);
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("pending raises TENANT_PENDING", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant("pending")
),
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) =>
c.json({ code: err instanceof TenantError ? err.code : "x" }, 403)
);
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.PENDING);
});
it("missing tenant raises TENANT_NOT_FOUND when required", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () => null),
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) =>
c.json({ code: err instanceof TenantError ? err.code : "x" }, 404)
);
const res = await app.request("/");
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("not-required missing tenant passes through", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () => null),
required: false,
})
);
app.get("/", (c) =>
c.json({ tenant: getTenant(c) ?? null, id: getTenantId(c) ?? null })
);
const res = await app.request("/");
const body = (await res.json()) as {
tenant: Tenant | null;
id: string | null;
};
expect(body.tenant).toBeNull();
expect(body.id).toBeNull();
});
it("excludePaths short-circuits resolver", { timeout: 5000 }, async () => {
let called = false;
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: {
name: "spy",
resolve: async () => {
called = true;
return null;
},
},
})
);
app.get("/health", (c) => c.text("ok"));
const res = await app.request("/health");
expect(res.status).toBe(200);
expect(called).toBe(false);
});
it("resolver throws → TENANT_RESOLUTION_FAILED", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: {
name: "boom",
resolve: async () => {
throw new Error("db down");
},
},
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) =>
c.json(
{
code: err instanceof TenantError ? err.code : "x",
cause: (err as TenantError).cause?.message,
},
500
)
);
const res = await app.request("/");
const body = (await res.json()) as { code: string; cause: string };
expect(body.code).toBe(TENANT_CODES.RESOLUTION_FAILED);
expect(body.cause).toBe("db down");
});
it("resolver throws non-Error → still wraps", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: {
name: "boom",
resolve: async () => {
// Throw a non-Error so the wrapper's `instanceof` branch fires.
const failure: unknown = "literal";
throw failure;
},
},
})
);
app.get("/", (c) => c.text("never"));
app.onError((err, c) =>
c.json(
{
code: err instanceof TenantError ? err.code : "x",
cause: (err as TenantError).cause?.message,
},
500
)
);
const res = await app.request("/");
const body = (await res.json()) as { code: string; cause: string };
expect(body.code).toBe(TENANT_CODES.RESOLUTION_FAILED);
expect(body.cause).toBe("literal");
});
it("onError maps TenantError to custom Response", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () => null),
onError: (error, c) =>
c.json({ via: "onError", code: error.code }, 418),
})
);
app.get("/", (c) => c.text("never"));
const res = await app.request("/");
expect(res.status).toBe(418);
const body = (await res.json()) as { via: string; code: string };
expect(body.via).toBe("onError");
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("onResolved fires after status gate", { timeout: 5000 }, async () => {
let seen: string | null = null;
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
onResolved: (tenant) => {
seen = tenant.id;
},
})
);
app.get("/", (c) => c.text("ok"));
await app.request("/", { headers: { "x-tenant-id": "acme" } });
expect(seen).toBe("t_1");
});
it("non-TenantError bypasses onError", { timeout: 5000 }, async () => {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
onError: () => new Response("never", { status: 200 }),
})
);
app.get("/", () => {
throw new Error("downstream");
});
app.onError((err, c) => c.text(err.message, 500));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(500);
expect(await res.text()).toBe("downstream");
});
});
describe("getTenant/getTenantId helpers", () => {
it("return undefined when no tenant set", { timeout: 5000 }, async () => {
const app = new Hono();
let id: string | undefined;
let t: Tenant | undefined;
app.get("/", (c) => {
id = getTenantId(c);
t = getTenant(c);
return c.text("ok");
});
await app.request("/");
expect(id).toBeUndefined();
expect(t).toBeUndefined();
});
});

View File

@@ -0,0 +1,140 @@
import type { Context, MiddlewareHandler, Next } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import type { TenantResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
/**
* Hono `ContextVariableMap` augmentation — gives `c.get("tenant")` the
* concrete `Tenant` type with no cast at every call site. This is the
* one and only key the middleware writes; `tenantId` and `tenantContext`
* are deliberately NOT augmented because they are no longer set.
*/
declare module "hono" {
interface ContextVariableMap {
tenant: Tenant;
}
}
/** Options accepted by {@link createTenantMiddleware}. */
export interface TenantMiddlewareOptions {
/** Resolver chain — typically a `ChainedTenantResolver`. */
readonly resolver: TenantResolver;
/** When `true` (default), a missing tenant raises `TENANT_NOT_FOUND`. */
readonly required?: boolean;
/** Paths the middleware short-circuits over (default `/health, /ready, /docs, /openapi`). */
readonly excludePaths?: readonly string[];
/** When `true`, `status === "suspended"` is allowed through. Default `false`. */
readonly allowSuspended?: boolean;
/** Optional error mapper — receives the typed `TenantError`. */
readonly onError?: (
error: TenantError,
c: Context
) => Response | Promise<Response>;
/** Called after the tenant has been resolved + status-gated. */
readonly onResolved?: (tenant: Tenant, c: Context) => void | Promise<void>;
}
const DEFAULT_EXCLUDE: readonly string[] = [
"/health",
"/ready",
"/docs",
"/openapi",
];
/**
* Build a Hono middleware that resolves the current tenant, applies the
* status gate (`deleted → TENANT_NOT_FOUND`, `suspended →
* TENANT_SUSPENDED`, `pending → TENANT_PENDING`), and stores the result
* under the single canonical `c.set("tenant", tenant)` key.
*/
export function createTenantMiddleware(
options: TenantMiddlewareOptions
): MiddlewareHandler {
const {
resolver,
required = true,
excludePaths = DEFAULT_EXCLUDE,
allowSuspended = false,
onError,
onResolved,
} = options;
return async function tenantMiddleware(
c: Context,
next: Next
): Promise<Response | undefined> {
if (excludePaths.some((p) => c.req.path.startsWith(p))) {
await next();
return undefined;
}
try {
let tenant: Tenant | null;
try {
tenant = await resolver.resolve(c);
} catch (cause) {
throw new TenantError(
TENANT_CODES.RESOLUTION_FAILED,
"Tenant resolver threw",
{
cause:
cause instanceof Error
? cause
: new Error(String(cause)),
context: { resolver: resolver.name },
}
);
}
if (!tenant) {
if (required) {
throw new TenantError(
TENANT_CODES.NOT_FOUND,
"No tenant resolved for the request",
{ context: { resolver: resolver.name } }
);
}
await next();
return undefined;
}
if (tenant.status === "deleted") {
throw new TenantError(
TENANT_CODES.NOT_FOUND,
`Tenant ${tenant.id} is deleted`,
{ context: { tenantId: tenant.id } }
);
}
if (tenant.status === "suspended" && !allowSuspended) {
throw new TenantError(
TENANT_CODES.SUSPENDED,
`Tenant ${tenant.id} is suspended`,
{ context: { tenantId: tenant.id } }
);
}
if (tenant.status === "pending") {
throw new TenantError(
TENANT_CODES.PENDING,
`Tenant ${tenant.id} is pending activation`,
{ context: { tenantId: tenant.id } }
);
}
c.set("tenant", tenant);
if (onResolved) await onResolved(tenant, c);
await next();
return;
} catch (error) {
if (error instanceof TenantError && onError) {
return await onError(error, c);
}
throw error;
}
};
}
/** Read the resolved tenant id, or `undefined` if none. */
export function getTenantId(c: Context): string | undefined {
return c.get("tenant")?.id;
}
/** Read the resolved tenant, or `undefined` if none. */
export function getTenant(c: Context): Tenant | undefined {
return c.get("tenant");
}

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,96 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import { headerResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
import { createTenantMiddleware } from "./middleware.js";
import { requireFeature } from "./require-feature.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
function trap() {
const app = new Hono();
app.onError((err, c) =>
c.json(
{ code: err instanceof TenantError ? err.code : "x" },
err instanceof TenantError ? 404 : 500
)
);
return app;
}
describe("requireFeature", () => {
it("raises when no tenant resolved", { timeout: 5000 }, async () => {
const app = trap();
app.get("/", requireFeature("beta"), (c) => c.text("never"));
const res = await app.request("/");
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("raises when feature disabled", { timeout: 5000 }, async () => {
const app = trap();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ settings: { features: { beta: false } } })
),
})
);
app.get("/", requireFeature("beta"), (c) => c.text("never"));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.INVALID_INPUT);
});
it("raises when features map absent", { timeout: 5000 }, async () => {
const app = trap();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
})
);
app.get("/", requireFeature("beta"), (c) => c.text("never"));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.INVALID_INPUT);
});
it("passes when feature enabled", { timeout: 5000 }, async () => {
const app = trap();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ settings: { features: { beta: true } } })
),
})
);
app.get("/", requireFeature("beta"), (c) => c.text("yes"));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(await res.text()).toBe("yes");
});
});

View File

@@ -0,0 +1,31 @@
import type { Context, MiddlewareHandler, Next } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
/**
* Guard middleware — raises `TENANT_NOT_FOUND` when no tenant is
* resolved, and `TENANT_INVALID_INPUT` when the resolved tenant does
* not have `settings.features?.[featureName]` set to `true`.
*/
export function requireFeature(featureName: string): MiddlewareHandler {
return async function requireFeatureMiddleware(
c: Context,
next: Next
): Promise<void> {
const tenant = c.get("tenant");
if (!tenant) {
throw new TenantError(
TENANT_CODES.NOT_FOUND,
`requireFeature(${featureName}) ran without a resolved tenant`,
{ context: { feature: featureName } }
);
}
if (!tenant.settings.features?.[featureName]) {
throw new TenantError(
TENANT_CODES.INVALID_INPUT,
`Feature "${featureName}" is not enabled for tenant ${tenant.id}`,
{ context: { tenantId: tenant.id, feature: featureName } }
);
}
await next();
};
}

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,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import { headerResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
import { createTenantMiddleware } from "./middleware.js";
import { requirePlugin } from "./require-plugin.js";
function makeTenant(over: Partial<Tenant> = {}): Tenant {
return {
id: "t_1",
name: "Acme",
slug: "acme",
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
function trap() {
const app = new Hono();
app.onError((err, c) =>
c.json(
{ code: err instanceof TenantError ? err.code : "x" },
err instanceof TenantError ? 404 : 500
)
);
return app;
}
describe("requirePlugin", () => {
it("raises when no tenant resolved", { timeout: 5000 }, async () => {
const app = trap();
app.get("/", requirePlugin("payment"), (c) => c.text("never"));
const res = await app.request("/");
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
it("raises when plugin not enabled", { timeout: 5000 }, async () => {
const app = trap();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ enabledPlugins: [] })
),
})
);
app.get("/", requirePlugin("payment"), (c) => c.text("never"));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.INVALID_INPUT);
});
it("passes through when plugin enabled", { timeout: 5000 }, async () => {
const app = trap();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant({ enabledPlugins: ["payment"] })
),
})
);
app.get("/", requirePlugin("payment"), (c) => c.text("yes"));
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(await res.text()).toBe("yes");
});
});

View File

@@ -0,0 +1,31 @@
import type { Context, MiddlewareHandler, Next } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
/**
* Guard middleware — raises `TENANT_NOT_FOUND` when no tenant is
* resolved, and `TENANT_INVALID_INPUT` when the resolved tenant does
* not list `pluginName` in `enabledPlugins`. Fail-closed by design.
*/
export function requirePlugin(pluginName: string): MiddlewareHandler {
return async function requirePluginMiddleware(
c: Context,
next: Next
): Promise<void> {
const tenant = c.get("tenant");
if (!tenant) {
throw new TenantError(
TENANT_CODES.NOT_FOUND,
`requirePlugin(${pluginName}) ran without a resolved tenant`,
{ context: { plugin: pluginName } }
);
}
if (!tenant.enabledPlugins.includes(pluginName)) {
throw new TenantError(
TENANT_CODES.INVALID_INPUT,
`Plugin "${pluginName}" is not enabled for tenant ${tenant.id}`,
{ context: { tenantId: tenant.id, plugin: pluginName } }
);
}
await next();
};
}

View File

@@ -0,0 +1,9 @@
export type {
RateLimitRecord,
RateLimitStore,
TenantRateLimitOptions,
} from "./rate-limit.js";
export {
MemoryRateLimitStore,
tenantRateLimit,
} from "./rate-limit.js";

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,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TENANT_CODES } from "../error-codes/error-codes.js";
import { createTenantMiddleware } from "../middleware/middleware.js";
import { headerResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
import {
MemoryRateLimitStore,
type RateLimitRecord,
type RateLimitStore,
tenantRateLimit,
} from "./rate-limit.js";
function makeTenant(id = "t_1"): Tenant {
return {
id,
name: "Acme",
slug: id,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
function appWith(rateLimit: ReturnType<typeof tenantRateLimit>) {
const app = new Hono();
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async (id) =>
makeTenant(id)
),
})
);
app.use("*", rateLimit);
app.get("/", (c) => c.text("ok"));
return app;
}
describe("MemoryRateLimitStore", () => {
it("memory store LRU evicts beyond maxEntries", { timeout: 5000 }, () => {
const store = new MemoryRateLimitStore(2);
store.set("a", { count: 1, resetAt: 1 });
store.set("b", { count: 1, resetAt: 1 });
store.set("c", { count: 1, resetAt: 1 });
expect(store.size).toBe(2);
expect(store.get("a")).toBeUndefined();
expect(store.get("b")?.count).toBe(1);
expect(store.get("c")?.count).toBe(1);
});
it("LRU touch on get promotes recency", { timeout: 5000 }, () => {
const store = new MemoryRateLimitStore(2);
store.set("a", { count: 1, resetAt: 1 });
store.set("b", { count: 1, resetAt: 1 });
// Access "a" — promotes; next set evicts "b".
store.get("a");
store.set("c", { count: 1, resetAt: 1 });
expect(store.get("a")?.count).toBe(1);
expect(store.get("b")).toBeUndefined();
expect(store.get("c")?.count).toBe(1);
});
it("set on existing key updates and re-promotes", {
timeout: 5000,
}, () => {
const store = new MemoryRateLimitStore(2);
store.set("a", { count: 1, resetAt: 1 });
store.set("b", { count: 1, resetAt: 1 });
store.set("a", { count: 5, resetAt: 1 });
// "a" is now newest; "c" should evict "b"
store.set("c", { count: 1, resetAt: 1 });
expect(store.get("b")).toBeUndefined();
expect(store.get("a")?.count).toBe(5);
});
it("delete removes a key", { timeout: 5000 }, () => {
const store = new MemoryRateLimitStore();
store.set("a", { count: 1, resetAt: 1 });
store.delete("a");
expect(store.get("a")).toBeUndefined();
});
});
describe("tenantRateLimit", () => {
it("exceeds limit responds 429 with Retry-After", {
timeout: 5000,
}, async () => {
const app = appWith(
tenantRateLimit({ maxRequests: 2, windowMs: 60_000 })
);
await app.request("/", { headers: { "x-tenant-id": "acme" } });
await app.request("/", { headers: { "x-tenant-id": "acme" } });
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).not.toBeNull();
const body = (await res.json()) as { code: string; retryAfter: number };
expect(body.code).toBe(TENANT_CODES.RATE_LIMITED);
expect(body.retryAfter).toBeGreaterThan(0);
});
it("pluggable store interface", { timeout: 5000 }, async () => {
const calls: { op: string; key: string }[] = [];
const inner = new Map<string, RateLimitRecord>();
const store: RateLimitStore = {
get(key) {
calls.push({ op: "get", key });
return inner.get(key);
},
set(key, value) {
calls.push({ op: "set", key });
inner.set(key, value);
},
delete(key) {
calls.push({ op: "delete", key });
inner.delete(key);
},
};
const app = appWith(
tenantRateLimit({
maxRequests: 10,
windowMs: 60_000,
store,
keyPrefix: "rl",
})
);
await app.request("/", { headers: { "x-tenant-id": "acme" } });
expect(calls.some((c) => c.op === "get" && c.key === "rl:acme")).toBe(
true
);
expect(calls.some((c) => c.op === "set" && c.key === "rl:acme")).toBe(
true
);
});
it("tenant-less request passes through", { timeout: 5000 }, async () => {
const app = new Hono();
app.use("*", tenantRateLimit({ maxRequests: 1, windowMs: 1000 }));
app.get("/", (c) => c.text("ok"));
const r1 = await app.request("/");
const r2 = await app.request("/");
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
});
it("sets X-RateLimit-* headers on success", {
timeout: 5000,
}, async () => {
const app = appWith(
tenantRateLimit({ maxRequests: 5, windowMs: 60_000 })
);
const res = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(res.headers.get("X-RateLimit-Limit")).toBe("5");
expect(res.headers.get("X-RateLimit-Remaining")).toBe("4");
expect(res.headers.get("X-RateLimit-Reset")).not.toBeNull();
});
it("window expiry resets counter", { timeout: 5000 }, async () => {
let now = 1_000_000;
const app = appWith(
tenantRateLimit({
maxRequests: 1,
windowMs: 1000,
clock: () => now,
})
);
const r1 = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(r1.status).toBe(200);
const r2 = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(r2.status).toBe(429);
now += 2000;
const r3 = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(r3.status).toBe(200);
});
it("uses default store + prefix when omitted", {
timeout: 5000,
}, async () => {
const app = appWith(
tenantRateLimit({ maxRequests: 1, windowMs: 60_000 })
);
await app.request("/", { headers: { "x-tenant-id": "acme" } });
const r2 = await app.request("/", {
headers: { "x-tenant-id": "acme" },
});
expect(r2.status).toBe(429);
});
});

View File

@@ -0,0 +1,159 @@
import type { Context, MiddlewareHandler, Next } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
/** Rate-limit window record stored under a key. */
export interface RateLimitRecord {
count: number;
resetAt: number;
}
/** Pluggable backing store interface. */
export interface RateLimitStore {
get(
key: string
): Promise<RateLimitRecord | undefined> | RateLimitRecord | undefined;
set(key: string, value: RateLimitRecord): Promise<void> | void;
delete(key: string): Promise<void> | void;
}
/**
* Bounded in-memory store with LRU eviction at `maxEntries`. Per-process
* only — production deployments should swap in a Redis-backed store via
* the same `RateLimitStore` interface.
*/
export class MemoryRateLimitStore implements RateLimitStore {
private readonly maxEntries: number;
private readonly entries = new Map<string, RateLimitRecord>();
public constructor(maxEntries = 10_000) {
this.maxEntries = maxEntries;
}
public get(key: string): RateLimitRecord | undefined {
const record = this.entries.get(key);
if (!record) return undefined;
// LRU touch — move to end of insertion order.
this.entries.delete(key);
this.entries.set(key, record);
return record;
}
public set(key: string, value: RateLimitRecord): void {
if (this.entries.has(key)) {
this.entries.delete(key);
} else if (this.entries.size >= this.maxEntries) {
// `entries.size >= maxEntries` AND `maxEntries >= 1` (by
// constructor contract) guarantees at least one key exists.
const oldest = this.entries.keys().next().value!;
this.entries.delete(oldest);
}
this.entries.set(key, value);
}
public delete(key: string): void {
this.entries.delete(key);
}
public get size(): number {
return this.entries.size;
}
}
/** Options accepted by {@link tenantRateLimit}. */
export interface TenantRateLimitOptions {
/** Maximum requests per window. */
readonly maxRequests: number;
/** Window size in milliseconds. */
readonly windowMs: number;
/** Pluggable store — defaults to `MemoryRateLimitStore(10_000)`. */
readonly store?: RateLimitStore;
/** Key prefix (multi-tenant deployments may want unique app prefixes). */
readonly keyPrefix?: string;
/** Injectable clock — defaults to `Date.now`. */
readonly clock?: () => number;
}
/**
* Per-tenant rate-limit middleware. Reads `c.get("tenant").id` (set by
* `createTenantMiddleware`) and bumps a counter against the configured
* store. Tenant-less requests pass through untouched. Exceeding the
* window raises `TENANT_RATE_LIMITED` with a `Retry-After` header.
*/
export function tenantRateLimit(
options: TenantRateLimitOptions
): MiddlewareHandler {
const {
maxRequests,
windowMs,
store = new MemoryRateLimitStore(),
keyPrefix = "ratelimit",
clock = Date.now,
} = options;
return async function tenantRateLimitMiddleware(
c: Context,
next: Next
): Promise<Response | undefined> {
const tenant = c.get("tenant");
if (!tenant) {
await next();
return undefined;
}
const key = `${keyPrefix}:${tenant.id}`;
const now = clock();
let entry = await store.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
}
entry.count += 1;
await store.set(key, entry);
const remaining = Math.max(0, maxRequests - entry.count);
c.res.headers.set("X-RateLimit-Limit", String(maxRequests));
c.res.headers.set("X-RateLimit-Remaining", String(remaining));
c.res.headers.set(
"X-RateLimit-Reset",
String(Math.ceil(entry.resetAt / 1000))
);
if (entry.count > maxRequests) {
const retryAfter = Math.max(
1,
Math.ceil((entry.resetAt - now) / 1000)
);
const error = new TenantError(
TENANT_CODES.RATE_LIMITED,
`Tenant ${tenant.id} exceeded ${maxRequests} requests / ${windowMs} ms`,
{
context: {
tenantId: tenant.id,
limit: maxRequests,
windowMs,
retryAfter,
},
}
);
const headers = new Headers();
headers.set("Retry-After", String(retryAfter));
headers.set("X-RateLimit-Limit", String(maxRequests));
headers.set("X-RateLimit-Remaining", "0");
headers.set(
"X-RateLimit-Reset",
String(Math.ceil(entry.resetAt / 1000))
);
headers.set("Content-Type", "application/json");
return new Response(
JSON.stringify({
code: error.code,
message: error.message,
retryAfter,
}),
{ status: 429, headers }
);
}
await next();
return;
};
}

View File

@@ -0,0 +1,20 @@
export type {
TenantLookupFn,
TenantResolver,
} from "./resolver.js";
export {
ChainedTenantResolver,
chainResolvers,
DomainTenantResolver,
domainResolver,
HeaderTenantResolver,
headerResolver,
JWTTenantResolver,
jwtResolver,
PathTenantResolver,
pathResolver,
SubdomainTenantResolver,
subdomainResolver,
TimeoutTenantResolver,
timeoutResolver,
} from "./resolver.js";

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,332 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import type { Tenant } from "../types/tenant.js";
import {
ChainedTenantResolver,
chainResolvers,
domainResolver,
headerResolver,
jwtResolver,
pathResolver,
subdomainResolver,
TimeoutTenantResolver,
timeoutResolver,
} from "./resolver.js";
function makeTenant(slug: string): Tenant {
return {
id: `t_${slug}`,
name: slug,
slug,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
async function captureResolve(
resolver: { resolve(c: never): Promise<Tenant | null> },
init: { url: string; headers?: Record<string, string>; user?: unknown }
): Promise<Tenant | null> {
const app = new Hono();
let captured: Tenant | null | undefined;
let thrown: unknown;
app.all("*", async (c) => {
if (init.user !== undefined) {
c.set("user" as never, init.user as never);
}
try {
captured = await resolver.resolve(c as never);
} catch (err) {
thrown = err;
}
return c.text("ok");
});
await app.request(init.url, { headers: init.headers });
if (thrown !== undefined) throw thrown;
return captured ?? null;
}
describe("HeaderTenantResolver", () => {
it("calls lookupFn with header value", { timeout: 5000 }, async () => {
const seen: string[] = [];
const r = headerResolver("x-tenant-id", async (id) => {
seen.push(id);
return makeTenant(id);
});
const t = await captureResolve(r, {
url: "http://x/",
headers: { "x-tenant-id": "acme" },
});
expect(seen).toEqual(["acme"]);
expect(t?.slug).toBe("acme");
});
it("returns null when header missing", { timeout: 5000 }, async () => {
const r = headerResolver("x-tenant-id", async () => makeTenant("x"));
const t = await captureResolve(r, { url: "http://x/" });
expect(t).toBeNull();
});
});
describe("SubdomainTenantResolver", () => {
it("subdomain strips port and www", { timeout: 5000 }, async () => {
const seen: string[] = [];
const r = subdomainResolver("app.com", async (slug) => {
seen.push(slug);
return makeTenant(slug);
});
const t = await captureResolve(r, {
url: "http://foo.app.com:3000/",
headers: { host: "foo.app.com:3000" },
});
expect(seen).toEqual(["foo"]);
expect(t?.slug).toBe("foo");
const t2 = await captureResolve(r, {
url: "http://www.app.com/",
headers: { host: "www.app.com" },
});
expect(t2).toBeNull();
});
it("returns null without host header", { timeout: 5000 }, async () => {
const r = subdomainResolver("app.com", async (s) => makeTenant(s));
// Manually construct context without host
const app = new Hono();
let captured: Tenant | null | undefined;
app.all("*", async (c) => {
captured = await r.resolve(c);
return c.text("ok");
});
await app.request("http://x/", { headers: {} as never });
// Hono sets host automatically from URL, so use a request lacking host
// by stripping. Simpler: assert mismatch returns null.
const t = await captureResolve(r, {
url: "http://other.com/",
headers: { host: "other.com" },
});
expect(t).toBeNull();
expect(captured).toBeDefined();
});
});
describe("DomainTenantResolver", () => {
it("uses hostname minus port as key", { timeout: 5000 }, async () => {
const seen: string[] = [];
const r = domainResolver(async (d) => {
seen.push(d);
return makeTenant(d);
});
const t = await captureResolve(r, {
url: "http://books.example.com:8080/",
headers: { host: "books.example.com:8080" },
});
expect(seen).toEqual(["books.example.com"]);
expect(t?.slug).toBe("books.example.com");
});
it("returns null when no host header", { timeout: 5000 }, async () => {
const r = domainResolver(async (d) => makeTenant(d));
const app = new Hono();
let captured: Tenant | null | undefined;
app.all("*", async (c) => {
// Spoof: delete host header by accessing raw request
captured = await r.resolve(c);
return c.text("ok");
});
// Hono's default host is from URL; we test the branch through a request
// that has no host header by passing a Request with explicit headers absent.
const req = new Request("http://x/", { headers: {} });
req.headers.delete("host");
await app.fetch(req);
expect(captured).toBeNull();
});
});
describe("PathTenantResolver", () => {
it("extracts slug after prefix", { timeout: 5000 }, async () => {
const seen: string[] = [];
const r = pathResolver("/t/", async (slug) => {
seen.push(slug);
return makeTenant(slug);
});
const t = await captureResolve(r, { url: "http://x/t/acme/users" });
expect(seen).toEqual(["acme"]);
expect(t?.slug).toBe("acme");
});
it("returns null when path lacks prefix", { timeout: 5000 }, async () => {
const r = pathResolver("/t/", async (s) => makeTenant(s));
const t = await captureResolve(r, { url: "http://x/other" });
expect(t).toBeNull();
});
it("returns null when no slug after prefix", {
timeout: 5000,
}, async () => {
const r = pathResolver("/t/", async (s) => makeTenant(s));
const t = await captureResolve(r, { url: "http://x/t/" });
expect(t).toBeNull();
});
});
describe("JWTTenantResolver", () => {
it("jwt reads claim from c.get('user')", { timeout: 5000 }, async () => {
const r = jwtResolver("tenantId", async (id) => makeTenant(id));
const t = await captureResolve(r, {
url: "http://x/",
user: { tenantId: "acme", sub: "u1" },
});
expect(t?.slug).toBe("acme");
});
it("returns null when no user set", { timeout: 5000 }, async () => {
const r = jwtResolver("tenantId", async (id) => makeTenant(id));
const t = await captureResolve(r, { url: "http://x/" });
expect(t).toBeNull();
});
it("returns null when claim missing or non-string", {
timeout: 5000,
}, async () => {
const r = jwtResolver("tenantId", async (id) => makeTenant(id));
const t1 = await captureResolve(r, {
url: "http://x/",
user: { sub: "u1" },
});
expect(t1).toBeNull();
const t2 = await captureResolve(r, {
url: "http://x/",
user: { tenantId: 42 },
});
expect(t2).toBeNull();
const t3 = await captureResolve(r, {
url: "http://x/",
user: { tenantId: "" },
});
expect(t3).toBeNull();
});
});
describe("ChainedTenantResolver", () => {
it("chained returns first non-null", { timeout: 5000 }, async () => {
const header = headerResolver("x-tenant-id", async () => null);
let jwtCalled = false;
const jwt = jwtResolver("tenantId", async (id) => {
jwtCalled = true;
return makeTenant(id);
});
let subCalled = false;
const sub = subdomainResolver("app.com", async (slug) => {
subCalled = true;
return makeTenant(slug);
});
const chain = chainResolvers(header, jwt, sub);
const t = await captureResolve(chain, {
url: "http://x/",
user: { tenantId: "from-jwt" },
});
expect(t?.slug).toBe("from-jwt");
expect(jwtCalled).toBe(true);
expect(subCalled).toBe(false);
});
it("returns null when no child resolves", { timeout: 5000 }, async () => {
const a = headerResolver("x-a", async () => null);
const b = headerResolver("x-b", async () => null);
const chain = new ChainedTenantResolver(a, b);
const t = await captureResolve(chain, { url: "http://x/" });
expect(t).toBeNull();
});
it("exposes name 'chained'", { timeout: 5000 }, () => {
const chain = new ChainedTenantResolver();
expect(chain.name).toBe("chained");
});
});
describe("TimeoutTenantResolver", () => {
it("raises TENANT_LOOKUP_TIMEOUT when inner hangs", {
timeout: 5000,
}, async () => {
const slow = {
name: "slow",
resolve: () => new Promise<Tenant | null>(() => {}),
};
const wrapped = timeoutResolver(slow, 25);
await expect(
captureResolve(wrapped, { url: "http://x/" })
).rejects.toMatchObject({ code: TENANT_CODES.LOOKUP_TIMEOUT });
});
it("resolves through when inner returns in time", {
timeout: 5000,
}, async () => {
const fast = headerResolver("x-tenant-id", async (id) =>
makeTenant(id)
);
const wrapped = timeoutResolver(fast, 1000);
const t = await captureResolve(wrapped, {
url: "http://x/",
headers: { "x-tenant-id": "acme" },
});
expect(t?.slug).toBe("acme");
});
it("propagates inner errors verbatim", { timeout: 5000 }, async () => {
const fail = {
name: "fail",
resolve: async () => {
throw new Error("boom");
},
};
const wrapped = new TimeoutTenantResolver(fail, 1000);
await expect(
captureResolve(wrapped, { url: "http://x/" })
).rejects.toMatchObject({ message: "boom" });
});
it("exposes wrapped name", { timeout: 5000 }, () => {
const inner = headerResolver("x", async () => null);
const wrapped = new TimeoutTenantResolver(inner, 10);
expect(wrapped.name).toBe("timeout(header)");
});
it("does not double-raise after inner rejected", {
timeout: 5000,
}, async () => {
const fast = {
name: "fast",
resolve: async () => {
throw new Error("boom");
},
};
const wrapped = timeoutResolver(fast, 5);
await expect(
captureResolve(wrapped, { url: "http://x/" })
).rejects.toBeInstanceOf(Error);
// Let the timer fire (it would have at 5ms); confirm no unhandled rejection.
await new Promise((r) => setTimeout(r, 20));
});
it("TenantError thrown is the typed class", {
timeout: 5000,
}, async () => {
const slow = {
name: "slow",
resolve: () => new Promise<Tenant | null>(() => {}),
};
const wrapped = timeoutResolver(slow, 10);
try {
await captureResolve(wrapped, { url: "http://x/" });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(TenantError);
}
});
});

View File

@@ -0,0 +1,246 @@
import type { Context } from "hono";
import { TENANT_CODES, TenantError } from "../error-codes/error-codes.js";
import type { Tenant } from "../types/tenant.js";
/** Lookup function shape — accepts an opaque key (id/slug/domain). */
export type TenantLookupFn = (key: string) => Promise<Tenant | null>;
/**
* A resolver maps a single inbound request to a `Tenant` or `null`. The
* concrete strategy (header, subdomain, path, JWT claim, …) decides
* which piece of the request to inspect and which `lookupFn` key to
* call with.
*/
export interface TenantResolver {
/** Stable identifier — used for OTel attributes and chain composition. */
readonly name: string;
/** Resolve the inbound request. Returns `null` when no tenant matches. */
resolve(c: Context): Promise<Tenant | null>;
}
/** Header-based resolver — reads a request header value as the lookup key. */
export class HeaderTenantResolver implements TenantResolver {
public readonly name = "header";
private readonly headerName: string;
private readonly lookupFn: TenantLookupFn;
public constructor(headerName: string, lookupFn: TenantLookupFn) {
this.headerName = headerName;
this.lookupFn = lookupFn;
}
public async resolve(c: Context): Promise<Tenant | null> {
const tenantId = c.req.header(this.headerName);
if (!tenantId) return null;
return this.lookupFn(tenantId);
}
}
/**
* Subdomain-based resolver — strips port and the literal `www` label so
* `foo.app.com:3000` and `www.app.com` both behave correctly.
*/
export class SubdomainTenantResolver implements TenantResolver {
public readonly name = "subdomain";
private readonly baseDomain: string;
private readonly lookupFn: TenantLookupFn;
public constructor(baseDomain: string, lookupFn: TenantLookupFn) {
this.baseDomain = baseDomain;
this.lookupFn = lookupFn;
}
public async resolve(c: Context): Promise<Tenant | null> {
const host = c.req.header("host");
if (!host) return null;
const subdomain = this.extractSubdomain(host);
if (!subdomain) return null;
return this.lookupFn(subdomain);
}
private extractSubdomain(host: string): string | null {
// `String#split` always yields at least one element — the non-null
// assertion is sound and keeps the branch graph minimal.
const hostname = host.split(":")[0]!;
if (!hostname.endsWith(this.baseDomain)) return null;
const subdomain = hostname.slice(0, -this.baseDomain.length - 1);
if (!subdomain || subdomain === "www") return null;
return subdomain;
}
}
/**
* Domain-based resolver — strips port and uses the full hostname as the
* lookup key. Suits custom-domain mappings (`books.example.com → tenant
* "books-co"`).
*/
export class DomainTenantResolver implements TenantResolver {
public readonly name = "domain";
private readonly lookupFn: TenantLookupFn;
public constructor(lookupFn: TenantLookupFn) {
this.lookupFn = lookupFn;
}
public async resolve(c: Context): Promise<Tenant | null> {
const host = c.req.header("host");
if (!host) return null;
// `String#split` always yields at least one element.
const domain = host.split(":")[0]!;
return this.lookupFn(domain);
}
}
/**
* Path-prefix resolver — for `/t/<slug>/...` style URLs. Reads the slug
* immediately following `prefix` and feeds it to `lookupFn`.
*/
export class PathTenantResolver implements TenantResolver {
public readonly name = "path";
private readonly prefix: string;
private readonly lookupFn: TenantLookupFn;
public constructor(prefix: string, lookupFn: TenantLookupFn) {
this.prefix = prefix;
this.lookupFn = lookupFn;
}
public async resolve(c: Context): Promise<Tenant | null> {
const path = c.req.path;
if (!path.startsWith(this.prefix)) return null;
const remaining = path.slice(this.prefix.length);
const slug = remaining.split("/")[0];
if (!slug) return null;
return this.lookupFn(slug);
}
}
/**
* JWT-claim resolver — reads `c.get("user")` (set by an upstream auth
* middleware) and extracts the configured claim as the lookup key.
*/
export class JWTTenantResolver implements TenantResolver {
public readonly name = "jwt";
private readonly claimName: string;
private readonly lookupFn: TenantLookupFn;
public constructor(claimName: string, lookupFn: TenantLookupFn) {
this.claimName = claimName;
this.lookupFn = lookupFn;
}
public async resolve(c: Context): Promise<Tenant | null> {
const user = c.get("user") as Record<string, unknown> | undefined;
if (!user) return null;
const tenantId = user[this.claimName];
if (typeof tenantId !== "string" || !tenantId) return null;
return this.lookupFn(tenantId);
}
}
/**
* Chained resolver — invokes children in order and returns the first
* non-null result. Stops iteration on the first hit.
*/
export class ChainedTenantResolver implements TenantResolver {
public readonly name = "chained";
private readonly resolvers: readonly TenantResolver[];
public constructor(...resolvers: TenantResolver[]) {
this.resolvers = resolvers;
}
public async resolve(c: Context): Promise<Tenant | null> {
for (const resolver of this.resolvers) {
const tenant = await resolver.resolve(c);
if (tenant) return tenant;
}
return null;
}
}
/** Factory helpers — keep call sites concise. */
export function headerResolver(
headerName: string,
lookupFn: TenantLookupFn
): TenantResolver {
return new HeaderTenantResolver(headerName, lookupFn);
}
export function subdomainResolver(
baseDomain: string,
lookupFn: TenantLookupFn
): TenantResolver {
return new SubdomainTenantResolver(baseDomain, lookupFn);
}
export function domainResolver(lookupFn: TenantLookupFn): TenantResolver {
return new DomainTenantResolver(lookupFn);
}
export function pathResolver(
prefix: string,
lookupFn: TenantLookupFn
): TenantResolver {
return new PathTenantResolver(prefix, lookupFn);
}
export function jwtResolver(
claimName: string,
lookupFn: TenantLookupFn
): TenantResolver {
return new JWTTenantResolver(claimName, lookupFn);
}
export function chainResolvers(...resolvers: TenantResolver[]): TenantResolver {
return new ChainedTenantResolver(...resolvers);
}
/**
* Timeout wrapper — raises `TenantError(TENANT_LOOKUP_TIMEOUT)` when the
* wrapped resolver does not settle within `timeoutMs`. The original
* promise is left to settle (no abort), so the wrapped store is free
* to clean up at its own pace.
*/
export class TimeoutTenantResolver implements TenantResolver {
public readonly name: string;
private readonly inner: TenantResolver;
private readonly timeoutMs: number;
public constructor(inner: TenantResolver, timeoutMs: number) {
this.inner = inner;
this.timeoutMs = timeoutMs;
this.name = `timeout(${inner.name})`;
}
public resolve(c: Context): Promise<Tenant | null> {
return new Promise<Tenant | null>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new TenantError(
TENANT_CODES.LOOKUP_TIMEOUT,
`Tenant lookup via ${this.inner.name} exceeded ${this.timeoutMs} ms`,
{ context: { resolver: this.inner.name } }
)
);
}, this.timeoutMs);
this.inner.resolve(c).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(cause) => {
clearTimeout(timer);
reject(cause);
}
);
});
}
}
export function timeoutResolver(
inner: TenantResolver,
timeoutMs: number
): TenantResolver {
return new TimeoutTenantResolver(inner, timeoutMs);
}

View File

@@ -0,0 +1,8 @@
export {
getCurrentTenant,
getCurrentTenantId,
isTenantStorageEnabled,
runWithTenant,
tenantStorageMiddleware,
withTenantStorage,
} from "./storage.js";

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,137 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createTenantMiddleware } from "../middleware/middleware.js";
import { headerResolver } from "../resolver/resolver.js";
import type { Tenant } from "../types/tenant.js";
import {
getCurrentTenant,
getCurrentTenantId,
isTenantStorageEnabled,
runWithTenant,
tenantStorageMiddleware,
withTenantStorage,
} from "./storage.js";
function makeTenant(id = "t_1"): Tenant {
return {
id,
name: "Acme",
slug: id,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
afterEach(() => {
withTenantStorage(false);
});
describe("withTenantStorage toggle", () => {
it("defaults to off", { timeout: 5000 }, () => {
expect(isTenantStorageEnabled()).toBe(false);
});
it("toggles on/off", { timeout: 5000 }, () => {
withTenantStorage(true);
expect(isTenantStorageEnabled()).toBe(true);
withTenantStorage(false);
expect(isTenantStorageEnabled()).toBe(false);
});
});
describe("AsyncLocalStorage thread", () => {
it("ALS off keeps getCurrentTenant undefined", {
timeout: 5000,
}, async () => {
const app = new Hono();
let seen: Tenant | undefined;
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant()
),
})
);
app.use("*", tenantStorageMiddleware());
app.get("/", async (c) => {
await new Promise<void>((r) => setImmediate(r));
seen = getCurrentTenant();
return c.text("ok");
});
await app.request("/", { headers: { "x-tenant-id": "acme" } });
expect(seen).toBeUndefined();
});
it("ALS on threads tenant through async stack", {
timeout: 5000,
}, async () => {
withTenantStorage(true);
const app = new Hono();
let seenId: string | undefined;
app.use(
"*",
createTenantMiddleware({
resolver: headerResolver("x-tenant-id", async () =>
makeTenant("acme-deep")
),
})
);
app.use("*", tenantStorageMiddleware());
app.get("/", async (c) => {
// Cross several async boundaries before reading.
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setTimeout(r, 1));
seenId = getCurrentTenantId();
return c.text("ok");
});
await app.request("/", { headers: { "x-tenant-id": "acme" } });
expect(seenId).toBe("acme-deep");
});
it("middleware is no-op when tenant unresolved (ALS on)", {
timeout: 5000,
}, async () => {
withTenantStorage(true);
const app = new Hono();
app.use("*", tenantStorageMiddleware());
app.get("/", (c) => {
expect(getCurrentTenant()).toBeUndefined();
return c.text("ok");
});
const res = await app.request("/");
expect(res.status).toBe(200);
});
it("runWithTenant runs inside ALS scope", {
timeout: 5000,
}, async () => {
withTenantStorage(true);
const t = makeTenant("explicit");
const id = runWithTenant(t, () => getCurrentTenantId());
expect(id).toBe("explicit");
});
it("runWithTenant runs fn directly when ALS off", {
timeout: 5000,
}, () => {
const t = makeTenant();
const id = runWithTenant(t, () => getCurrentTenantId());
expect(id).toBeUndefined();
});
it("middleware bypasses ALS when toggle is off", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", tenantStorageMiddleware());
app.get("/", (c) => c.text("ok"));
const res = await app.request("/");
expect(res.status).toBe(200);
expect(isTenantStorageEnabled()).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { Context, MiddlewareHandler, Next } from "hono";
import type { Tenant } from "../types/tenant.js";
const tenantStorage = new AsyncLocalStorage<Tenant>();
let enabled = false;
/**
* Toggle the opt-in `AsyncLocalStorage` thread for the tenant value.
* When `false` (default), `getCurrentTenant()` always returns
* `undefined` and the middleware factory returned by
* {@link tenantStorageMiddleware} is a no-op pass-through. When
* `true`, the same middleware wraps `next()` in `tenantStorage.run`
* so deep call-stack code can read the tenant without threading the
* Hono context through every signature.
*
* Genesis defaults this OFF to eliminate the dual-access-path footgun
* upstream had — applications opt-in only when they genuinely need
* the ALS thread.
*/
export function withTenantStorage(enabled_: boolean): void {
enabled = enabled_;
}
/** Inspect the current toggle — primarily for tests. */
export function isTenantStorageEnabled(): boolean {
return enabled;
}
/** Read the tenant from the ALS scope, or `undefined` when disabled / outside. */
export function getCurrentTenant(): Tenant | undefined {
if (!enabled) return undefined;
return tenantStorage.getStore();
}
/** Shorthand for `getCurrentTenant()?.id`. */
export function getCurrentTenantId(): string | undefined {
return getCurrentTenant()?.id;
}
/**
* Middleware factory — when ALS is enabled, wraps `next()` in
* `tenantStorage.run(tenant, next)`. Mount it AFTER
* `createTenantMiddleware` so `c.get("tenant")` is populated.
*/
export function tenantStorageMiddleware(): MiddlewareHandler {
return async function tenantStorageThread(
c: Context,
next: Next
): Promise<void> {
if (!enabled) {
await next();
return;
}
const tenant = c.get("tenant");
if (!tenant) {
await next();
return;
}
await tenantStorage.run(tenant, async () => {
await next();
});
};
}
/**
* Run `fn` inside the ALS scope explicitly — useful for background
* jobs spawned outside the request lifecycle that still need the
* tenant threaded through inner async code.
*/
export function runWithTenant<T>(tenant: Tenant, fn: () => T): T {
if (!enabled) return fn();
return tenantStorage.run(tenant, fn);
}

View File

@@ -0,0 +1,12 @@
export {
hasPermission,
type Tenant,
type TenantBranding,
type TenantInvitation,
type TenantLimits,
type TenantMember,
type TenantRole,
TenantRolePermissions,
type TenantSettings,
type TenantStatus,
} from "./tenant.js";

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`.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { hasPermission, TenantRolePermissions } from "./tenant.js";
describe("hasPermission", () => {
it("owner has wildcard permission", { timeout: 5000 }, () => {
expect(hasPermission("owner", "any:string")).toBe(true);
expect(hasPermission("owner", "tenant:delete")).toBe(true);
});
it("viewer cannot tenant:update", { timeout: 5000 }, () => {
expect(hasPermission("viewer", "tenant:update")).toBe(false);
});
it("viewer can tenant:read", { timeout: 5000 }, () => {
expect(hasPermission("viewer", "tenant:read")).toBe(true);
});
it("admin can update settings but not invite to owner", {
timeout: 5000,
}, () => {
expect(hasPermission("admin", "settings:update")).toBe(true);
expect(hasPermission("admin", "tenant:delete")).toBe(false);
});
it("member can read but not update", { timeout: 5000 }, () => {
expect(hasPermission("member", "tenant:read")).toBe(true);
expect(hasPermission("member", "tenant:update")).toBe(false);
});
});
describe("TenantRolePermissions", () => {
it("owner carries only the wildcard", { timeout: 5000 }, () => {
expect(TenantRolePermissions.owner).toEqual(["*"]);
});
it("viewer carries only tenant:read", { timeout: 5000 }, () => {
expect(TenantRolePermissions.viewer).toEqual(["tenant:read"]);
});
it("admin carries the full update set", { timeout: 5000 }, () => {
expect(TenantRolePermissions.admin).toContain("tenant:update");
expect(TenantRolePermissions.admin).toContain("members:create");
expect(TenantRolePermissions.admin).toContain("settings:update");
});
});

View File

@@ -0,0 +1,109 @@
/**
* Canonical `Tenant` interface + supporting types for `@suaveplan/api-tenant`.
*
* Genesis ships ONE tenant shape — upstream's `TenantConfig` /
* `TenantEventConfig` duplicates are deliberately omitted; consumers
* thread the `Tenant` value through the entire stack.
*/
/** Lifecycle status of a tenant. */
export type TenantStatus = "active" | "suspended" | "pending" | "deleted";
/** Role taxonomy honoured by {@link hasPermission}. */
export type TenantRole = "owner" | "admin" | "member" | "viewer";
/** Optional resource caps a caller can read via `getLimit`. */
export interface TenantLimits {
maxUsers?: number;
maxStorage?: number;
maxApiCalls?: number;
maxSites?: number;
}
/** Visual branding fields surfaced to UIs that scope per tenant. */
export interface TenantBranding {
logo?: string;
primaryColor?: string;
secondaryColor?: string;
customCss?: string;
}
/** Per-tenant settings. `features` and `limits` slots are reserved. */
export interface TenantSettings {
locale?: string;
timezone?: string;
dateFormat?: string;
features?: Record<string, boolean>;
limits?: TenantLimits;
branding?: TenantBranding;
custom?: Record<string, unknown>;
}
/** Canonical tenant entity. The single shape stored under `c.set("tenant")`. */
export interface Tenant {
id: string;
name: string;
slug: string;
status: TenantStatus;
plan?: string;
settings: TenantSettings;
enabledPlugins: string[];
domains: string[];
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/** Membership record linking a user to a tenant + role. */
export interface TenantMember {
id: string;
tenantId: string;
userId: string;
role: TenantRole;
permissions: string[];
invitedBy?: string;
joinedAt: Date;
createdAt: Date;
updatedAt: Date;
}
/** Pending tenant invitation surface. Sending the email is application policy. */
export interface TenantInvitation {
id: string;
tenantId: string;
email: string;
role: TenantRole;
token: string;
expiresAt: Date;
invitedBy: string;
acceptedAt?: Date;
createdAt: Date;
}
/**
* Role → permission strings map. The wildcard `"*"` on `owner` grants
* every permission via {@link hasPermission}.
*/
export const TenantRolePermissions: Record<TenantRole, readonly string[]> = {
owner: ["*"],
admin: [
"tenant:read",
"tenant:update",
"members:read",
"members:create",
"members:update",
"members:delete",
"settings:read",
"settings:update",
],
member: ["tenant:read", "members:read", "settings:read"],
viewer: ["tenant:read"],
};
/**
* Returns `true` if `role` carries `permission` (or owns the wildcard).
*/
export function hasPermission(role: TenantRole, permission: string): boolean {
const permissions = TenantRolePermissions[role];
return permissions.includes("*") || permissions.includes(permission);
}

View File

@@ -0,0 +1,179 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_DIR = resolve(__dirname, "..");
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
exports?: Record<string, unknown>;
};
const ALLOWED_DEPS = new Set([
"@opentelemetry/api",
"@suaveplan/api-plugin",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"hono",
"tslib",
"zod",
]);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) {
if (name === "node_modules" || name === "dist") continue;
walk(p, out);
} else {
out.push(p);
}
}
return out;
}
describe("dep-graph", () => {
it("dependencies match the server allowlist", { timeout: 5000 }, () => {
const deps = Object.keys(pkg.dependencies ?? {});
for (const d of deps) {
expect(ALLOWED_DEPS.has(d)).toBe(true);
}
// Every allowed dep is present (no skip-by-omission).
for (const expected of [
"@suaveplan/error",
"@suaveplan/api-plugin",
"@suaveplan/events",
"@suaveplan/types",
"hono",
"tslib",
"zod",
"@opentelemetry/api",
]) {
expect(deps).toContain(expected);
}
});
it("subpath exports cover every feature", { timeout: 5000 }, () => {
const exports = pkg.exports ?? {};
const expected = [
".",
"./error-codes",
"./types/tenant",
"./resolver",
"./cache",
"./context",
"./middleware",
"./rate-limit",
"./lifecycle",
"./storage",
];
for (const e of expected) {
expect(Object.keys(exports)).toContain(e);
}
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const srcDir = join(PKG_DIR, "src");
const all = walk(srcDir);
const missing: string[] = [];
for (const file of all) {
if (!file.endsWith(".ts")) continue;
if (file.endsWith(".test.ts")) continue;
if (file.endsWith(".spec.ts")) continue;
if (file.endsWith(".types.ts")) continue;
const base = file.replace(/\.ts$/, "");
const fileName = base.split("/").pop();
if (fileName === "index") continue;
// The root index.ts is covered by the package README.
if (file === join(srcDir, "index.ts")) continue;
const md = `${base}.md`;
try {
statSync(md);
} catch {
missing.push(md.replace(`${PKG_DIR}/`, ""));
}
}
expect(missing).toEqual([]);
});
it("no biome suppressions in src/", { timeout: 5000 }, () => {
const all = walk(join(PKG_DIR, "src"));
const hits: string[] = [];
for (const f of all) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
const txt = readFileSync(f, "utf8");
if (txt.includes("biome-ignore")) hits.push(f);
if (txt.includes("eslint-disable")) hits.push(f);
}
expect(hits).toEqual([]);
});
it("no banned patterns in src/", { timeout: 5000 }, () => {
const banned = [
"TODO",
"FIXME",
"ts-ignore",
"ts-expect-error",
"@deprecated",
"console.log",
];
const all = walk(join(PKG_DIR, "src"));
const hits: { file: string; pattern: string }[] = [];
for (const f of all) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
const txt = readFileSync(f, "utf8");
for (const p of banned) {
if (txt.includes(p)) hits.push({ file: f, pattern: p });
}
}
expect(hits).toEqual([]);
});
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
const featureBarrels = readdirSync(join(PKG_DIR, "src"))
.filter((entry) => {
const p = join(PKG_DIR, "src", entry);
try {
return statSync(p).isDirectory();
} catch {
return false;
}
})
.map((d) => join(PKG_DIR, "src", d, "index.ts"));
const hits: string[] = [];
for (const b of featureBarrels) {
try {
const txt = readFileSync(b, "utf8");
if (/^export\s+\*/m.test(txt)) hits.push(b);
} catch {
/* directory may have no index.ts */
}
}
expect(hits).toEqual([]);
});
});
// Guard against accidental flat-error-codes file showing up.
describe("layout invariants", () => {
it("error-codes lives at src/error-codes/error-codes.ts (directory form)", {
timeout: 5000,
}, () => {
const inner = join(PKG_DIR, "src", "error-codes", "error-codes.ts");
statSync(inner);
const dir = dirname(inner);
// No flat src/error-codes.ts collision.
const flat = join(PKG_DIR, "src", "error-codes.ts");
let flatExists = true;
try {
statSync(flat);
} catch {
flatExists = false;
}
expect(flatExists).toBe(false);
expect(dir.endsWith("/error-codes")).toBe(true);
});
});

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
chainResolvers,
createTenantMiddleware,
headerResolver,
MemoryRateLimitStore,
subdomainResolver,
TENANT_CODES,
type Tenant,
TenantCache,
TenantError,
tenantRateLimit,
} from "../../src/index.js";
function makeTenant(id: string, over: Partial<Tenant> = {}): Tenant {
return {
id,
name: id,
slug: id,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
};
}
describe("integration — tenant middleware roundtrip (real Hono + cache + store)", () => {
it("resolves via chain, caches positive lookup, enforces rate limit", {
timeout: 30000,
}, async () => {
const db = new Map<string, Tenant>([
["acme", makeTenant("acme")],
["beta", makeTenant("beta")],
]);
let dbCalls = 0;
const lookup = async (k: string): Promise<Tenant | null> => {
dbCalls += 1;
return db.get(k) ?? null;
};
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", lookup),
subdomainResolver("app.test", lookup)
),
{ ttlMs: 60_000 }
);
const store = new MemoryRateLimitStore(100);
const app = new Hono();
app.use("*", createTenantMiddleware({ resolver }));
app.use(
"*",
tenantRateLimit({
maxRequests: 2,
windowMs: 60_000,
store,
keyPrefix: "rl",
})
);
app.get("/me", (c) => c.json({ id: c.get("tenant").id }));
app.onError((err, c) => {
if (err instanceof TenantError) {
return c.json({ code: err.code }, 404);
}
return c.text("boom", 500);
});
// 1. First hit — cold cache, db touched.
const r1 = await app.request("/me", {
headers: { "x-tenant-id": "acme" },
});
expect(r1.status).toBe(200);
expect(dbCalls).toBe(1);
// 2. Second hit — cache hit, db untouched.
const r2 = await app.request("/me", {
headers: { "x-tenant-id": "acme" },
});
expect(r2.status).toBe(200);
expect(dbCalls).toBe(1);
// 3. Third hit — rate-limit exceeded.
const r3 = await app.request("/me", {
headers: { "x-tenant-id": "acme" },
});
expect(r3.status).toBe(429);
const body = (await r3.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.RATE_LIMITED);
expect(r3.headers.get("Retry-After")).not.toBeNull();
});
it("subdomain fallback resolves when header missing", {
timeout: 30000,
}, async () => {
const db = new Map<string, Tenant>([["sub", makeTenant("sub")]]);
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", async (k) => db.get(k) ?? null),
subdomainResolver("app.test", async (k) => db.get(k) ?? null)
),
{ ttlMs: 60_000, negativeTtlMs: 1000 }
);
const app = new Hono();
app.use("*", createTenantMiddleware({ resolver }));
app.get("/", (c) => c.text(c.get("tenant").id));
app.onError((err, c) =>
c.json(
{
code: err instanceof TenantError ? err.code : "x",
message: err.message,
},
500
)
);
const res = await app.request("http://sub.app.test/", {
headers: { host: "sub.app.test" },
});
expect(await res.text()).toBe("sub");
});
it("unknown tenant in chain → 404 via onError mapping", {
timeout: 30000,
}, async () => {
const resolver = TenantCache.wrap(
chainResolvers(
headerResolver("x-tenant-id", async () => null),
subdomainResolver("app.test", async () => null)
),
{ ttlMs: 60_000 }
);
const app = new Hono();
app.use("*", createTenantMiddleware({ resolver }));
app.get("/", (c) => c.text(c.get("tenant").id));
app.onError((err, c) => {
if (err instanceof TenantError)
return c.json({ code: err.code }, 404);
return c.text("", 500);
});
const res = await app.request("http://unknown.app.test/", {
headers: { host: "unknown.app.test" },
});
expect(res.status).toBe(404);
const body = (await res.json()) as { code: string };
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { Hono } from "hono";
import { headerResolver, type Tenant, TenantCache } from "../src/index.js";
function makeTenant(id: string): Tenant {
return {
id,
name: id,
slug: id,
status: "active",
settings: {},
enabledPlugins: [],
domains: [],
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
describe("property — cache TTL boundary invariant", () => {
it("entry expires strictly after ttlMs boundary (fast-check 200 runs)", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 10, max: 10_000 }),
fc.integer({ min: 0, max: 100_000 }),
async (ttlMs, baseNow) => {
let calls = 0;
const inner = headerResolver("x-tenant-id", async (id) => {
calls += 1;
return makeTenant(id);
});
let now = baseNow;
const cached = TenantCache.wrap(inner, {
ttlMs,
clock: () => now,
});
const app = new Hono();
app.get("/", async (c) => {
await cached.resolve(c);
return c.text("ok");
});
const hit = (): Promise<unknown> =>
app.request("/", {
headers: { "x-tenant-id": "k", host: "x" },
});
await hit(); // populate
expect(calls).toBe(1);
// Strictly inside TTL → cached.
now = baseNow + ttlMs - 1;
await hit();
expect(calls).toBe(1);
// At the boundary (expiresAt > now is false when equal) → re-lookup.
now = baseNow + ttlMs;
await hit();
expect(calls).toBe(2);
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,39 @@
/**
* Test-environment setup for @suaveplan/api-tenant.
*
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
* source module is imported, so the meter resolved at module load
* is the real provider's meter — not the noop fallback. Tests that
* inspect emitted metrics can read them out of
* `testInMemoryMetricExporter`.
*
* Node ≥18 already exposes `crypto.subtle` and `crypto.getRandomValues`
* globally, so no crypto polyfill is required. Server-tier: `node:async_hooks`
* is allowed (used by the opt-in `withTenantStorage` builder).
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-tenant",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});