@daloyjs/core 0.39.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Multitenancy primitive.
3
+ *
4
+ * `tenancy(opts)` returns a `Hooks` bundle that resolves the calling tenant
5
+ * once per request, validates and normalizes it, and exposes it on
6
+ * `ctx.state.tenant` for handlers and downstream middleware. It is the
7
+ * single source of truth for "who is this request for" so the per-tenant
8
+ * isolation knobs on the rest of the framework (`rateLimit` `keyGenerator`,
9
+ * `concurrencyLimit` / `idempotency` / `responseCache` `scope`) can all key
10
+ * off the same resolved value via {@link tenantScope}.
11
+ *
12
+ * Secure-by-default posture:
13
+ *
14
+ * - **Refuse-unresolved.** With the default `require: true`, a request whose
15
+ * tenant cannot be resolved is rejected (`400`) rather than silently served
16
+ * as some ambient "default" tenant — the failure mode that leaks one
17
+ * tenant's data to another.
18
+ * - **Format-validated ids.** Resolved ids are normalized to a conservative
19
+ * `[a-z0-9_-]` charset before they are stored or used as a key. A tenant id
20
+ * pulled from a spoofable header can otherwise smuggle newlines, `:`, `/`,
21
+ * or `*` into rate-limit keys, cache keys, and log lines (key/log injection,
22
+ * cache poisoning). Anything that fails the pattern is treated as an unknown
23
+ * tenant.
24
+ * - **No enumeration.** An id that resolves but is not in your `allow`
25
+ * list/validator is rejected as `404 Not Found` by default, so probing for
26
+ * valid tenant names cannot be distinguished from hitting a missing route.
27
+ * - **Host-spoof safe.** {@link tenantFromSubdomain} treats a `Host` that is
28
+ * not under the declared `baseDomain` as unresolved instead of trusting it.
29
+ *
30
+ * Ordering: `tenancy()` resolves in `beforeHandle`, and so do the isolation
31
+ * primitives that consume the result. Register `tenancy()` **before** them
32
+ * (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
33
+ * is populated by the time their `keyGenerator` / `scope` callbacks run.
34
+ *
35
+ * ```ts
36
+ * import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
37
+ *
38
+ * const app = new App({
39
+ * hooks: tenancy({
40
+ * resolve: tenantFromSubdomain({ baseDomain: "example.com" }),
41
+ * allow: ["acme", "globex"],
42
+ * }),
43
+ * });
44
+ *
45
+ * // Per-tenant rate-limit buckets keyed off the resolved tenant.
46
+ * app.use(rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() }));
47
+ * ```
48
+ *
49
+ * @since 0.42.0
50
+ */
51
+ import type { BaseContext, Hooks } from "./types.js";
52
+ /**
53
+ * Resolves a raw (un-normalized) tenant id from a request, or a nullish value
54
+ * when this strategy cannot determine one. Resolvers are tried in order and
55
+ * the first non-empty result wins.
56
+ *
57
+ * @since 0.42.0
58
+ */
59
+ export type TenantResolver = (ctx: BaseContext<any, any>) => string | null | undefined | Promise<string | null | undefined>;
60
+ /**
61
+ * Default normalizer: trim, lowercase, and accept only ids matching
62
+ * {@link DEFAULT_TENANT_PATTERN}. Returns `undefined` for anything else, which
63
+ * the middleware treats as an unknown tenant.
64
+ *
65
+ * @param raw - The raw value produced by a {@link TenantResolver}.
66
+ * @returns The normalized id, or `undefined` when it is not a valid tenant id.
67
+ * @since 0.42.0
68
+ */
69
+ export declare function defaultTenantNormalize(raw: string): string | undefined;
70
+ /** Options for {@link tenantFromSubdomain}. @since 0.42.0 */
71
+ export interface SubdomainTenantOptions {
72
+ /**
73
+ * Explicit registrable base domain (e.g. `"example.com"`). Strongly
74
+ * recommended in production: a `Host` that is not under this base is treated
75
+ * as unresolved rather than trusted, which defends against `Host`-header
76
+ * spoofing. When omitted, the PSL snapshot is used to split the host.
77
+ */
78
+ baseDomain?: string;
79
+ /**
80
+ * Which subdomain label to use, counting from the left (`0` = leftmost).
81
+ * For `acme.example.com` the default `0` yields `"acme"`. Default `0`.
82
+ */
83
+ index?: number;
84
+ /** Forwarded to {@link subdomains}: extra public-suffix entries. */
85
+ extraSuffixes?: readonly string[];
86
+ /**
87
+ * Forwarded to {@link subdomains}: enables the production staleness check on
88
+ * the bundled Public Suffix List snapshot. Default `false`.
89
+ */
90
+ production?: boolean;
91
+ }
92
+ /**
93
+ * Resolve the tenant from a request subdomain using the PSL-aware
94
+ * {@link subdomains} helper. `acme.example.com` → `"acme"`.
95
+ *
96
+ * A `Host` that is not under the declared `baseDomain` resolves to `undefined`
97
+ * (unresolved) instead of throwing, so a spoofed `Host` becomes a clean
98
+ * rejection rather than a `500`.
99
+ *
100
+ * @param opts - Subdomain resolution options.
101
+ * @returns A {@link TenantResolver}.
102
+ * @since 0.42.0
103
+ */
104
+ export declare function tenantFromSubdomain(opts?: SubdomainTenantOptions): TenantResolver;
105
+ /**
106
+ * Resolve the tenant from a request header (e.g. `"x-tenant-id"`).
107
+ *
108
+ * **Security:** request headers are client-controlled. Only use this behind a
109
+ * trusted proxy/load balancer that *overwrites* the header on every inbound
110
+ * request — otherwise a caller can set it to any tenant. Pair with
111
+ * {@link TenancyOptions.allow} to bound the accepted values.
112
+ *
113
+ * @param headerName - Header to read (case-insensitive).
114
+ * @returns A {@link TenantResolver}.
115
+ * @since 0.42.0
116
+ */
117
+ export declare function tenantFromHeader(headerName: string): TenantResolver;
118
+ /** Options for {@link tenantFromPathPrefix}. @since 0.42.0 */
119
+ export interface PathPrefixTenantOptions {
120
+ /**
121
+ * Which non-empty path segment to use, counting from the left (`0` = first).
122
+ * For `/acme/orders` the default `0` yields `"acme"`. Default `0`.
123
+ */
124
+ segment?: number;
125
+ }
126
+ /**
127
+ * Resolve the tenant from a path segment. `/acme/orders` → `"acme"`.
128
+ *
129
+ * Note: this only reads the id; it does not rewrite the path, so your routes
130
+ * still include the tenant segment (e.g. register `/:tenant/orders`, or read
131
+ * `ctx.state.tenant` and ignore the segment in the handler).
132
+ *
133
+ * @param opts - Path-prefix resolution options.
134
+ * @returns A {@link TenantResolver}.
135
+ * @since 0.42.0
136
+ */
137
+ export declare function tenantFromPathPrefix(opts?: PathPrefixTenantOptions): TenantResolver;
138
+ /** Options for {@link tenantFromClaim}. @since 0.42.0 */
139
+ export interface ClaimTenantOptions {
140
+ /**
141
+ * `ctx.state` key holding the authenticated principal. Default `"auth"`,
142
+ * matching the first-party auth helpers which write an
143
+ * `{ scheme, credentials }` context to `ctx.state.auth`. The claim is read
144
+ * from `credentials[claim]` when present, otherwise from `node[claim]`.
145
+ */
146
+ stateKey?: string;
147
+ }
148
+ /**
149
+ * Resolve the tenant from a verified auth claim already on `ctx.state`
150
+ * (e.g. an `org` / `tenant` JWT claim). Reads `ctx.state.auth.credentials`
151
+ * (the {@link AuthContext} shape) or, if there is no `credentials` field, the
152
+ * state node itself.
153
+ *
154
+ * **Ordering:** the auth middleware that populates the claim must run *before*
155
+ * `tenancy()`. Register your verifier first, then `tenancy()`.
156
+ *
157
+ * @param claim - Claim/property name carrying the tenant id.
158
+ * @param opts - Where to read the principal from.
159
+ * @returns A {@link TenantResolver}.
160
+ * @since 0.42.0
161
+ */
162
+ export declare function tenantFromClaim(claim: string, opts?: ClaimTenantOptions): TenantResolver;
163
+ /** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
164
+ export type UnresolvedStatus = 400 | 401 | 403 | 404;
165
+ /** Status codes acceptable for an unknown/disallowed-tenant rejection. @since 0.42.0 */
166
+ export type InvalidStatus = 400 | 403 | 404;
167
+ /** Options for {@link tenancy}. @since 0.42.0 */
168
+ export interface TenancyOptions {
169
+ /**
170
+ * One resolver, or several tried in order until one returns a non-empty
171
+ * value. Combine e.g. `[tenantFromClaim("org"), tenantFromSubdomain(...)]`
172
+ * to prefer a verified claim and fall back to the subdomain.
173
+ */
174
+ resolve: TenantResolver | TenantResolver[];
175
+ /**
176
+ * Reject requests whose tenant cannot be resolved. Default `true`. Set to
177
+ * `false` only when some routes are legitimately tenant-less; the request
178
+ * then proceeds with `ctx.state.tenant` left `undefined`.
179
+ */
180
+ require?: boolean;
181
+ /**
182
+ * Bound the accepted tenant space: an array allowlist, or an (optionally
183
+ * async) validator `(id, ctx) => boolean`. A resolved id that fails is
184
+ * rejected with {@link invalidStatus}. Array entries are validated against
185
+ * the normalizer at construction time (a malformed entry throws).
186
+ */
187
+ allow?: readonly string[] | ((tenantId: string, ctx: BaseContext<any, any>) => boolean | Promise<boolean>);
188
+ /**
189
+ * Normalize/validate a raw resolved id. Return `undefined` to reject it.
190
+ * Default {@link defaultTenantNormalize} (trim + lowercase + strict charset).
191
+ */
192
+ normalize?: (raw: string) => string | undefined;
193
+ /** `ctx.state` key the resolved tenant id is written to. Default `"tenant"`. */
194
+ stateKey?: string;
195
+ /** Status for an unresolved tenant when `require` is true. Default `400`. */
196
+ unresolvedStatus?: UnresolvedStatus;
197
+ /** Status for a resolved-but-disallowed tenant. Default `404` (no enumeration). */
198
+ invalidStatus?: InvalidStatus;
199
+ }
200
+ /**
201
+ * Multitenancy middleware. Resolves, validates, and normalizes the tenant for
202
+ * each request and stores it on `ctx.state[stateKey]` (default `tenant`).
203
+ * See the module overview for the secure-by-default posture and ordering
204
+ * rules.
205
+ *
206
+ * @param opts - Resolution, validation, and rejection configuration.
207
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
208
+ * @throws If no resolver is supplied, or an `allow` array entry is not a valid
209
+ * tenant id under the configured normalizer.
210
+ * @since 0.42.0
211
+ */
212
+ export declare function tenancy(opts: TenancyOptions): Hooks;
213
+ /** Options for {@link tenantScope}. @since 0.42.0 */
214
+ export interface TenantScopeOptions {
215
+ /** `ctx.state` key the tenant id was written to. Default `"tenant"`. */
216
+ stateKey?: string;
217
+ /**
218
+ * Key returned when no tenant is present (only reachable with
219
+ * `tenancy({ require: false })`). Default `"tenant:unknown"`.
220
+ */
221
+ fallback?: string;
222
+ }
223
+ /**
224
+ * Build a `(ctx) => string` key function that reads the resolved tenant and
225
+ * returns a `tenant:<id>` partition key. Drop it straight into the isolation
226
+ * knobs so each tenant gets its own bucket/namespace and cannot see, exhaust,
227
+ * or poison another tenant's:
228
+ *
229
+ * ```ts
230
+ * rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() });
231
+ * concurrencyLimit({ maxConcurrent: 20, scope: tenantScope() });
232
+ * idempotency({ scope: tenantScope() }); // CWE-524 cross-tenant cache defense
233
+ * responseCache({ ttlMs: 30_000, scope: tenantScope() });
234
+ * ```
235
+ *
236
+ * The `tenant:` prefix keeps these keys from colliding with other key spaces
237
+ * (e.g. `concurrencyLimit`'s literal `"global"` bucket).
238
+ *
239
+ * @param opts - Where to read the tenant from and the tenant-less fallback.
240
+ * @returns A key function suitable for `keyGenerator` / `scope`.
241
+ * @since 0.42.0
242
+ */
243
+ export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any>) => string;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Multitenancy primitive.
3
+ *
4
+ * `tenancy(opts)` returns a `Hooks` bundle that resolves the calling tenant
5
+ * once per request, validates and normalizes it, and exposes it on
6
+ * `ctx.state.tenant` for handlers and downstream middleware. It is the
7
+ * single source of truth for "who is this request for" so the per-tenant
8
+ * isolation knobs on the rest of the framework (`rateLimit` `keyGenerator`,
9
+ * `concurrencyLimit` / `idempotency` / `responseCache` `scope`) can all key
10
+ * off the same resolved value via {@link tenantScope}.
11
+ *
12
+ * Secure-by-default posture:
13
+ *
14
+ * - **Refuse-unresolved.** With the default `require: true`, a request whose
15
+ * tenant cannot be resolved is rejected (`400`) rather than silently served
16
+ * as some ambient "default" tenant — the failure mode that leaks one
17
+ * tenant's data to another.
18
+ * - **Format-validated ids.** Resolved ids are normalized to a conservative
19
+ * `[a-z0-9_-]` charset before they are stored or used as a key. A tenant id
20
+ * pulled from a spoofable header can otherwise smuggle newlines, `:`, `/`,
21
+ * or `*` into rate-limit keys, cache keys, and log lines (key/log injection,
22
+ * cache poisoning). Anything that fails the pattern is treated as an unknown
23
+ * tenant.
24
+ * - **No enumeration.** An id that resolves but is not in your `allow`
25
+ * list/validator is rejected as `404 Not Found` by default, so probing for
26
+ * valid tenant names cannot be distinguished from hitting a missing route.
27
+ * - **Host-spoof safe.** {@link tenantFromSubdomain} treats a `Host` that is
28
+ * not under the declared `baseDomain` as unresolved instead of trusting it.
29
+ *
30
+ * Ordering: `tenancy()` resolves in `beforeHandle`, and so do the isolation
31
+ * primitives that consume the result. Register `tenancy()` **before** them
32
+ * (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
33
+ * is populated by the time their `keyGenerator` / `scope` callbacks run.
34
+ *
35
+ * ```ts
36
+ * import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
37
+ *
38
+ * const app = new App({
39
+ * hooks: tenancy({
40
+ * resolve: tenantFromSubdomain({ baseDomain: "example.com" }),
41
+ * allow: ["acme", "globex"],
42
+ * }),
43
+ * });
44
+ *
45
+ * // Per-tenant rate-limit buckets keyed off the resolved tenant.
46
+ * app.use(rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() }));
47
+ * ```
48
+ *
49
+ * @since 0.42.0
50
+ */
51
+ import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError, } from "./errors.js";
52
+ import { subdomains } from "./subdomains.js";
53
+ /**
54
+ * Conservative default tenant-id grammar: a DNS-label-like token, lowercase
55
+ * `a-z0-9` with internal `-`/`_`, 1–63 chars, no leading/trailing separator.
56
+ * Deliberately strict so a resolved id is always safe to embed in a key or a
57
+ * log line.
58
+ */
59
+ const DEFAULT_TENANT_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$/;
60
+ /**
61
+ * Default normalizer: trim, lowercase, and accept only ids matching
62
+ * {@link DEFAULT_TENANT_PATTERN}. Returns `undefined` for anything else, which
63
+ * the middleware treats as an unknown tenant.
64
+ *
65
+ * @param raw - The raw value produced by a {@link TenantResolver}.
66
+ * @returns The normalized id, or `undefined` when it is not a valid tenant id.
67
+ * @since 0.42.0
68
+ */
69
+ export function defaultTenantNormalize(raw) {
70
+ const id = raw.trim().toLowerCase();
71
+ return DEFAULT_TENANT_PATTERN.test(id) ? id : undefined;
72
+ }
73
+ /**
74
+ * Resolve the tenant from a request subdomain using the PSL-aware
75
+ * {@link subdomains} helper. `acme.example.com` → `"acme"`.
76
+ *
77
+ * A `Host` that is not under the declared `baseDomain` resolves to `undefined`
78
+ * (unresolved) instead of throwing, so a spoofed `Host` becomes a clean
79
+ * rejection rather than a `500`.
80
+ *
81
+ * @param opts - Subdomain resolution options.
82
+ * @returns A {@link TenantResolver}.
83
+ * @since 0.42.0
84
+ */
85
+ export function tenantFromSubdomain(opts = {}) {
86
+ const index = opts.index ?? 0;
87
+ const base = opts.baseDomain?.toLowerCase();
88
+ return (ctx) => {
89
+ let hostname;
90
+ try {
91
+ hostname = new URL(ctx.request.url).hostname.toLowerCase();
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ if (!hostname)
97
+ return undefined;
98
+ if (base && hostname !== base && !hostname.endsWith(`.${base}`)) {
99
+ // Host is not under the declared base — possible spoofing. Resolve to
100
+ // unresolved instead of letting subdomains() throw.
101
+ return undefined;
102
+ }
103
+ const { labels } = subdomains(hostname, {
104
+ baseDomain: opts.baseDomain,
105
+ extraSuffixes: opts.extraSuffixes,
106
+ production: opts.production,
107
+ });
108
+ return labels[index];
109
+ };
110
+ }
111
+ /**
112
+ * Resolve the tenant from a request header (e.g. `"x-tenant-id"`).
113
+ *
114
+ * **Security:** request headers are client-controlled. Only use this behind a
115
+ * trusted proxy/load balancer that *overwrites* the header on every inbound
116
+ * request — otherwise a caller can set it to any tenant. Pair with
117
+ * {@link TenancyOptions.allow} to bound the accepted values.
118
+ *
119
+ * @param headerName - Header to read (case-insensitive).
120
+ * @returns A {@link TenantResolver}.
121
+ * @since 0.42.0
122
+ */
123
+ export function tenantFromHeader(headerName) {
124
+ const name = headerName.toLowerCase();
125
+ return (ctx) => ctx.request.headers.get(name) ?? undefined;
126
+ }
127
+ /**
128
+ * Resolve the tenant from a path segment. `/acme/orders` → `"acme"`.
129
+ *
130
+ * Note: this only reads the id; it does not rewrite the path, so your routes
131
+ * still include the tenant segment (e.g. register `/:tenant/orders`, or read
132
+ * `ctx.state.tenant` and ignore the segment in the handler).
133
+ *
134
+ * @param opts - Path-prefix resolution options.
135
+ * @returns A {@link TenantResolver}.
136
+ * @since 0.42.0
137
+ */
138
+ export function tenantFromPathPrefix(opts = {}) {
139
+ const segment = opts.segment ?? 0;
140
+ return (ctx) => {
141
+ let pathname;
142
+ try {
143
+ pathname = new URL(ctx.request.url).pathname;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ const parts = pathname.split("/").filter(Boolean);
149
+ return parts[segment];
150
+ };
151
+ }
152
+ /**
153
+ * Resolve the tenant from a verified auth claim already on `ctx.state`
154
+ * (e.g. an `org` / `tenant` JWT claim). Reads `ctx.state.auth.credentials`
155
+ * (the {@link AuthContext} shape) or, if there is no `credentials` field, the
156
+ * state node itself.
157
+ *
158
+ * **Ordering:** the auth middleware that populates the claim must run *before*
159
+ * `tenancy()`. Register your verifier first, then `tenancy()`.
160
+ *
161
+ * @param claim - Claim/property name carrying the tenant id.
162
+ * @param opts - Where to read the principal from.
163
+ * @returns A {@link TenantResolver}.
164
+ * @since 0.42.0
165
+ */
166
+ export function tenantFromClaim(claim, opts = {}) {
167
+ const stateKey = opts.stateKey ?? "auth";
168
+ return (ctx) => {
169
+ const node = ctx.state[stateKey];
170
+ if (!node || typeof node !== "object")
171
+ return undefined;
172
+ const withCreds = node;
173
+ const source = withCreds.credentials && typeof withCreds.credentials === "object"
174
+ ? withCreds.credentials
175
+ : node;
176
+ const value = source[claim];
177
+ if (typeof value === "string")
178
+ return value;
179
+ if (typeof value === "number" && Number.isFinite(value))
180
+ return String(value);
181
+ return undefined;
182
+ };
183
+ }
184
+ /** Build the right `HttpError` for a configured status. */
185
+ function rejection(status, detail) {
186
+ switch (status) {
187
+ case 401:
188
+ return new UnauthorizedError(detail);
189
+ case 403:
190
+ return new ForbiddenError(detail);
191
+ case 404:
192
+ return new NotFoundError(detail);
193
+ default:
194
+ return new BadRequestError(detail);
195
+ }
196
+ }
197
+ /**
198
+ * Multitenancy middleware. Resolves, validates, and normalizes the tenant for
199
+ * each request and stores it on `ctx.state[stateKey]` (default `tenant`).
200
+ * See the module overview for the secure-by-default posture and ordering
201
+ * rules.
202
+ *
203
+ * @param opts - Resolution, validation, and rejection configuration.
204
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
205
+ * @throws If no resolver is supplied, or an `allow` array entry is not a valid
206
+ * tenant id under the configured normalizer.
207
+ * @since 0.42.0
208
+ */
209
+ export function tenancy(opts) {
210
+ const resolvers = Array.isArray(opts.resolve) ? opts.resolve : [opts.resolve];
211
+ if (resolvers.length === 0) {
212
+ throw new Error("tenancy(): at least one resolver is required.");
213
+ }
214
+ const stateKey = opts.stateKey ?? "tenant";
215
+ const required = opts.require ?? true;
216
+ const normalize = opts.normalize ?? defaultTenantNormalize;
217
+ const unresolvedStatus = opts.unresolvedStatus ?? 400;
218
+ const invalidStatus = opts.invalidStatus ?? 404;
219
+ // Pre-normalize an array allowlist so comparisons are apples-to-apples, and
220
+ // fail fast on a misconfigured entry rather than silently never matching it.
221
+ let allowSet;
222
+ let allowFn;
223
+ if (Array.isArray(opts.allow)) {
224
+ allowSet = new Set();
225
+ for (const entry of opts.allow) {
226
+ const n = normalize(entry);
227
+ if (n === undefined) {
228
+ throw new Error(`tenancy(): allowlist entry ${JSON.stringify(entry)} is not a valid tenant id.`);
229
+ }
230
+ allowSet.add(n);
231
+ }
232
+ }
233
+ else if (typeof opts.allow === "function") {
234
+ allowFn = opts.allow;
235
+ }
236
+ return {
237
+ async beforeHandle(ctx) {
238
+ let raw;
239
+ for (const resolve of resolvers) {
240
+ raw = await resolve(ctx);
241
+ if (raw != null && raw !== "")
242
+ break;
243
+ }
244
+ if (raw == null || raw === "") {
245
+ if (required) {
246
+ throw rejection(unresolvedStatus, "Could not determine the tenant for this request.");
247
+ }
248
+ return; // optional tenancy: proceed tenant-less
249
+ }
250
+ const id = normalize(raw);
251
+ if (id === undefined) {
252
+ // Malformed id — reject as unknown so a poisoned value never reaches a
253
+ // key or log line, and without revealing it was a format problem.
254
+ throw rejection(invalidStatus, "Unknown tenant.");
255
+ }
256
+ if (allowSet && !allowSet.has(id)) {
257
+ throw rejection(invalidStatus, "Unknown tenant.");
258
+ }
259
+ if (allowFn && !(await allowFn(id, ctx))) {
260
+ throw rejection(invalidStatus, "Unknown tenant.");
261
+ }
262
+ ctx.state[stateKey] = id;
263
+ },
264
+ };
265
+ }
266
+ /**
267
+ * Build a `(ctx) => string` key function that reads the resolved tenant and
268
+ * returns a `tenant:<id>` partition key. Drop it straight into the isolation
269
+ * knobs so each tenant gets its own bucket/namespace and cannot see, exhaust,
270
+ * or poison another tenant's:
271
+ *
272
+ * ```ts
273
+ * rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() });
274
+ * concurrencyLimit({ maxConcurrent: 20, scope: tenantScope() });
275
+ * idempotency({ scope: tenantScope() }); // CWE-524 cross-tenant cache defense
276
+ * responseCache({ ttlMs: 30_000, scope: tenantScope() });
277
+ * ```
278
+ *
279
+ * The `tenant:` prefix keeps these keys from colliding with other key spaces
280
+ * (e.g. `concurrencyLimit`'s literal `"global"` bucket).
281
+ *
282
+ * @param opts - Where to read the tenant from and the tenant-less fallback.
283
+ * @returns A key function suitable for `keyGenerator` / `scope`.
284
+ * @since 0.42.0
285
+ */
286
+ export function tenantScope(opts = {}) {
287
+ const stateKey = opts.stateKey ?? "tenant";
288
+ const fallback = opts.fallback ?? "tenant:unknown";
289
+ return (ctx) => {
290
+ const value = ctx.state[stateKey];
291
+ return typeof value === "string" && value.length > 0 ? `tenant:${value}` : fallback;
292
+ };
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "0.39.1",
3
+ "version": "0.42.0",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -113,6 +113,10 @@
113
113
  "types": "./dist/tracing.d.ts",
114
114
  "import": "./dist/tracing.js"
115
115
  },
116
+ "./tenancy": {
117
+ "types": "./dist/tenancy.d.ts",
118
+ "import": "./dist/tenancy.js"
119
+ },
116
120
  "./multipart": {
117
121
  "types": "./dist/multipart.d.ts",
118
122
  "import": "./dist/multipart.js"
@@ -241,9 +245,11 @@
241
245
  "example": "node --import tsx examples/basic.ts",
242
246
  "bench": "node --import tsx bench/router.bench.ts",
243
247
  "test": "node --import tsx --test tests/**/*.test.ts",
248
+ "test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts",
244
249
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
245
250
  "coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include='dist-coverage/src/**' --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",
246
- "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json",
251
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit",
252
+ "typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
247
253
  "format": "prettier --write .",
248
254
  "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
249
255
  "gen:client": "openapi-ts",