Initial Commit
This commit is contained in:
188
packages/api-stack/api/api-tenant/README.md
Normal file
188
packages/api-stack/api/api-tenant/README.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# @suaveplan/api-tenant - Multi-tenant resolution + scoping layer for the Suaveplan API. Ships a canonical Tenant type with role/permission helpers, six resolver strategies plus ChainedTenantResolver, a pluggable TenantCache with TTL + negative-entry cache + request coalescing, fail-closed NullTenantContext, a single-context-key Hono middleware (Hono ContextVariableMap augmentation), requirePlugin/requireFeature fail-closed guards, pluggable tenantRateLimit with bounded MemoryRateLimitStore (LRU eviction), TenantAwarePluginDescriptor with tenantScopes opt-out, and opt-in AsyncLocalStorage threading. Server-tier — runs in Bun and Node.
|
||||
|
||||
Multi-tenant resolution + scoping for the Suaveplan API. Server-tier —
|
||||
runs on Bun and Node, depends on `hono` and `node:async_hooks`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [cache](./src/cache/cache.md) — The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
|
||||
- [cache](./src/cache/cache.md) — The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware in-process cache that prevents lookup stampedes…
|
||||
- [context](./src/context/context.md) — The `context` module ships `createTenantContext(tenant)` — a pure factory that wraps a resolved `Tenant` in an ergonomi…
|
||||
- [context](./src/context/context.md) — The `context` module ships `createTenantContext(tenant)` — a pure factory that wraps a resolved `Tenant` in an ergonomi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the seven stable `TENANT.*` failure codes shipped by `@suaveplan/api-tenant` and the c…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module defines the seven stable `TENANT.*` failure codes shipped by `@suaveplan/api-tenant` and the c…
|
||||
- [lifecycle](./src/lifecycle/lifecycle.md) — The `lifecycle` module ships `TenantLifecycle`, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
|
||||
- [lifecycle](./src/lifecycle/lifecycle.md) — The `lifecycle` module ships `TenantLifecycle`, a thin dispatcher that wires the seven tenant lifecycle hooks (`onTenan…
|
||||
- [middleware](./src/middleware/middleware.md) — The `middleware` module ships `createTenantMiddleware(options)` — the Hono middleware that resolves the inbound tenant,…
|
||||
- [middleware](./src/middleware/middleware.md) — The `middleware` module ships `createTenantMiddleware(options)` — the Hono middleware that resolves the inbound tenant,…
|
||||
- [require-feature](./src/middleware/require-feature.md) — `requireFeature(name)` returns a Hono middleware that gates route access on the boolean at `tenant.settings.features?.[…
|
||||
- [require-plugin](./src/middleware/require-plugin.md) — `requirePlugin(name)` returns a Hono middleware that gates route access on `tenant.enabledPlugins.includes(name)`. The…
|
||||
- [rate-limit](./src/rate-limit/rate-limit.md) — The `rate-limit` module ships `tenantRateLimit(options)` — a Hono middleware that throttles requests per resolved tenan…
|
||||
- [rate-limit](./src/rate-limit/rate-limit.md) — The `rate-limit` module ships `tenantRateLimit(options)` — a Hono middleware that throttles requests per resolved tenan…
|
||||
- [resolver](./src/resolver/resolver.md) — The `resolver` module ships the six built-in `TenantResolver` implementations consumed by `createTenantMiddleware` plus…
|
||||
- [resolver](./src/resolver/resolver.md) — The `resolver` module ships the six built-in `TenantResolver` implementations consumed by `createTenantMiddleware` plus…
|
||||
- [storage](./src/storage/storage.md) — The `storage` module ships the opt-in `AsyncLocalStorage` thread for the resolved tenant. defaults the thread O…
|
||||
- [storage](./src/storage/storage.md) — The `storage` module ships the opt-in `AsyncLocalStorage` thread for the resolved tenant. defaults the thread O…
|
||||
- [types](./src/types/tenant.md) — The `tenant` module ships the single canonical `Tenant` interface consumed by every other module in `@suaveplan/api-ten…
|
||||
- [tenant](./src/types/tenant.md) — The `tenant` module ships the single canonical `Tenant` interface consumed by every other module in `@suaveplan/api-ten…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What you get
|
||||
|
||||
- `Tenant` — one canonical entity. `c.set("tenant", tenant)` is the
|
||||
only context key the middleware writes; the Hono `ContextVariableMap`
|
||||
augmentation gives `c.get("tenant")` a typed `Tenant` with no cast.
|
||||
- Six resolver strategies — `HeaderTenantResolver`,
|
||||
`SubdomainTenantResolver`, `DomainTenantResolver`,
|
||||
`PathTenantResolver`, `JWTTenantResolver`, `ChainedTenantResolver`.
|
||||
A `TimeoutTenantResolver` wraps any resolver with a millisecond
|
||||
timeout that surfaces as `TenantError(TENANT_LOOKUP_TIMEOUT)`.
|
||||
- `TenantCache.wrap(resolver, options)` — TTL + negative-entry cache
|
||||
- in-flight request coalescing + LRU eviction at `maxEntries`.
|
||||
- `createTenantMiddleware({ resolver, required?, excludePaths?,
|
||||
allowSuspended?, onError?, onResolved? })` — Hono middleware that
|
||||
applies the status gate (`deleted → TENANT_NOT_FOUND`, `suspended →
|
||||
TENANT_SUSPENDED`, `pending → TENANT_PENDING`).
|
||||
- `requirePlugin(name)` / `requireFeature(name)` — fail-closed guards.
|
||||
No tenant resolved → `TENANT_NOT_FOUND`. Missing entitlement →
|
||||
`TENANT_INVALID_INPUT`.
|
||||
- `tenantRateLimit({ maxRequests, windowMs, store? })` with the
|
||||
pluggable `RateLimitStore` interface; default `MemoryRateLimitStore`
|
||||
is bounded by `maxEntries` with LRU eviction.
|
||||
- `TenantAwarePluginDescriptor` + `TenantLifecycle` — seven tenant
|
||||
lifecycle hooks dispatched through the `HookExecutor` shared with
|
||||
`@suaveplan/api-plugin`. `tenantScopes: "all" | "explicit" | "none"`
|
||||
controls how the events fan out.
|
||||
- Opt-in `AsyncLocalStorage` thread via `withTenantStorage(true)` +
|
||||
`tenantStorageMiddleware()`; default OFF so `getCurrentTenant()` is
|
||||
`undefined` and cannot disagree with `c.get("tenant")`.
|
||||
- `NullTenantContext` — fail-closed sentinel for tenant-less code
|
||||
paths. Every predicate returns `false` / `undefined`.
|
||||
- 7 stable `TENANT.*` error codes via `defineCodes`.
|
||||
|
||||
## Quick-start
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
chainResolvers,
|
||||
createTenantMiddleware,
|
||||
headerResolver,
|
||||
requirePlugin,
|
||||
subdomainResolver,
|
||||
tenantRateLimit,
|
||||
TenantCache,
|
||||
TenantError,
|
||||
} from "@suaveplan/api-tenant";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const resolver = TenantCache.wrap(
|
||||
chainResolvers(
|
||||
headerResolver("x-tenant-id", (id) => db.tenants.byId(id)),
|
||||
subdomainResolver("suaveplan.io", (slug) =>
|
||||
db.tenants.bySlug(slug)
|
||||
)
|
||||
),
|
||||
{ ttlMs: 30_000, negativeTtlMs: 5_000 }
|
||||
);
|
||||
|
||||
app.use("*", createTenantMiddleware({ resolver }));
|
||||
app.use("*", tenantRateLimit({ maxRequests: 60, windowMs: 60_000 }));
|
||||
|
||||
app.get("/payments", requirePlugin("payment"), (c) =>
|
||||
c.json({ tenant: c.get("tenant").id })
|
||||
);
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof TenantError) {
|
||||
return c.json(
|
||||
{ code: err.code, message: err.message },
|
||||
err.code === "TENANT.NOT_FOUND" ? 404 : 403
|
||||
);
|
||||
}
|
||||
return c.text("Server error", 500);
|
||||
});
|
||||
```
|
||||
|
||||
## Cache configuration
|
||||
|
||||
```ts
|
||||
import { TenantCache, headerResolver } from "@suaveplan/api-tenant";
|
||||
|
||||
const cached = TenantCache.wrap(
|
||||
headerResolver("x-tenant-id", lookup),
|
||||
{
|
||||
ttlMs: 60_000,
|
||||
negativeTtlMs: 5_000,
|
||||
maxEntries: 10_000,
|
||||
keyFn: (c) => c.req.header("x-tenant-id") ?? "",
|
||||
clock: () => Date.now(),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Pluggable rate-limit store
|
||||
|
||||
```ts
|
||||
import {
|
||||
type RateLimitStore,
|
||||
tenantRateLimit,
|
||||
} from "@suaveplan/api-tenant";
|
||||
|
||||
const redisStore: RateLimitStore = {
|
||||
async get(key) {
|
||||
const raw = await redis.get(key);
|
||||
return raw ? JSON.parse(raw) : undefined;
|
||||
},
|
||||
async set(key, value) {
|
||||
await redis.set(key, JSON.stringify(value), "PX", value.resetAt);
|
||||
},
|
||||
async delete(key) {
|
||||
await redis.del(key);
|
||||
},
|
||||
};
|
||||
|
||||
app.use("*", tenantRateLimit({
|
||||
maxRequests: 1000,
|
||||
windowMs: 60_000,
|
||||
store: redisStore,
|
||||
keyPrefix: "rl:v1",
|
||||
}));
|
||||
```
|
||||
|
||||
## Opt-in AsyncLocalStorage
|
||||
|
||||
```ts
|
||||
import {
|
||||
getCurrentTenant,
|
||||
tenantStorageMiddleware,
|
||||
withTenantStorage,
|
||||
} from "@suaveplan/api-tenant";
|
||||
|
||||
withTenantStorage(true);
|
||||
app.use("*", tenantStorageMiddleware());
|
||||
|
||||
async function deep() {
|
||||
const tenant = getCurrentTenant();
|
||||
return tenant?.id ?? "anonymous";
|
||||
}
|
||||
```
|
||||
|
||||
When `withTenantStorage` is `false` (the default), `getCurrentTenant()`
|
||||
always returns `undefined`. This is deliberate — it eliminates the
|
||||
upstream dual-access-path footgun where ALS state could diverge from
|
||||
`c.get("tenant")`.
|
||||
|
||||
## Error codes
|
||||
|
||||
`TENANT_NOT_FOUND`, `TENANT_SUSPENDED`, `TENANT_PENDING`,
|
||||
`TENANT_RESOLUTION_FAILED`, `TENANT_RATE_LIMITED`,
|
||||
`TENANT_INVALID_INPUT`, `TENANT_LOOKUP_TIMEOUT`. All thrown as
|
||||
`TenantError extends SuaveplanError<TenantErrorCode>`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
117
packages/api-stack/api/api-tenant/package.json
Normal file
117
packages/api-stack/api/api-tenant/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
86
packages/api-stack/api/api-tenant/src/cache/cache.md
vendored
Normal file
86
packages/api-stack/api/api-tenant/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
# cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware
|
||||
in-process cache that prevents lookup stampedes. `TenantCache.wrap(resolver,
|
||||
options)` returns a new `TenantResolver` that holds positive entries for
|
||||
`ttlMs`, negative entries (no-tenant) for `negativeTtlMs`, evicts the
|
||||
oldest entry when the bounded `maxEntries` cap is hit, and coalesces
|
||||
concurrent in-flight lookups for the same key into a single `Promise` so
|
||||
a burst of requests hitting an unmatched key fires `lookupFn` exactly
|
||||
once.
|
||||
|
||||
The cache is intentionally in-process and bounded — the design trades a
|
||||
distributed-cache hit-rate for predictable memory + zero deployment
|
||||
state. Callers wanting Redis or another shared store can keep
|
||||
`TenantCache.wrap` in front of a custom resolver that reads from there;
|
||||
the cache wrapper is a separable concern from the persistence layer.
|
||||
|
||||
## Features
|
||||
|
||||
- TTL for positive entries (`ttlMs`) and negative entries
|
||||
(`negativeTtlMs`, defaults to `ttlMs / 4`).
|
||||
- Bounded by `maxEntries` (default 1000), oldest-first eviction
|
||||
using the natural insertion-order semantics of `Map`.
|
||||
- In-flight request coalescing via an internal `Map<key, Promise>` so
|
||||
100 parallel hits for the same key fire `lookupFn` once.
|
||||
- Pluggable `keyFn(c)` — defaults to `host + path`, falling back to
|
||||
`x-tenant-id` header, finally to bare path. Override per app.
|
||||
- Injectable `clock` — `FakeClock` from `@suaveplan/testing` works.
|
||||
- LRU touch-on-hit — accessing a cached entry re-inserts it so the
|
||||
oldest-evicted entry is the genuinely least-recently used one.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TenantCache, headerResolver } from "@suaveplan/api-tenant";
|
||||
|
||||
const inner = headerResolver("x-tenant-id", (id) => db.tenants.get(id));
|
||||
const cached = TenantCache.wrap(inner, { ttlMs: 30_000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pinning a deterministic clock and overriding the key derivation for a
|
||||
subdomain resolver:
|
||||
|
||||
```ts
|
||||
import { TenantCache, subdomainResolver } from "@suaveplan/api-tenant";
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const clock = new FakeClock(0);
|
||||
const cached = TenantCache.wrap(
|
||||
subdomainResolver("app.com", (slug) => db.tenants.bySlug(slug)),
|
||||
{
|
||||
ttlMs: 60_000,
|
||||
negativeTtlMs: 5_000,
|
||||
maxEntries: 5_000,
|
||||
keyFn: (c) => `sub:${c.req.header("host") ?? ""}`,
|
||||
clock: () => clock.now(),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TenantCache` — static `.wrap(resolver, options): TenantResolver`.
|
||||
- `class CachedTenantResolver` — concrete class, exposes `.size` and
|
||||
`.clear()` for tests; not normally constructed directly.
|
||||
- `interface TenantCacheOptions` — `ttlMs` (required), `negativeTtlMs?`,
|
||||
`maxEntries?`, `keyFn?`, `clock?`.
|
||||
- `type CacheClock = () => number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Map` preserves insertion order in JavaScript, so the "oldest" entry
|
||||
during eviction is the first one yielded by `.keys().next()`. A cache
|
||||
hit deletes-and-reinserts the entry, promoting it to the most-recent
|
||||
position — this is full LRU behaviour without a doubly-linked list.
|
||||
The negative TTL is shorter than the positive TTL by default so a
|
||||
mistyped tenant slug self-corrects faster than a real one becomes
|
||||
stale.
|
||||
226
packages/api-stack/api/api-tenant/src/cache/cache.test.ts
vendored
Normal file
226
packages/api-stack/api/api-tenant/src/cache/cache.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
126
packages/api-stack/api/api-tenant/src/cache/cache.ts
vendored
Normal file
126
packages/api-stack/api/api-tenant/src/cache/cache.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
8
packages/api-stack/api/api-tenant/src/cache/index.ts
vendored
Normal file
8
packages/api-stack/api/api-tenant/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
CacheClock,
|
||||
TenantCacheOptions,
|
||||
} from "./cache.js";
|
||||
export {
|
||||
CachedTenantResolver,
|
||||
TenantCache,
|
||||
} from "./cache.js";
|
||||
86
packages/api-stack/api/api-tenant/src/context/context.md
Normal file
86
packages/api-stack/api/api-tenant/src/context/context.md
Normal 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.
|
||||
142
packages/api-stack/api/api-tenant/src/context/context.test.ts
Normal file
142
packages/api-stack/api/api-tenant/src/context/context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
packages/api-stack/api/api-tenant/src/context/context.ts
Normal file
79
packages/api-stack/api/api-tenant/src/context/context.ts
Normal 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,
|
||||
});
|
||||
5
packages/api-stack/api/api-tenant/src/context/index.ts
Normal file
5
packages/api-stack/api/api-tenant/src/context/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { TenantContext } from "./context.js";
|
||||
export {
|
||||
createTenantContext,
|
||||
NullTenantContext,
|
||||
} from "./context.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module defines the seven stable `TENANT.*` failure codes
|
||||
shipped by `@suaveplan/api-tenant` and the concrete `TenantError`
|
||||
class that carries them. Every public failure path in the package —
|
||||
resolver lookups, middleware status gating, rate-limit enforcement,
|
||||
input validation, and lookup timeouts — raises a `TenantError` with
|
||||
one of these codes so consumers can `switch` on the string value
|
||||
without depending on a class-instanceof check.
|
||||
|
||||
The registry uses `defineCodes("TENANT", [...])` from
|
||||
`@suaveplan/error`, which freezes the object and produces
|
||||
`"TENANT.NOT_FOUND"`-style stable identifiers. Each code is sized to a
|
||||
specific operational outcome so log aggregators and on-call dashboards
|
||||
can group incidents accurately rather than collapsing every failure
|
||||
under a single bucket.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly seven `TENANT.*` codes: `NOT_FOUND`, `SUSPENDED`, `PENDING`,
|
||||
`RESOLUTION_FAILED`, `RATE_LIMITED`, `INVALID_INPUT`, `LOOKUP_TIMEOUT`.
|
||||
- Frozen registry — `Object.isFrozen(TENANT_CODES)` is `true`.
|
||||
- `TenantError extends SuaveplanError<TenantErrorCode>` — the generic
|
||||
parameter narrows `.code` from `string` to the discriminated union.
|
||||
- A duplicate `.tenantCode` field is exposed alongside `.code` so
|
||||
callers writing structural assertions (`error.tenantCode === ...`)
|
||||
do not have to widen the parent's type parameter.
|
||||
- `SuaveplanErrorOptions` flows through to support `cause`, `context`,
|
||||
redactable payloads, and OTel span-event adapter integration.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TENANT_CODES, TenantError } from "@suaveplan/api-tenant";
|
||||
|
||||
throw new TenantError(TENANT_CODES.NOT_FOUND, "Tenant 'acme' not found");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Threading a cause and structured context for OTel ingestion:
|
||||
|
||||
```ts
|
||||
import { TENANT_CODES, TenantError } from "@suaveplan/api-tenant";
|
||||
|
||||
try {
|
||||
await db.query("SELECT * FROM tenants WHERE slug = $1", ["acme"]);
|
||||
} catch (cause) {
|
||||
throw new TenantError(
|
||||
TENANT_CODES.RESOLUTION_FAILED,
|
||||
"Tenant lookup failed",
|
||||
{
|
||||
cause: cause instanceof Error ? cause : new Error(String(cause)),
|
||||
context: { resolver: "subdomain", input: "acme" },
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_CODES` — frozen `Record<string, "TENANT.${string}">` mapping
|
||||
short keys (`NOT_FOUND` etc.) to dotted identifiers.
|
||||
- `TenantErrorCode` — discriminated string union over
|
||||
`(typeof TENANT_CODES)[keyof typeof TENANT_CODES]`.
|
||||
- `class TenantError extends SuaveplanError<TenantErrorCode>` —
|
||||
constructor `(code, message, options?)`. Public `.tenantCode` field
|
||||
mirrors `.code`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor widens visibility to `public` because
|
||||
`SuaveplanError`'s constructor is `protected` for the base class only.
|
||||
Every shipped subclass (`PluginError`, `TenantError`, etc.)
|
||||
follows the same shape so consumers can swap subclasses without
|
||||
adjusting call sites. The `tenantCode` mirror field avoids forcing
|
||||
callers to write `error.code as TenantErrorCode` when narrowing.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { TenantErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
TENANT_CODES,
|
||||
TenantError,
|
||||
} from "./error-codes.js";
|
||||
93
packages/api-stack/api/api-tenant/src/index.ts
Normal file
93
packages/api-stack/api/api-tenant/src/index.ts
Normal 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";
|
||||
11
packages/api-stack/api/api-tenant/src/lifecycle/index.ts
Normal file
11
packages/api-stack/api/api-tenant/src/lifecycle/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
TenantAwarePluginDescriptor,
|
||||
TenantLifecycleHookName,
|
||||
TenantLifecycleHooks,
|
||||
TenantScope,
|
||||
} from "./lifecycle.js";
|
||||
export {
|
||||
createTenantLifecycle,
|
||||
TENANT_LIFECYCLE_HOOK_NAMES,
|
||||
TenantLifecycle,
|
||||
} from "./lifecycle.js";
|
||||
112
packages/api-stack/api/api-tenant/src/lifecycle/lifecycle.md
Normal file
112
packages/api-stack/api/api-tenant/src/lifecycle/lifecycle.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
207
packages/api-stack/api/api-tenant/src/lifecycle/lifecycle.ts
Normal file
207
packages/api-stack/api/api-tenant/src/lifecycle/lifecycle.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
121
packages/api-stack/api/api-tenant/src/middleware/middleware.md
Normal file
121
packages/api-stack/api/api-tenant/src/middleware/middleware.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
140
packages/api-stack/api/api-tenant/src/middleware/middleware.ts
Normal file
140
packages/api-stack/api/api-tenant/src/middleware/middleware.ts
Normal 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");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# require-feature
|
||||
|
||||
## Purpose
|
||||
|
||||
`requireFeature(name)` returns a Hono middleware that gates route
|
||||
access on the boolean at `tenant.settings.features?.[name]`. The
|
||||
guard is fail-closed: a request that reached the route without a
|
||||
resolved tenant raises `TENANT_NOT_FOUND` instead of treating the
|
||||
missing tenant as a permissive default. The shape mirrors
|
||||
`requirePlugin` so applications can stack the two guards without
|
||||
worrying about ordering surprises.
|
||||
|
||||
The middleware is intentionally simple: a boolean check against the
|
||||
`features` map. A real feature-flag engine (percentage rollouts,
|
||||
multivariate flags, audience targeting) lives in
|
||||
`@suaveplan/feature-flags`; this guard just reads the slot the
|
||||
tenant record exposes.
|
||||
|
||||
## Features
|
||||
|
||||
- Fail-closed on no-tenant — raises `TENANT_NOT_FOUND`.
|
||||
- Raises `TENANT_INVALID_INPUT` when `features?.[name]` is anything
|
||||
other than `true` (missing, `false`, or another falsy value).
|
||||
- Pure — no global state, no side effects.
|
||||
- Typed via `ContextVariableMap` augmentation, no `as Tenant` cast.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireFeature } from "@suaveplan/api-tenant";
|
||||
|
||||
app.get("/beta/dashboard", requireFeature("beta-dashboard"), handler);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Using a fall-through default to keep the gate boolean while a feature
|
||||
ramp is in progress:
|
||||
|
||||
```ts
|
||||
import { requireFeature } from "@suaveplan/api-tenant";
|
||||
|
||||
// `features.{name} = true` rolls a tenant in.
|
||||
app.use("/beta/*", requireFeature("beta"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `requireFeature(featureName: string): MiddlewareHandler`.
|
||||
- Throws `TenantError(TENANT.NOT_FOUND)` when no tenant resolved.
|
||||
- Throws `TenantError(TENANT.INVALID_INPUT)` when the feature is
|
||||
not enabled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`features?.[name]` is read with optional-chaining so a tenant whose
|
||||
`settings.features` map is absent (instead of empty) still falls
|
||||
through to the failure branch rather than crashing. Like
|
||||
`requirePlugin`, the error code is the shared `TENANT_INVALID_INPUT`
|
||||
rather than a per-guard code — the seven `TENANT.*` codes are sized
|
||||
for operational outcomes, not API-specific failure modes.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# require-plugin
|
||||
|
||||
## Purpose
|
||||
|
||||
`requirePlugin(name)` returns a Hono middleware that gates route
|
||||
access on `tenant.enabledPlugins.includes(name)`. The guard is
|
||||
deliberately fail-closed: a request that reached the route without
|
||||
a resolved tenant raises `TENANT_NOT_FOUND` rather than silently
|
||||
passing. This mirrors the `NullTenantContext.isPluginEnabled = false`
|
||||
default elsewhere in the package — `@suaveplan/api-tenant` never lets
|
||||
a tenant-less request inherit a tenant-scoped permission.
|
||||
|
||||
The middleware is intended to be mounted after `createTenantMiddleware`
|
||||
in the chain. It does not perform tenant resolution itself; it only
|
||||
checks the resolved value.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `c.get("tenant")` — fully typed via the
|
||||
`ContextVariableMap` augmentation, no casts.
|
||||
- Raises `TENANT_NOT_FOUND` with `{ plugin: name }` context when no
|
||||
tenant is set.
|
||||
- Raises `TENANT_INVALID_INPUT` with `{ tenantId, plugin }` context
|
||||
when the plugin is not in `enabledPlugins`.
|
||||
- Pure middleware — no global state, no logger dependency, safe to
|
||||
mount per route or globally.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requirePlugin } from "@suaveplan/api-tenant";
|
||||
|
||||
app.get("/payments", requirePlugin("payment"), handler);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Stacking with a feature-flag gate so a "payment" plugin can be
|
||||
enabled without exposing a beta feature:
|
||||
|
||||
```ts
|
||||
import {
|
||||
requireFeature,
|
||||
requirePlugin,
|
||||
} from "@suaveplan/api-tenant";
|
||||
|
||||
app.use(
|
||||
"/payments/*",
|
||||
requirePlugin("payment"),
|
||||
requireFeature("payments-v2")
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `requirePlugin(pluginName: string): MiddlewareHandler`.
|
||||
- Throws `TenantError(TENANT.NOT_FOUND)` when no tenant resolved.
|
||||
- Throws `TenantError(TENANT.INVALID_INPUT)` when the plugin is not
|
||||
enabled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error code for "plugin not enabled" is `TENANT_INVALID_INPUT`
|
||||
rather than a dedicated `TENANT_PLUGIN_DISABLED` because the suaveplan
|
||||
code budget is fixed at 7 codes and the user-facing failure mode is
|
||||
"the request asked for something invalid for this tenant" — a 4xx,
|
||||
not a 5xx. The middleware does not log the failure; that is the
|
||||
application's choice via `onError` in the upstream `createTenantMiddleware`
|
||||
or a global Hono error handler.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
RateLimitRecord,
|
||||
RateLimitStore,
|
||||
TenantRateLimitOptions,
|
||||
} from "./rate-limit.js";
|
||||
export {
|
||||
MemoryRateLimitStore,
|
||||
tenantRateLimit,
|
||||
} from "./rate-limit.js";
|
||||
100
packages/api-stack/api/api-tenant/src/rate-limit/rate-limit.md
Normal file
100
packages/api-stack/api/api-tenant/src/rate-limit/rate-limit.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
159
packages/api-stack/api/api-tenant/src/rate-limit/rate-limit.ts
Normal file
159
packages/api-stack/api/api-tenant/src/rate-limit/rate-limit.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
20
packages/api-stack/api/api-tenant/src/resolver/index.ts
Normal file
20
packages/api-stack/api/api-tenant/src/resolver/index.ts
Normal 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";
|
||||
91
packages/api-stack/api/api-tenant/src/resolver/resolver.md
Normal file
91
packages/api-stack/api/api-tenant/src/resolver/resolver.md
Normal 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.
|
||||
332
packages/api-stack/api/api-tenant/src/resolver/resolver.test.ts
Normal file
332
packages/api-stack/api/api-tenant/src/resolver/resolver.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
246
packages/api-stack/api/api-tenant/src/resolver/resolver.ts
Normal file
246
packages/api-stack/api/api-tenant/src/resolver/resolver.ts
Normal 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);
|
||||
}
|
||||
8
packages/api-stack/api/api-tenant/src/storage/index.ts
Normal file
8
packages/api-stack/api/api-tenant/src/storage/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
getCurrentTenant,
|
||||
getCurrentTenantId,
|
||||
isTenantStorageEnabled,
|
||||
runWithTenant,
|
||||
tenantStorageMiddleware,
|
||||
withTenantStorage,
|
||||
} from "./storage.js";
|
||||
95
packages/api-stack/api/api-tenant/src/storage/storage.md
Normal file
95
packages/api-stack/api/api-tenant/src/storage/storage.md
Normal 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.
|
||||
137
packages/api-stack/api/api-tenant/src/storage/storage.test.ts
Normal file
137
packages/api-stack/api/api-tenant/src/storage/storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
packages/api-stack/api/api-tenant/src/storage/storage.ts
Normal file
74
packages/api-stack/api/api-tenant/src/storage/storage.ts
Normal 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);
|
||||
}
|
||||
12
packages/api-stack/api/api-tenant/src/types/index.ts
Normal file
12
packages/api-stack/api/api-tenant/src/types/index.ts
Normal 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";
|
||||
96
packages/api-stack/api/api-tenant/src/types/tenant.md
Normal file
96
packages/api-stack/api/api-tenant/src/types/tenant.md
Normal 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`.
|
||||
45
packages/api-stack/api/api-tenant/src/types/tenant.test.ts
Normal file
45
packages/api-stack/api/api-tenant/src/types/tenant.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
109
packages/api-stack/api/api-tenant/src/types/tenant.ts
Normal file
109
packages/api-stack/api/api-tenant/src/types/tenant.ts
Normal 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);
|
||||
}
|
||||
179
packages/api-stack/api/api-tenant/tests/dep-graph.test.ts
Normal file
179
packages/api-stack/api/api-tenant/tests/dep-graph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
66
packages/api-stack/api/api-tenant/tests/property.test.ts
Normal file
66
packages/api-stack/api/api-tenant/tests/property.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
39
packages/api-stack/api/api-tenant/tests/setup/polyfills.ts
Normal file
39
packages/api-stack/api/api-tenant/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/api-stack/api/api-tenant/tsconfig.json
Normal file
12
packages/api-stack/api/api-tenant/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-tenant/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-tenant/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/api-stack/api/api-tenant/vite.config.ts
Normal file
37
packages/api-stack/api/api-tenant/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/api-stack/api/api-tenant/vitest.config.ts
Normal file
32
packages/api-stack/api/api-tenant/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user