@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6
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 +34 -22
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +42 -7
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +117 -46
- package/dist/app.d.ts +30 -4
- package/dist/app.js +187 -45
- package/dist/auto-ban.js +1 -3
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +9 -6
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +14 -8
- package/dist/etag.js +12 -2
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +16 -9
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +135 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +27 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +124 -32
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +6 -5
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:1fe1844d-d5f6-5dd3-b35a-8f79f17a35f5",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
7
|
+
"timestamp": "2026-07-26T17:41:07.733Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.6"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
24
|
+
"version": "1.0.0-rc.6",
|
|
25
25
|
"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.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.0.0-rc.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.6",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
51
|
+
"version": "1.0.0-rc.6",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
5
|
+
"name": "@daloyjs/core-1.0.0-rc.6",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.6-1fe1844d-d5f6-5dd3-b35a-8f79f17a35f5",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-07-
|
|
8
|
+
"created": "2026-07-26T17:41:07.733Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.6",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.6"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/security-schemes.js
CHANGED
|
@@ -28,8 +28,7 @@ export function securitySchemeRequiresPayloadAuth(scheme) {
|
|
|
28
28
|
if (!scheme || typeof scheme !== "object")
|
|
29
29
|
return false;
|
|
30
30
|
const record = scheme;
|
|
31
|
-
return
|
|
32
|
-
record.requirePayloadAuth === true);
|
|
31
|
+
return record[REQUIRE_PAYLOAD_AUTH_EXTENSION] === true || record.requirePayloadAuth === true;
|
|
33
32
|
}
|
|
34
33
|
/**
|
|
35
34
|
* Normalize a builder output into a spec-compliant OpenAPI security scheme by
|
package/dist/security.d.ts
CHANGED
|
@@ -141,6 +141,11 @@ export declare const SMUGGLING_SINGLETON_HEADERS: readonly string[];
|
|
|
141
141
|
* Throws {@link BadRequestError} so the framework returns a structured
|
|
142
142
|
* `400 problem+json` instead of forwarding a smuggling-class request.
|
|
143
143
|
*
|
|
144
|
+
* The framework's dispatch path runs this check, the reserved-prefix check,
|
|
145
|
+
* and the header-count cap in a single shared walk internally (see
|
|
146
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
147
|
+
* needed for custom pipelines.
|
|
148
|
+
*
|
|
144
149
|
* @param headers - Normalized request headers to inspect.
|
|
145
150
|
* @since 0.15.0
|
|
146
151
|
*/
|
|
@@ -179,6 +184,10 @@ export declare const RESERVED_INBOUND_HEADER_PREFIXES: readonly string[];
|
|
|
179
184
|
* `400 problem+json` instead of routing a request that may be probing
|
|
180
185
|
* for an internal-dispatch bypass.
|
|
181
186
|
*
|
|
187
|
+
* The framework's dispatch path runs this check and the header-count cap
|
|
188
|
+
* in a single shared walk internally (see {@link assertInboundHeaderGuards});
|
|
189
|
+
* calling this helper directly is only needed for custom pipelines.
|
|
190
|
+
*
|
|
182
191
|
* @param headers - Normalized request headers to inspect (names arrive lowercased).
|
|
183
192
|
* @since 0.36.0
|
|
184
193
|
*/
|
|
@@ -212,11 +221,43 @@ export declare const DEFAULT_MAX_HEADER_COUNT = 100;
|
|
|
212
221
|
* framework returns a structured `problem+json` response instead of routing
|
|
213
222
|
* a flood.
|
|
214
223
|
*
|
|
224
|
+
* The framework's dispatch path runs this check and the reserved-prefix
|
|
225
|
+
* check in a single shared walk internally (see
|
|
226
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
227
|
+
* needed for custom pipelines.
|
|
228
|
+
*
|
|
215
229
|
* @param headers - The incoming request headers.
|
|
216
230
|
* @param limit - Maximum distinct header fields to allow. `0` disables.
|
|
217
231
|
* @since 0.38.0
|
|
218
232
|
*/
|
|
219
233
|
export declare function assertHeaderCountWithinLimit(headers: Headers, limit: number): void;
|
|
234
|
+
/**
|
|
235
|
+
* Combined inbound header guards for the dispatch hot path (internal).
|
|
236
|
+
*
|
|
237
|
+
* Runs the singleton-duplicate check ({@link assertNoDuplicateSingletonHeaders}),
|
|
238
|
+
* the reserved-internal-prefix check ({@link assertNoReservedInternalHeaders}),
|
|
239
|
+
* and the header-count cap ({@link assertHeaderCountWithinLimit}) in a
|
|
240
|
+
* **single** walk of the header map, with the same observable semantics as
|
|
241
|
+
* calling the helpers in sequence: any `400`-class violation (duplicate
|
|
242
|
+
* singleton header or reserved internal header) anywhere in the map is
|
|
243
|
+
* rejected with `400` even when the map also exceeds the count cap. To
|
|
244
|
+
* preserve that precedence, the `431` count-cap rejection is deferred until
|
|
245
|
+
* the scan has covered every header — the same full-walk cost the
|
|
246
|
+
* sequential helpers already paid.
|
|
247
|
+
*
|
|
248
|
+
* A non-positive / non-finite `limit` disables the count cap (same as
|
|
249
|
+
* {@link assertHeaderCountWithinLimit}) while still rejecting duplicate
|
|
250
|
+
* singleton headers and reserved internal prefixes.
|
|
251
|
+
*
|
|
252
|
+
* @param headers - Normalized request headers (names arrive lowercased).
|
|
253
|
+
* @param limit - Maximum distinct header fields to allow. `0` disables the count cap.
|
|
254
|
+
* @throws {BadRequestError} When a duplicate singleton header or a reserved
|
|
255
|
+
* internal header is present (takes precedence over the count cap).
|
|
256
|
+
* @throws {RequestHeaderFieldsTooLargeError} When no `400`-class violation is
|
|
257
|
+
* present and the distinct-header count exceeds `limit`.
|
|
258
|
+
* @since 1.0.0
|
|
259
|
+
*/
|
|
260
|
+
export declare function assertInboundHeaderGuards(headers: Headers, limit: number): void;
|
|
220
261
|
/**
|
|
221
262
|
* Minimum acceptable secret length in bytes for HMAC / signing material in
|
|
222
263
|
* production (boot guard). Matches the OWASP "Secret Management"
|
package/dist/security.js
CHANGED
|
@@ -260,6 +260,11 @@ export const SMUGGLING_SINGLETON_HEADERS = Object.freeze([
|
|
|
260
260
|
* Throws {@link BadRequestError} so the framework returns a structured
|
|
261
261
|
* `400 problem+json` instead of forwarding a smuggling-class request.
|
|
262
262
|
*
|
|
263
|
+
* The framework's dispatch path runs this check, the reserved-prefix check,
|
|
264
|
+
* and the header-count cap in a single shared walk internally (see
|
|
265
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
266
|
+
* needed for custom pipelines.
|
|
267
|
+
*
|
|
263
268
|
* @param headers - Normalized request headers to inspect.
|
|
264
269
|
* @since 0.15.0
|
|
265
270
|
*/
|
|
@@ -271,6 +276,13 @@ export function assertNoDuplicateSingletonHeaders(headers) {
|
|
|
271
276
|
}
|
|
272
277
|
}
|
|
273
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Set view of {@link SMUGGLING_SINGLETON_HEADERS}, built once at module load
|
|
281
|
+
* so the per-request walk in {@link assertInboundHeaderGuards} pays a Set
|
|
282
|
+
* lookup instead of three undici `Headers.get()` calls (each of which runs
|
|
283
|
+
* WebIDL ByteString conversion + header-name token validation).
|
|
284
|
+
*/
|
|
285
|
+
const SMUGGLING_SINGLETON_SET = new Set(SMUGGLING_SINGLETON_HEADERS);
|
|
274
286
|
/**
|
|
275
287
|
* Reserved inbound header namespaces that an external client must never
|
|
276
288
|
* be allowed to set. These prefixes are owned by the framework so that no
|
|
@@ -299,6 +311,29 @@ export const RESERVED_INBOUND_HEADER_PREFIXES = Object.freeze([
|
|
|
299
311
|
"x-daloy-internal-",
|
|
300
312
|
"x-daloyjs-internal-",
|
|
301
313
|
]);
|
|
314
|
+
/**
|
|
315
|
+
* Longest common prefix shared by every entry in
|
|
316
|
+
* {@link RESERVED_INBOUND_HEADER_PREFIXES}, computed once at module load.
|
|
317
|
+
*
|
|
318
|
+
* Used by {@link assertInboundHeaderGuards} as a cheap first-pass filter so
|
|
319
|
+
* the inner prefix loop is skipped for the overwhelming majority of header
|
|
320
|
+
* names. Because it is *derived* from the list (never hardcoded), adding a
|
|
321
|
+
* future reserved prefix that diverges simply shrinks this filter — in the
|
|
322
|
+
* worst case to `""`, where `String.prototype.startsWith("")` is always
|
|
323
|
+
* `true` and every header falls through to the full prefix scan. The fast
|
|
324
|
+
* path can therefore never cause a reserved header to be silently accepted.
|
|
325
|
+
*/
|
|
326
|
+
const RESERVED_PREFIX_COMMON = (() => {
|
|
327
|
+
let common = RESERVED_INBOUND_HEADER_PREFIXES[0] ?? "";
|
|
328
|
+
for (const prefix of RESERVED_INBOUND_HEADER_PREFIXES) {
|
|
329
|
+
let i = 0;
|
|
330
|
+
const max = Math.min(common.length, prefix.length);
|
|
331
|
+
while (i < max && common.charCodeAt(i) === prefix.charCodeAt(i))
|
|
332
|
+
i++;
|
|
333
|
+
common = common.slice(0, i);
|
|
334
|
+
}
|
|
335
|
+
return common;
|
|
336
|
+
})();
|
|
302
337
|
/**
|
|
303
338
|
* Reject requests that carry any header in
|
|
304
339
|
* {@link RESERVED_INBOUND_HEADER_PREFIXES}. See that constant for the
|
|
@@ -308,6 +343,10 @@ export const RESERVED_INBOUND_HEADER_PREFIXES = Object.freeze([
|
|
|
308
343
|
* `400 problem+json` instead of routing a request that may be probing
|
|
309
344
|
* for an internal-dispatch bypass.
|
|
310
345
|
*
|
|
346
|
+
* The framework's dispatch path runs this check and the header-count cap
|
|
347
|
+
* in a single shared walk internally (see {@link assertInboundHeaderGuards});
|
|
348
|
+
* calling this helper directly is only needed for custom pipelines.
|
|
349
|
+
*
|
|
311
350
|
* @param headers - Normalized request headers to inspect (names arrive lowercased).
|
|
312
351
|
* @since 0.36.0
|
|
313
352
|
*/
|
|
@@ -352,6 +391,11 @@ export const DEFAULT_MAX_HEADER_COUNT = 100;
|
|
|
352
391
|
* framework returns a structured `problem+json` response instead of routing
|
|
353
392
|
* a flood.
|
|
354
393
|
*
|
|
394
|
+
* The framework's dispatch path runs this check and the reserved-prefix
|
|
395
|
+
* check in a single shared walk internally (see
|
|
396
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
397
|
+
* needed for custom pipelines.
|
|
398
|
+
*
|
|
355
399
|
* @param headers - The incoming request headers.
|
|
356
400
|
* @param limit - Maximum distinct header fields to allow. `0` disables.
|
|
357
401
|
* @since 0.38.0
|
|
@@ -370,6 +414,67 @@ export function assertHeaderCountWithinLimit(headers, limit) {
|
|
|
370
414
|
}
|
|
371
415
|
});
|
|
372
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Combined inbound header guards for the dispatch hot path (internal).
|
|
419
|
+
*
|
|
420
|
+
* Runs the singleton-duplicate check ({@link assertNoDuplicateSingletonHeaders}),
|
|
421
|
+
* the reserved-internal-prefix check ({@link assertNoReservedInternalHeaders}),
|
|
422
|
+
* and the header-count cap ({@link assertHeaderCountWithinLimit}) in a
|
|
423
|
+
* **single** walk of the header map, with the same observable semantics as
|
|
424
|
+
* calling the helpers in sequence: any `400`-class violation (duplicate
|
|
425
|
+
* singleton header or reserved internal header) anywhere in the map is
|
|
426
|
+
* rejected with `400` even when the map also exceeds the count cap. To
|
|
427
|
+
* preserve that precedence, the `431` count-cap rejection is deferred until
|
|
428
|
+
* the scan has covered every header — the same full-walk cost the
|
|
429
|
+
* sequential helpers already paid.
|
|
430
|
+
*
|
|
431
|
+
* A non-positive / non-finite `limit` disables the count cap (same as
|
|
432
|
+
* {@link assertHeaderCountWithinLimit}) while still rejecting duplicate
|
|
433
|
+
* singleton headers and reserved internal prefixes.
|
|
434
|
+
*
|
|
435
|
+
* @param headers - Normalized request headers (names arrive lowercased).
|
|
436
|
+
* @param limit - Maximum distinct header fields to allow. `0` disables the count cap.
|
|
437
|
+
* @throws {BadRequestError} When a duplicate singleton header or a reserved
|
|
438
|
+
* internal header is present (takes precedence over the count cap).
|
|
439
|
+
* @throws {RequestHeaderFieldsTooLargeError} When no `400`-class violation is
|
|
440
|
+
* present and the distinct-header count exceeds `limit`.
|
|
441
|
+
* @since 1.0.0
|
|
442
|
+
*/
|
|
443
|
+
export function assertInboundHeaderGuards(headers, limit) {
|
|
444
|
+
const enforceCount = limit > 0 && Number.isFinite(limit);
|
|
445
|
+
let count = 0;
|
|
446
|
+
let overLimit = false;
|
|
447
|
+
// Single forEach: singleton-duplicate rejection + reserved-prefix rejection
|
|
448
|
+
// + distinct-name count. The 400-class checks throw immediately; the
|
|
449
|
+
// count-cap rejection is deferred past the walk so a violation after
|
|
450
|
+
// position `limit` still yields 400 (matching the sequential guards,
|
|
451
|
+
// where the 400-class scans ran first).
|
|
452
|
+
headers.forEach((value, name) => {
|
|
453
|
+
// Smuggling-singleton check: the WHATWG Headers collection coalesces
|
|
454
|
+
// duplicate fields to a comma-joined value, so "value contains a comma"
|
|
455
|
+
// means the client sent the header more than once (same semantics as
|
|
456
|
+
// assertNoDuplicateSingletonHeaders, folded into this walk to avoid
|
|
457
|
+
// three extra undici Headers.get() calls per request).
|
|
458
|
+
if (SMUGGLING_SINGLETON_SET.has(name) && value.indexOf(",") !== -1) {
|
|
459
|
+
throw new BadRequestError(`Duplicate ${name} header rejected`);
|
|
460
|
+
}
|
|
461
|
+
// Fast path: skip the inner prefix loop unless the name starts with the
|
|
462
|
+
// common prefix shared by every reserved prefix (derived at module load
|
|
463
|
+
// from RESERVED_INBOUND_HEADER_PREFIXES — see RESERVED_PREFIX_COMMON —
|
|
464
|
+
// so this filter can never silently drop a future reserved prefix).
|
|
465
|
+
if (name.startsWith(RESERVED_PREFIX_COMMON)) {
|
|
466
|
+
for (const prefix of RESERVED_INBOUND_HEADER_PREFIXES) {
|
|
467
|
+
if (name.startsWith(prefix)) {
|
|
468
|
+
throw new BadRequestError(`Reserved internal header rejected: ${name}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (enforceCount && ++count > limit)
|
|
473
|
+
overLimit = true;
|
|
474
|
+
});
|
|
475
|
+
if (overLimit)
|
|
476
|
+
throw new RequestHeaderFieldsTooLargeError(limit);
|
|
477
|
+
}
|
|
373
478
|
/**
|
|
374
479
|
* Minimum acceptable secret length in bytes for HMAC / signing material in
|
|
375
480
|
* production (boot guard). Matches the OWASP "Secret Management"
|
|
@@ -635,12 +740,8 @@ export async function verifyWebhookSignature(opts) {
|
|
|
635
740
|
if (Math.abs(nowSeconds - timestamp.seconds) > tolerance)
|
|
636
741
|
return false;
|
|
637
742
|
}
|
|
638
|
-
const payloadBytes = typeof opts.payload === "string"
|
|
639
|
-
|
|
640
|
-
: opts.payload;
|
|
641
|
-
const secretBytes = typeof opts.secret === "string"
|
|
642
|
-
? new TextEncoder().encode(opts.secret)
|
|
643
|
-
: opts.secret;
|
|
743
|
+
const payloadBytes = typeof opts.payload === "string" ? new TextEncoder().encode(opts.payload) : opts.payload;
|
|
744
|
+
const secretBytes = typeof opts.secret === "string" ? new TextEncoder().encode(opts.secret) : opts.secret;
|
|
644
745
|
const providedBytes = decodeWebhookSignature(opts.signature, algo.name, algo.signatureBytes);
|
|
645
746
|
if (!providedBytes)
|
|
646
747
|
return false;
|
|
@@ -675,12 +776,8 @@ export async function signWebhookPayload(opts) {
|
|
|
675
776
|
throw new TypeError("signWebhookPayload(): timestamp must be a non-negative integer number of seconds");
|
|
676
777
|
}
|
|
677
778
|
}
|
|
678
|
-
const payloadBytes = typeof opts.payload === "string"
|
|
679
|
-
|
|
680
|
-
: opts.payload;
|
|
681
|
-
const secretBytes = typeof opts.secret === "string"
|
|
682
|
-
? new TextEncoder().encode(opts.secret)
|
|
683
|
-
: opts.secret;
|
|
779
|
+
const payloadBytes = typeof opts.payload === "string" ? new TextEncoder().encode(opts.payload) : opts.payload;
|
|
780
|
+
const secretBytes = typeof opts.secret === "string" ? new TextEncoder().encode(opts.secret) : opts.secret;
|
|
684
781
|
const c = globalThis.crypto;
|
|
685
782
|
if (!c?.subtle)
|
|
686
783
|
throw new Error("WebCrypto unavailable: cannot sign webhook payload");
|
|
@@ -701,9 +798,28 @@ export async function signWebhookPayload(opts) {
|
|
|
701
798
|
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/g;
|
|
702
799
|
const WINDOWS_RESERVED_CHARS_RE = /[<>:"|?*]/g;
|
|
703
800
|
const WINDOWS_RESERVED_NAMES = new Set([
|
|
704
|
-
"con",
|
|
705
|
-
"
|
|
706
|
-
"
|
|
801
|
+
"con",
|
|
802
|
+
"prn",
|
|
803
|
+
"aux",
|
|
804
|
+
"nul",
|
|
805
|
+
"com1",
|
|
806
|
+
"com2",
|
|
807
|
+
"com3",
|
|
808
|
+
"com4",
|
|
809
|
+
"com5",
|
|
810
|
+
"com6",
|
|
811
|
+
"com7",
|
|
812
|
+
"com8",
|
|
813
|
+
"com9",
|
|
814
|
+
"lpt1",
|
|
815
|
+
"lpt2",
|
|
816
|
+
"lpt3",
|
|
817
|
+
"lpt4",
|
|
818
|
+
"lpt5",
|
|
819
|
+
"lpt6",
|
|
820
|
+
"lpt7",
|
|
821
|
+
"lpt8",
|
|
822
|
+
"lpt9",
|
|
707
823
|
]);
|
|
708
824
|
/**
|
|
709
825
|
* Return a single-segment, storage-safe basename derived from a
|
package/dist/session.d.ts
CHANGED
|
@@ -38,7 +38,13 @@ export declare const SESSION_HOOK_MARKER: unique symbol;
|
|
|
38
38
|
export declare const SESSION_SECRETS_MARKER: unique symbol;
|
|
39
39
|
/** A persisted session entry as stored by a {@link SessionStore}. */
|
|
40
40
|
export interface SessionRecord {
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Arbitrary JSON-serializable session payload. Mutating this object (including
|
|
43
|
+
* nested properties and array elements via the proxy on
|
|
44
|
+
* `ctx.state.session.data`) marks the session dirty so it is persisted on
|
|
45
|
+
* the response. Prefer plain objects/arrays/primitives; non-cloneable
|
|
46
|
+
* values (functions, DOM nodes) are not supported.
|
|
47
|
+
*/
|
|
42
48
|
data: Record<string, unknown>;
|
|
43
49
|
/** Absolute expiration as ms since epoch. */
|
|
44
50
|
expiresAt: number;
|
|
@@ -110,7 +116,12 @@ export interface SessionOptions {
|
|
|
110
116
|
export type SessionContext = {
|
|
111
117
|
/** Current session id. Refreshed by `regenerate()`. */
|
|
112
118
|
readonly id: string;
|
|
113
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Session payload. Top-level and nested mutations (including array element
|
|
121
|
+
* writes) mark the session dirty so {@link SessionStore.set} runs on the
|
|
122
|
+
* response. Prefer `set`/`delete` for top-level keys when you do not need
|
|
123
|
+
* nested objects.
|
|
124
|
+
*/
|
|
114
125
|
readonly data: Record<string, unknown>;
|
|
115
126
|
/** Read a single payload key. */
|
|
116
127
|
get<T = unknown>(key: string): T | undefined;
|
package/dist/session.js
CHANGED
|
@@ -104,21 +104,106 @@ function sessionCookieAttributes(opts) {
|
|
|
104
104
|
function markDirty(internal) {
|
|
105
105
|
internal.dirty = true;
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Deep-clone a session payload so store load/store never shares nested object
|
|
109
|
+
* references with the live request-scoped data.
|
|
110
|
+
*
|
|
111
|
+
* Prefers {@link structuredClone} (available on every supported runtime:
|
|
112
|
+
* Node >= 17, Deno, Bun, Workers, Vercel edge). Unlike a JSON round-trip it
|
|
113
|
+
* preserves `Date`, `Map`, `Set`, typed arrays, `BigInt`, and `undefined`
|
|
114
|
+
* property values, and it handles cyclic graphs — so a session that legitimately
|
|
115
|
+
* holds those values is neither silently corrupted (JSON stringifies a `Date`
|
|
116
|
+
* to a string and drops `undefined`) nor rejected (JSON throws on `BigInt` or a
|
|
117
|
+
* cycle). Falls back to the JSON round-trip only for the rare value
|
|
118
|
+
* `structuredClone` cannot copy (e.g. a function), which is unsupported in a
|
|
119
|
+
* session payload anyway.
|
|
120
|
+
*
|
|
121
|
+
* @param value - Session data tree (a plain object).
|
|
122
|
+
* @returns An independent deep copy.
|
|
123
|
+
*/
|
|
124
|
+
function cloneSessionData(value) {
|
|
125
|
+
if (typeof structuredClone === "function") {
|
|
126
|
+
try {
|
|
127
|
+
return structuredClone(value);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// structuredClone throws on non-cloneable values (functions, symbols);
|
|
131
|
+
// fall through to the JSON round-trip for best-effort compatibility.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return JSON.parse(JSON.stringify(value));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Whether a value should be wrapped by the deep dirty-tracking proxy: only
|
|
138
|
+
* plain objects and arrays. Exotic objects (`Date`, `Map`, `Set`, `RegExp`,
|
|
139
|
+
* typed arrays, class instances) are returned unwrapped so methods that rely
|
|
140
|
+
* on an internal slot as `this` (e.g. `Date.prototype.getTime`) keep working —
|
|
141
|
+
* a proxy would break them. Reassigning such a value on its parent is still
|
|
142
|
+
* dirty-tracked; only in-place internal mutation of the exotic value is not.
|
|
143
|
+
*
|
|
144
|
+
* @param value - Candidate nested value.
|
|
145
|
+
* @returns `true` for plain objects / arrays.
|
|
146
|
+
*/
|
|
147
|
+
function isProxyableContainer(value) {
|
|
148
|
+
if (value === null || typeof value !== "object")
|
|
149
|
+
return false;
|
|
150
|
+
if (Array.isArray(value))
|
|
151
|
+
return true;
|
|
152
|
+
const proto = Object.getPrototypeOf(value);
|
|
153
|
+
return proto === Object.prototype || proto === null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Recursive proxy that marks the session dirty on any nested set/delete,
|
|
157
|
+
* including array element assignment. Nested plain objects and arrays returned
|
|
158
|
+
* from `get` are re-wrapped so mutations deep in the tree are observed.
|
|
159
|
+
*
|
|
160
|
+
* Proxies are memoized per underlying target in `cache` (a {@link WeakMap}), so
|
|
161
|
+
* reading the same nested object twice yields the *same* proxy and object
|
|
162
|
+
* identity is preserved (`data.user === data.user`). The cache is scoped to a
|
|
163
|
+
* single session context (one request), so it never leaks across requests and
|
|
164
|
+
* its keys are collected with their targets.
|
|
165
|
+
*
|
|
166
|
+
* @param target - Underlying plain object/array held by the session.
|
|
167
|
+
* @param onDirty - Invoked on every successful mutation.
|
|
168
|
+
* @param cache - Per-context target→proxy memo table.
|
|
169
|
+
* @returns A proxy with the same surface as `target`.
|
|
170
|
+
*/
|
|
171
|
+
function createDeepDirtyProxy(target, onDirty, cache) {
|
|
172
|
+
const existing = cache.get(target);
|
|
173
|
+
if (existing)
|
|
174
|
+
return existing;
|
|
175
|
+
const proxy = new Proxy(target, {
|
|
176
|
+
get(t, prop, receiver) {
|
|
177
|
+
const value = Reflect.get(t, prop, receiver);
|
|
178
|
+
if (isProxyableContainer(value)) {
|
|
179
|
+
return createDeepDirtyProxy(value, onDirty, cache);
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
113
182
|
},
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
183
|
+
set(t, prop, value, receiver) {
|
|
184
|
+
const ok = Reflect.set(t, prop, value, receiver);
|
|
185
|
+
if (ok)
|
|
186
|
+
onDirty();
|
|
187
|
+
return ok;
|
|
188
|
+
},
|
|
189
|
+
deleteProperty(t, prop) {
|
|
190
|
+
const had = Reflect.has(t, prop);
|
|
191
|
+
const ok = Reflect.deleteProperty(t, prop);
|
|
192
|
+
if (ok && had)
|
|
193
|
+
onDirty();
|
|
194
|
+
return ok;
|
|
120
195
|
},
|
|
121
196
|
});
|
|
197
|
+
cache.set(target, proxy);
|
|
198
|
+
return proxy;
|
|
199
|
+
}
|
|
200
|
+
function makeSessionContext(id, data, internal, regenerate) {
|
|
201
|
+
const onDirty = () => {
|
|
202
|
+
markDirty(internal);
|
|
203
|
+
};
|
|
204
|
+
// Per-request memo table so nested reads return a stable proxy identity.
|
|
205
|
+
const proxyCache = new WeakMap();
|
|
206
|
+
const proxy = createDeepDirtyProxy(data, onDirty, proxyCache);
|
|
122
207
|
let currentId = id;
|
|
123
208
|
return {
|
|
124
209
|
get id() {
|
|
@@ -271,6 +356,7 @@ export function session(opts) {
|
|
|
271
356
|
destroyed: false,
|
|
272
357
|
regenerated: false,
|
|
273
358
|
created: false,
|
|
359
|
+
rawData: {},
|
|
274
360
|
};
|
|
275
361
|
const raw = readRequestCookie(ctx.request.headers.get("cookie"), cookieName);
|
|
276
362
|
internal.hadCookie = raw !== null;
|
|
@@ -287,7 +373,9 @@ export function session(opts) {
|
|
|
287
373
|
const rec = await store.get(candidateId);
|
|
288
374
|
if (rec && rec.expiresAt > Date.now()) {
|
|
289
375
|
id = candidateId;
|
|
290
|
-
|
|
376
|
+
// Deep-clone so nested mutations cannot mutate the store's
|
|
377
|
+
// retained object graph without going through dirty tracking.
|
|
378
|
+
data = rec.data ? cloneSessionData(rec.data) : {};
|
|
291
379
|
}
|
|
292
380
|
break;
|
|
293
381
|
}
|
|
@@ -326,6 +414,9 @@ export function session(opts) {
|
|
|
326
414
|
internal.originalId = null;
|
|
327
415
|
return next;
|
|
328
416
|
};
|
|
417
|
+
// Retain the raw object so onSend can deep-clone it directly.
|
|
418
|
+
// `regenerate` mutates this same reference, so it stays authoritative.
|
|
419
|
+
internal.rawData = data;
|
|
329
420
|
const sessionCtx = makeSessionContext(id, data, internal, regenerate);
|
|
330
421
|
const state = ctx.state;
|
|
331
422
|
state[STATE_KEY] = sessionCtx;
|
|
@@ -347,8 +438,9 @@ export function session(opts) {
|
|
|
347
438
|
}
|
|
348
439
|
return undefined;
|
|
349
440
|
}
|
|
350
|
-
|
|
351
|
-
|
|
441
|
+
// Clone the raw (un-proxied) data: structuredClone rejects a Proxy, and
|
|
442
|
+
// the proxy writes through to this object so it holds every mutation.
|
|
443
|
+
const data = internal.rawData;
|
|
352
444
|
const sid = internal.activeId;
|
|
353
445
|
const expiresAt = Date.now() + internal.ttlMs;
|
|
354
446
|
// A brand-new, untouched session is a no-op unless saveUninitialized is on.
|
|
@@ -357,13 +449,15 @@ export function session(opts) {
|
|
|
357
449
|
return undefined;
|
|
358
450
|
const mustPersist = internal.dirty || internal.created || internal.regenerated;
|
|
359
451
|
if (mustPersist) {
|
|
360
|
-
|
|
452
|
+
// Deep-clone so nested mutations persist as an independent tree and
|
|
453
|
+
// never share references with the store.
|
|
454
|
+
await store.set(sid, { data: cloneSessionData(data), expiresAt });
|
|
361
455
|
}
|
|
362
456
|
else if (internal.rolling) {
|
|
363
457
|
if (store.touch)
|
|
364
458
|
await store.touch(sid, expiresAt);
|
|
365
459
|
else
|
|
366
|
-
await store.set(sid, { data:
|
|
460
|
+
await store.set(sid, { data: cloneSessionData(data), expiresAt });
|
|
367
461
|
}
|
|
368
462
|
if (internal.regenerated && internal.originalId && internal.originalId !== sid) {
|
|
369
463
|
await store.destroy(internal.originalId);
|
package/dist/subdomains.js
CHANGED
|
@@ -126,10 +126,7 @@ export function subdomains(hostname, opts = {}) {
|
|
|
126
126
|
}
|
|
127
127
|
return resultFor(host, base);
|
|
128
128
|
}
|
|
129
|
-
const suffixes = new Set([
|
|
130
|
-
...PSL_PUBLIC_SUFFIXES,
|
|
131
|
-
...(opts.extraSuffixes ?? []),
|
|
132
|
-
]);
|
|
129
|
+
const suffixes = new Set([...PSL_PUBLIC_SUFFIXES, ...(opts.extraSuffixes ?? [])]);
|
|
133
130
|
const labels = host.split(".");
|
|
134
131
|
// Walk from the longest suffix candidate down to the shortest so the
|
|
135
132
|
// longest match wins (`s3.amazonaws.com` beats `com`).
|
package/dist/tenancy.d.ts
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
* (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
|
|
33
33
|
* is populated by the time their `keyGenerator` / `scope` callbacks run.
|
|
34
34
|
*
|
|
35
|
+
* `responseCache()` is a special case in two ways: it partitions on the resolved
|
|
36
|
+
* tenant **automatically** (no `keyGenerator` needed — see
|
|
37
|
+
* {@link TENANCY_RESOLVED_MARKER}), and because a mis-ordered cache is a silent
|
|
38
|
+
* cross-tenant disclosure rather than a merely wrong bucket, mounting it *ahead*
|
|
39
|
+
* of `tenancy()` refuses to boot in production instead of leaking.
|
|
40
|
+
*
|
|
35
41
|
* ```ts
|
|
36
42
|
* import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
|
|
37
43
|
*
|
|
@@ -161,6 +167,40 @@ export interface ClaimTenantOptions {
|
|
|
161
167
|
*/
|
|
162
168
|
export declare function tenantFromClaim(claim: string, opts?: ClaimTenantOptions): TenantResolver;
|
|
163
169
|
/** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
|
|
170
|
+
/**
|
|
171
|
+
* Marker stamped on the `Hooks` object returned by {@link tenancy}, so the `App`
|
|
172
|
+
* boot guard can verify that a `responseCache()` in the same chain is mounted
|
|
173
|
+
* *after* tenancy — i.e. that the tenant is in `ctx.state` by the time the cache
|
|
174
|
+
* key is built.
|
|
175
|
+
*
|
|
176
|
+
* @since 1.0.0
|
|
177
|
+
*/
|
|
178
|
+
export declare const TENANCY_HOOK_MARKER: unique symbol;
|
|
179
|
+
/**
|
|
180
|
+
* `ctx.state` symbol under which {@link tenancy} records the tenant it resolved,
|
|
181
|
+
* independently of the configurable {@link TenancyOptions.stateKey}.
|
|
182
|
+
*
|
|
183
|
+
* Consumers that must partition shared state per tenant — notably
|
|
184
|
+
* `responseCache()`, which folds it into the cache key to prevent cross-tenant
|
|
185
|
+
* cached-response disclosure (CWE-524) — read this instead of guessing the
|
|
186
|
+
* `stateKey`. Kept in the global symbol registry so a consumer can re-derive it
|
|
187
|
+
* with `Symbol.for(...)` without importing this module.
|
|
188
|
+
*
|
|
189
|
+
* @since 1.0.0
|
|
190
|
+
*/
|
|
191
|
+
export declare const TENANCY_RESOLVED_MARKER: unique symbol;
|
|
192
|
+
/**
|
|
193
|
+
* Value recorded under {@link TENANCY_RESOLVED_MARKER} when {@link tenancy} ran
|
|
194
|
+
* but resolved no tenant (only reachable with `tenancy({ require: false })`).
|
|
195
|
+
*
|
|
196
|
+
* Distinguishing "tenancy is active and resolved nothing" from "no tenancy at
|
|
197
|
+
* all" lets a consumer keep tenant-less traffic in its own partition rather than
|
|
198
|
+
* sharing the unpartitioned one. The leading space cannot occur in a normalized
|
|
199
|
+
* tenant id, so it can never collide with a real one.
|
|
200
|
+
*
|
|
201
|
+
* @since 1.0.0
|
|
202
|
+
*/
|
|
203
|
+
export declare const TENANT_UNRESOLVED = " unresolved";
|
|
164
204
|
export type UnresolvedStatus = 400 | 401 | 403 | 404;
|
|
165
205
|
/** Status codes acceptable for an unknown/disallowed-tenant rejection. @since 0.42.0 */
|
|
166
206
|
export type InvalidStatus = 400 | 403 | 404;
|