@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.5

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.
Files changed (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
@@ -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"
@@ -460,3 +501,24 @@ export declare function hasMongoOperatorKeys(value: unknown): boolean;
460
501
  * @since 0.35.0
461
502
  */
462
503
  export declare function assertNoMongoOperators(value: unknown): void;
504
+ /**
505
+ * Like {@link safeJsonParse}, but additionally enforces structural limits
506
+ * (total object keys across the tree and maximum nesting depth) to defend
507
+ * against wide-object / hash-flood and deep-nesting DoS payloads that stay
508
+ * under the byte cap.
509
+ *
510
+ * The limits are checked with a single pre-parse text scan
511
+ * ({@link assertJsonTextStructure}), so an oversized structure is rejected
512
+ * *before* it is parsed or allocated. On success the text is handed to
513
+ * {@link safeJsonParse}, which applies the same prototype-pollution stripping
514
+ * as every other body parser — so the security posture is identical, only the
515
+ * structural bounds are added.
516
+ *
517
+ * @param text - JSON text. Empty string returns `undefined`.
518
+ * @param maxKeys - Maximum total object keys (0 or negative = unlimited).
519
+ * @param maxDepth - Maximum nesting depth (0 or negative = unlimited).
520
+ * @returns Parsed value (with dangerous keys stripped).
521
+ * @throws {BadRequestError} on invalid JSON or when limits are exceeded.
522
+ * @since 1.0.0
523
+ */
524
+ export declare function safeJsonParseLimited(text: string, maxKeys?: number, maxDepth?: number): unknown;
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
- ? new TextEncoder().encode(opts.payload)
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
- ? new TextEncoder().encode(opts.payload)
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", "prn", "aux", "nul",
705
- "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
706
- "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
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
@@ -876,3 +992,92 @@ export function assertNoMongoOperators(value) {
876
992
  throw new BadRequestError("Operator-prefixed key rejected");
877
993
  }
878
994
  }
