@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.
- package/README.md +6 -2
- package/dist/adapters/node.js +11 -0
- package/dist/app.d.ts +131 -1
- package/dist/app.js +259 -18
- package/dist/cli.js +22 -0
- package/dist/docs.d.ts +49 -0
- package/dist/docs.js +39 -0
- package/dist/idempotency.d.ts +26 -4
- package/dist/idempotency.js +25 -5
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -0
- package/dist/response-cache.d.ts +26 -2
- package/dist/response-cache.js +14 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/tenancy.d.ts +243 -0
- package/dist/tenancy.js +293 -0
- package/package.json +8 -2
|
@@ -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;
|
package/dist/tenancy.js
ADDED
|
@@ -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.
|
|
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",
|