995
+ /**
996
+ * Enforces structural limits by scanning raw JSON *text* in a single
997
+ * allocation-free pass, before the value is parsed — so a wide-object or
998
+ * deep-nesting bomb is rejected without ever being materialized into memory.
999
+ *
1000
+ * Object keys are counted via `:` delimiters that sit outside string literals:
1001
+ * every JSON object member is `"key": value`, and array elements carry no
1002
+ * colon, so the number of structural colons equals the total object-key count
1003
+ * across the whole tree. Nesting depth tracks the running `{`/`[` … `}`/`]`
1004
+ * balance, again ignoring characters inside strings. Both checks short-circuit
1005
+ * the instant a limit is exceeded, giving bounded-time rejection.
1006
+ *
1007
+ * This mirrors the accounting of the older recursive object walk but costs one
1008
+ * tight character loop instead of a second full traversal of the parsed graph,
1009
+ * keeping the common (small-body) path close to a bare `JSON.parse`.
1010
+ *
1011
+ * @param text - The raw JSON text (already known to be non-empty).
1012
+ * @param maxKeys - Maximum total object keys (`<= 0` disables the key check).
1013
+ * @param maxDepth - Maximum nesting depth (`<= 0` disables the depth check).
1014
+ * @throws {BadRequestError} When the key or depth limit is exceeded.
1015
+ * @internal
1016
+ */
1017
+ function assertJsonTextStructure(text, maxKeys, maxDepth) {
1018
+ let depth = 0;
1019
+ let keyCount = 0;
1020
+ let inString = false;
1021
+ let escaped = false;
1022
+ for (let i = 0; i < text.length; i++) {
1023
+ const c = text.charCodeAt(i);
1024
+ if (inString) {
1025
+ if (escaped)
1026
+ escaped = false;
1027
+ else if (c === 0x5c /* \ */)
1028
+ escaped = true;
1029
+ else if (c === 0x22 /* " */)
1030
+ inString = false;
1031
+ continue;
1032
+ }
1033
+ switch (c) {
1034
+ case 0x22 /* " */:
1035
+ inString = true;
1036
+ break;
1037
+ case 0x7b /* { */:
1038
+ case 0x5b /* [ */:
1039
+ depth++;
1040
+ if (maxDepth > 0 && depth > maxDepth) {
1041
+ throw new BadRequestError("JSON exceeds maximum nesting depth");
1042
+ }
1043
+ break;
1044
+ case 0x7d /* } */:
1045
+ case 0x5d /* ] */:
1046
+ depth--;
1047
+ break;
1048
+ case 0x3a /* : */:
1049
+ if (maxKeys > 0 && ++keyCount > maxKeys) {
1050
+ throw new BadRequestError("JSON exceeds maximum key count");
1051
+ }
1052
+ break;
1053
+ }
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Like {@link safeJsonParse}, but additionally enforces structural limits
1058
+ * (total object keys across the tree and maximum nesting depth) to defend
1059
+ * against wide-object / hash-flood and deep-nesting DoS payloads that stay
1060
+ * under the byte cap.
1061
+ *
1062
+ * The limits are checked with a single pre-parse text scan
1063
+ * ({@link assertJsonTextStructure}), so an oversized structure is rejected
1064
+ * *before* it is parsed or allocated. On success the text is handed to
1065
+ * {@link safeJsonParse}, which applies the same prototype-pollution stripping
1066
+ * as every other body parser — so the security posture is identical, only the
1067
+ * structural bounds are added.
1068
+ *
1069
+ * @param text - JSON text. Empty string returns `undefined`.
1070
+ * @param maxKeys - Maximum total object keys (0 or negative = unlimited).
1071
+ * @param maxDepth - Maximum nesting depth (0 or negative = unlimited).
1072
+ * @returns Parsed value (with dangerous keys stripped).
1073
+ * @throws {BadRequestError} on invalid JSON or when limits are exceeded.
1074
+ * @since 1.0.0
1075
+ */
1076
+ export function safeJsonParseLimited(text, maxKeys = 10_000, maxDepth = 50) {
1077
+ if (text.length === 0)
1078
+ return undefined;
1079
+ if (maxKeys > 0 || maxDepth > 0) {
1080
+ assertJsonTextStructure(text, maxKeys, maxDepth);
1081
+ }
1082
+ return safeJsonParse(text);
1083
+ }
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
- /** Arbitrary serializable session payload. Mutating this object marks the session dirty. */
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
- /** Session payload. Mutating this object marks the session dirty. */
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
- function makeSessionContext(id, data, internal, regenerate) {
108
- const proxy = new Proxy(data, {
109
- set(target, key, value) {
110
- target[key] = value;
111
- markDirty(internal);
112
- return true;
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
- deleteProperty(target, key) {
115
- const had = key in target;
116
- delete target[key];
117
- if (had)
118
- markDirty(internal);
119
- return true;
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
- data = rec.data ? { ...rec.data } : {};
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
- const sessionCtx = state[STATE_KEY];
351
- const data = sessionCtx.data;
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
- await store.set(sid, { data: { ...data }, expiresAt });
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: { ...data }, expiresAt });
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/tenancy.d.ts CHANGED
@@ -48,7 +48,7 @@
48
48
  *
49
49
  * @since 0.42.0
50
50
  */
51
- import type { BaseContext, Hooks } from "./types.js";
51
+ import type { BaseContext, Hooks, PreBodyContext } from "./types.js";
52
52
  /**
53
53
  * Resolves a raw (un-normalized) tenant id from a request, or a nullish value
54
54
  * when this strategy cannot determine one. Resolvers are tried in order and
@@ -249,4 +249,4 @@ export interface TenantScopeOptions {
249
249
  * @returns A key function suitable for `keyGenerator` / `scope`.
250
250
  * @since 0.42.0
251
251
  */
252
- export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any>) => string;
252
+ export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any> | PreBodyContext<any>) => string;
@@ -69,7 +69,9 @@ export function assertTemporalClaims(claims, opts) {
69
69
  if (!isFiniteNumber(claims.exp)) {
70
70
  throw new TemporalClaimError("invalid_exp", "payload.exp is not a finite number.");
71
71
  }
72
- if (now > claims.exp + skew) {
72
+ // RFC 7519 §4.1.4: current time must be *before* exp. At the exact
73
+ // expiration second (after skew) the token is no longer valid.
74
+ if (now >= claims.exp + skew) {
73
75
  throw new TemporalClaimError("token_expired", "token has expired (exp).");
74
76
  }
75
77
  }