@daloyjs/core 1.3.1 → 1.3.2

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.
@@ -115,6 +115,8 @@ export declare function _resetCompressionRuntimeProbeForTests(): void;
115
115
  * - response already declares a `Content-Encoding`;
116
116
  * - response declares a `Set-Cookie` (response is mutating auth state);
117
117
  * - response body byte length is below `minimumSize` (default `1024`);
118
+ * - response body exceeds `maxCompressibleBytes` while reading; only the
119
+ * capture clone is cancelled, without waiting for the client to consume it;
118
120
  * - response `Content-Type` is in the always-on already-compressed
119
121
  * deny-list (image/video/audio/archives/fonts/wasm/pdf, with
120
122
  * `image/svg+xml` carved back in as compressible XML).
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * @since 0.25.0
22
22
  */
23
+ import { readResponseBodyUpTo } from "./internal-body.js";
23
24
  /**
24
25
  * Internal marker stamped on the hook so a future audit gate can confirm
25
26
  * the BREACH-aware middleware is installed (audit-item parity).
@@ -217,57 +218,6 @@ function normalizeOptionTokens(values, optionName) {
217
218
  }
218
219
  return Object.freeze(normalized);
219
220
  }
220
- /**
221
- * Read a response body up to `maxBytes`. Returns `null` if the stream
222
- * exceeds the cap (body is cancelled; caller should leave the response
223
- * uncompressed). Returns an empty buffer when there is no body.
224
- *
225
- * @param res - Response whose body will be consumed (pass a clone).
226
- * @param maxBytes - Inclusive upper bound on buffered size.
227
- */
228
- async function readBodyUpTo(res, maxBytes) {
229
- if (!res.body)
230
- return new Uint8Array(0);
231
- const reader = res.body.getReader();
232
- const chunks = [];
233
- let total = 0;
234
- try {
235
- // eslint-disable-next-line no-constant-condition
236
- while (true) {
237
- const { done, value } = await reader.read();
238
- if (done)
239
- break;
240
- if (!value || value.byteLength === 0)
241
- continue;
242
- total += value.byteLength;
243
- if (total > maxBytes) {
244
- await reader.cancel();
245
- return null;
246
- }
247
- chunks.push(value);
248
- }
249
- }
250
- catch {
251
- try {
252
- await reader.cancel();
253
- }
254
- catch {
255
- /* ignore */
256
- }
257
- return null;
258
- }
259
- if (chunks.length === 0)
260
- return new Uint8Array(0);
261
- if (chunks.length === 1)
262
- return chunks[0];
263
- const out = new Uint8Array(total);
264
- let offset = 0;
265
- for (const c of chunks) {
266
- out.set(c, offset);
267
- offset += c.byteLength;
268
- }
269
- return out;
270
- }
271
221
  async function compressBytes(bytes, encoding) {
272
222
  const Stream = globalThis.CompressionStream;
273
223
  const cs = new Stream(encoding);
@@ -317,6 +267,8 @@ async function compressBytes(bytes, encoding) {
317
267
  * - response already declares a `Content-Encoding`;
318
268
  * - response declares a `Set-Cookie` (response is mutating auth state);
319
269
  * - response body byte length is below `minimumSize` (default `1024`);
270
+ * - response body exceeds `maxCompressibleBytes` while reading; only the
271
+ * capture clone is cancelled, without waiting for the client to consume it;
320
272
  * - response `Content-Type` is in the always-on already-compressed
321
273
  * deny-list (image/video/audio/archives/fonts/wasm/pdf, with
322
274
  * `image/svg+xml` carved back in as compressible XML).
@@ -407,7 +359,7 @@ export function compression(opts = {}) {
407
359
  if (Number.isFinite(n) && n > maxCompressibleBytes)
408
360
  return undefined;
409
361
  }
410
- const original = await readBodyUpTo(res.clone(), maxCompressibleBytes);
362
+ const original = await readResponseBodyUpTo(res.clone(), maxCompressibleBytes).catch(() => null);
411
363
  if (original === null)
412
364
  return undefined; // exceeded cap while streaming
413
365
  if (original.byteLength < minimumSize)
@@ -254,7 +254,15 @@ export interface FetchGuardOptions {
254
254
  * @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
255
255
  * for the strict default posture (public IPs over `http:`/`https:` only).
256
256
  * @returns A `fetch`-compatible function that validates every hop (including
257
- * redirects) and throws {@link SsrfBlockedError} on refusal.
257
+ * redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
258
+ * `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
259
+ * preserve them. Custom credential headers must not be used with untrusted
260
+ * redirect destinations; use `redirect: "error"` or `"manual"` in that case.
261
+ * @throws {Error} If no underlying fetch implementation is available.
262
+ * @throws {SsrfBlockedError} The returned function throws when a destination
263
+ * or redirect chain violates the configured policy; network errors propagate.
264
+ * @throws {TypeError} The returned function throws on invalid requests or
265
+ * redirects when `redirect: "error"` is selected.
258
266
  * @since 0.34.0
259
267
  */
260
268
  export declare function fetchGuard(options?: FetchGuardOptions): typeof fetch;
@@ -176,7 +176,15 @@ const UNIQUE_LOCAL = ["fc00::/7"];
176
176
  * @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
177
177
  * for the strict default posture (public IPs over `http:`/`https:` only).
178
178
  * @returns A `fetch`-compatible function that validates every hop (including
179
- * redirects) and throws {@link SsrfBlockedError} on refusal.
179
+ * redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
180
+ * `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
181
+ * preserve them. Custom credential headers must not be used with untrusted
182
+ * redirect destinations; use `redirect: "error"` or `"manual"` in that case.
183
+ * @throws {Error} If no underlying fetch implementation is available.
184
+ * @throws {SsrfBlockedError} The returned function throws when a destination
185
+ * or redirect chain violates the configured policy; network errors propagate.
186
+ * @throws {TypeError} The returned function throws on invalid requests or
187
+ * redirects when `redirect: "error"` is selected.
180
188
  * @since 0.34.0
181
189
  */
182
190
  export function fetchGuard(options = {}) {
@@ -371,6 +379,12 @@ export function fetchGuard(options = {}) {
371
379
  referrerPolicy: request.referrerPolicy,
372
380
  })
373
381
  : new Request(next, request);
382
+ if (next.origin !== currentUrl.origin) {
383
+ request.headers.delete("authorization");
384
+ request.headers.delete("cookie");
385
+ request.headers.delete("proxy-authorization");
386
+ request.headers.delete("host");
387
+ }
374
388
  currentUrl = next;
375
389
  }
376
390
  };
@@ -283,6 +283,10 @@ export interface ResilientFetchOptions {
283
283
  * Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
284
284
  * circuit breaker. The returned function has the same call signature as
285
285
  * the global `fetch`.
286
+ * Response bodies discarded for a retry are cancelled before backoff without
287
+ * waiting for producer cancellation. The final response remains caller-owned.
288
+ * Caller cancellation preserves arbitrary abort reasons and never counts as
289
+ * an upstream failure or schedules another retry.
286
290
  *
287
291
  * Layer it over {@link fetchGuard} to keep SSRF protection underneath:
288
292
  *
@@ -348,6 +348,10 @@ function isAbortError(err) {
348
348
  * Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
349
349
  * circuit breaker. The returned function has the same call signature as
350
350
  * the global `fetch`.
351
+ * Response bodies discarded for a retry are cancelled before backoff without
352
+ * waiting for producer cancellation. The final response remains caller-owned.
353
+ * Caller cancellation preserves arbitrary abort reasons and never counts as
354
+ * an upstream failure or schedules another retry.
351
355
  *
352
356
  * Layer it over {@link fetchGuard} to keep SSRF protection underneath:
353
357
  *
@@ -416,6 +420,7 @@ export function resilientFetch(options = {}) {
416
420
  // the caller's signal can be combined per attempt.
417
421
  const request = new Request(input, init);
418
422
  const callerSignal = init?.signal ?? request.signal;
423
+ callerSignal?.throwIfAborted();
419
424
  const run = async () => {
420
425
  let lastError;
421
426
  for (let attempt = 1; attempt <= retries + 1; attempt++) {
@@ -428,8 +433,7 @@ export function resilientFetch(options = {}) {
428
433
  catch (err) {
429
434
  cleanup();
430
435
  // Caller cancelled: never retry, never count as upstream failure.
431
- if (isAbortError(err) && callerSignal?.aborted)
432
- throw err;
436
+ callerSignal?.throwIfAborted();
433
437
  // An SSRF refusal from an underlying fetchGuard is a hard, terminal
434
438
  // decision about the request itself — never retried.
435
439
  if (err instanceof Error && err.name === "SsrfBlockedError")
@@ -441,8 +445,7 @@ export function resilientFetch(options = {}) {
441
445
  const delay = backoffFor(attempt);
442
446
  options.onRetry?.(ctx, delay);
443
447
  await sleep(delay, callerSignal ?? undefined);
444
- if (callerSignal?.aborted)
445
- throw lastError;
448
+ callerSignal?.throwIfAborted();
446
449
  continue;
447
450
  }
448
451
  throw lastError;
@@ -452,9 +455,9 @@ export function resilientFetch(options = {}) {
452
455
  if (attempt <= retries && shouldRetry(ctx)) {
453
456
  const delay = backoffFor(attempt, response);
454
457
  options.onRetry?.(ctx, delay);
458
+ void response.body?.cancel().catch(() => undefined);
455
459
  await sleep(delay, callerSignal ?? undefined);
456
- if (callerSignal?.aborted)
457
- return response;
460
+ callerSignal?.throwIfAborted();
458
461
  continue;
459
462
  }
460
463
  return response;
@@ -476,7 +479,7 @@ export function resilientFetch(options = {}) {
476
479
  // SSRF refusals and caller aborts are not upstream health signals.
477
480
  if (err instanceof CircuitOpenError)
478
481
  throw err;
479
- const isCallerAbort = isAbortError(err) && callerSignal?.aborted;
482
+ const isCallerAbort = callerSignal?.aborted;
480
483
  const isSsrf = err instanceof Error && err.name === "SsrfBlockedError";
481
484
  if (isCallerAbort || isSsrf)
482
485
  breaker.release();
@@ -39,7 +39,11 @@ import type { Hooks } from "./types.js";
39
39
  * @since 0.37.0
40
40
  */
41
41
  export type HttpSignatureAlgorithm = "hmac-sha256" | "ed25519" | "ecdsa-p256-sha256" | "ecdsa-p384-sha384" | "rsa-pss-sha512" | "rsa-v1_5-sha256";
42
- /** Key material accepted by the signer/verifier. */
42
+ /**
43
+ * Key material accepted by the signer/verifier. Imported keys must match the
44
+ * selected algorithm's family, hash and curve, with the same 32-byte HMAC and
45
+ * 2048-bit RSA minimums as raw keys.
46
+ */
43
47
  export type HttpSignatureKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
44
48
  /**
45
49
  * A resolved verification key, optionally pinning the algorithm it may be used
@@ -134,7 +138,8 @@ export interface MessageSignature {
134
138
  * @returns The `Signature-Input` / `Signature` header values plus the exact
135
139
  * signature base that was signed.
136
140
  * @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
137
- * unserializable parameter values.
141
+ * unserializable parameter values. Imported keys must match the selected
142
+ * algorithm's family, hash and curve and meet its strength floor.
138
143
  * @throws {Error} when a covered component cannot be resolved (e.g. a covered
139
144
  * header is missing) or WebCrypto is unavailable.
140
145
  * @since 0.37.0
@@ -245,7 +250,8 @@ export interface VerifyMessageOptions {
245
250
  requiredTag?: string;
246
251
  /**
247
252
  * Replay check. When provided, a `nonce` is required and the signature is
248
- * rejected if this returns `true`.
253
+ * rejected if this returns `true`. Called only after cryptographic verification
254
+ * succeeds; implementations that record nonces must check and record atomically.
249
255
  */
250
256
  isReplay?: (nonce: string, info: KeyResolutionInfo) => boolean | Promise<boolean>;
251
257
  /** Clock used for age checks. Returns milliseconds. Defaults to `Date.now`. */
@@ -255,6 +261,7 @@ export interface VerifyMessageOptions {
255
261
  * Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
256
262
  * structured result and never throws on a bad/forged signature — only on a
257
263
  * programming error (e.g. WebCrypto unavailable).
264
+ * Imported keys that violate the algorithm or strength policy return invalid_key.
258
265
  *
259
266
  * @param opts - Received message plus verification policy (algorithm
260
267
  * allowlist, key resolver, freshness / replay checks); see
@@ -300,6 +307,8 @@ export interface HttpSignatureAuthOptions extends Omit<VerifyMessageOptions, "me
300
307
  * requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
301
308
  * a missing (unless `optional`) or invalid signature it throws
302
309
  * {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
310
+ * Verification runs before body I/O and before stored-response middleware,
311
+ * so cache hits and idempotency replays cannot skip signature authentication.
303
312
  *
304
313
  * @param opts - Verification policy plus middleware knobs; see
305
314
  * {@link HttpSignatureAuthOptions}.
@@ -167,7 +167,7 @@ async function importKey(alg, material, usage) {
167
167
  const spec = algSpec(alg);
168
168
  const c = getCrypto();
169
169
  if (isCryptoKey(material)) {
170
- assertRsaModulusFloor(alg, material);
170
+ assertKeyPolicy(alg, material);
171
171
  return material;
172
172
  }
173
173
  if (material instanceof Uint8Array) {
@@ -177,15 +177,31 @@ async function importKey(alg, material, usage) {
177
177
  if (material.byteLength < MIN_HMAC_KEY_BYTES) {
178
178
  throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2); got ${material.byteLength}.`);
179
179
  }
180
- return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
180
+ return c.subtle.importKey("raw", material, spec.importParams, false, [
181
+ usage,
182
+ ]);
181
183
  }
182
184
  if (isJsonWebKey(material)) {
183
185
  const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [usage]);
184
- assertRsaModulusFloor(alg, key);
186
+ assertKeyPolicy(alg, key);
185
187
  return key;
186
188
  }
187
189
  throw new TypeError("http-signatures: unsupported key material.");
188
190
  }
191
+ function assertKeyPolicy(alg, key) {
192
+ const expected = algSpec(alg).importParams;
193
+ const actual = key.algorithm;
194
+ if (actual.name !== expected.name ||
195
+ (expected.hash !== undefined && actual.hash?.name !== expected.hash) ||
196
+ (expected.namedCurve !== undefined && actual.namedCurve !== expected.namedCurve)) {
197
+ throw new TypeError(`http-signatures: key algorithm does not match ${alg}; family, hash and curve must match.`);
198
+ }
199
+ if (expected.name === "HMAC" &&
200
+ (!Number.isFinite(actual.length) || actual.length < MIN_HMAC_KEY_BYTES * 8)) {
201
+ throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2).`);
202
+ }
203
+ assertRsaModulusFloor(alg, key);
204
+ }
189
205
  function serializeSfString(s) {
190
206
  for (let i = 0; i < s.length; i++) {
191
207
  const code = s.charCodeAt(i);
@@ -518,7 +534,8 @@ function parseComponentSpec(spec) {
518
534
  * @returns The `Signature-Input` / `Signature` header values plus the exact
519
535
  * signature base that was signed.
520
536
  * @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
521
- * unserializable parameter values.
537
+ * unserializable parameter values. Imported keys must match the selected
538
+ * algorithm's family, hash and curve and meet its strength floor.
522
539
  * @throws {Error} when a covered component cannot be resolved (e.g. a covered
523
540
  * header is missing) or WebCrypto is unavailable.
524
541
  * @since 0.37.0
@@ -585,6 +602,7 @@ function fail(reason) {
585
602
  * Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
586
603
  * structured result and never throws on a bad/forged signature — only on a
587
604
  * programming error (e.g. WebCrypto unavailable).
605
+ * Imported keys that violate the algorithm or strength policy return invalid_key.
588
606
  *
589
607
  * @param opts - Received message plus verification policy (algorithm
590
608
  * allowlist, key resolver, freshness / replay checks); see
@@ -671,12 +689,8 @@ export async function verifyMessage(opts) {
671
689
  return fail("signature_expired");
672
690
  }
673
691
  // Replay check.
674
- if (opts.isReplay) {
675
- if (params.nonce === undefined)
676
- return fail("missing_nonce");
677
- if (await opts.isReplay(params.nonce, info))
678
- return fail("replay_detected");
679
- }
692
+ if (opts.isReplay && params.nonce === undefined)
693
+ return fail("missing_nonce");
680
694
  // Resolve the key + effective algorithm (defeating algorithm-confusion).
681
695
  const resolved = await opts.resolveKey(info);
682
696
  if (resolved === undefined)
@@ -730,6 +744,9 @@ export async function verifyMessage(opts) {
730
744
  }
731
745
  if (!ok)
732
746
  return fail("invalid_signature");
747
+ if (opts.isReplay && (await opts.isReplay(params.nonce, info))) {
748
+ return fail("replay_detected");
749
+ }
733
750
  return {
734
751
  valid: true,
735
752
  label,
@@ -765,6 +782,8 @@ export function verifyRequest(request, opts) {
765
782
  * requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
766
783
  * a missing (unless `optional`) or invalid signature it throws
767
784
  * {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
785
+ * Verification runs before body I/O and before stored-response middleware,
786
+ * so cache hits and idempotency replays cannot skip signature authentication.
768
787
  *
769
788
  * @param opts - Verification policy plus middleware knobs; see
770
789
  * {@link HttpSignatureAuthOptions}.
@@ -775,7 +794,7 @@ export function httpSignatureAuth(opts) {
775
794
  const stateKey = opts.stateKey ?? "httpSignature";
776
795
  const message = opts.message ?? "Valid HTTP message signature required";
777
796
  const authHooks = {
778
- async beforeHandle(ctx) {
797
+ async preBody(ctx) {
779
798
  const headers = ctx.request.headers;
780
799
  if (opts.optional && !headers.has("signature"))
781
800
  return undefined;
@@ -214,6 +214,8 @@ export interface IdempotencyOptions {
214
214
  * alone is not a bound — it only drops records that have *expired*, so a stream
215
215
  * of unique keys inside the TTL grew the map linearly no matter how often it ran,
216
216
  * with each entry pinning a stored response body.
217
+ * Late completions of evicted reservations use the same capacity policy;
218
+ * completing a retained entry updates it without evicting another record.
217
219
  *
218
220
  * Evicting a live record can only cost exactly-once semantics for a retry that
219
221
  * arrives after the eviction — it re-executes rather than replaying. That is the
@@ -235,7 +237,14 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
235
237
  * the in-memory store derives expiry from `record.expiresAt`.
236
238
  */
237
239
  reserve(key: string, record: IdempotencyRecord, _ttlMs?: number): IdempotencyRecord | null;
238
- /** @inheritDoc */
240
+ /**
241
+ * Persist a completed response, enforcing capacity even if its reservation
242
+ * was evicted while the handler ran.
243
+ * @param key - Reservation key to complete.
244
+ * @param record - Completed record with response data and expiry.
245
+ * @param _ttlMs - Unused; expiry is derived from record.expiresAt.
246
+ * @returns Nothing. A new entry may evict the oldest retained record.
247
+ */
239
248
  complete(key: string, record: IdempotencyRecord, _ttlMs?: number): void;
240
249
  /** @inheritDoc */
241
250
  release(key: string): void;
@@ -278,6 +287,12 @@ export declare const IDEMPOTENCY_HOOK_MARKER: unique symbol;
278
287
  * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
279
288
  * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
280
289
  * cached and the reservation is released so the client can retry.
290
+ * The byte cap is enforced while reading the response clone; exceeding it
291
+ * stops capture without waiting for EOF or consuming the client's branch.
292
+ * Replays require every scope aggregated from the route's requireScopes hooks;
293
+ * callers without those scopes continue to the normal authorization chain.
294
+ * Empty Authorization headers do not resolve a principal and cannot bypass
295
+ * the default cookie-bearing-request scope guard.
281
296
  *
282
297
  * @example
283
298
  * ```ts
@@ -33,6 +33,8 @@
33
33
  */
34
34
  import { BadRequestError, ConflictError, HttpError } from "./errors.js";
35
35
  import { markSchemaValidatedResponse } from "./internal-response.js";
36
+ import { readResponseBodyUpTo } from "./internal-body.js";
37
+ import { hasReplayScopes } from "./internal-replay.js";
36
38
  const enc = new TextEncoder();
37
39
  /** Internal `ctx.state` key carrying the reservation between hooks. */
38
40
  const PENDING_STATE_KEY = "__idempotencyPending";
@@ -73,6 +75,8 @@ const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
73
75
  * alone is not a bound — it only drops records that have *expired*, so a stream
74
76
  * of unique keys inside the TTL grew the map linearly no matter how often it ran,
75
77
  * with each entry pinning a stored response body.
78
+ * Late completions of evicted reservations use the same capacity policy;
79
+ * completing a retained entry updates it without evicting another record.
76
80
  *
77
81
  * Evicting a live record can only cost exactly-once semantics for a retry that
78
82
  * arrives after the eviction — it re-executes rather than replaying. That is the
@@ -117,8 +121,19 @@ export class MemoryIdempotencyStore {
117
121
  this.map.set(key, record);
118
122
  return null;
119
123
  }
120
- /** @inheritDoc */
124
+ /**
125
+ * Persist a completed response, enforcing capacity even if its reservation
126
+ * was evicted while the handler ran.
127
+ * @param key - Reservation key to complete.
128
+ * @param record - Completed record with response data and expiry.
129
+ * @param _ttlMs - Unused; expiry is derived from record.expiresAt.
130
+ * @returns Nothing. A new entry may evict the oldest retained record.
131
+ */
121
132
  complete(key, record, _ttlMs) {
133
+ if (!this.map.has(key)) {
134
+ this.reserve(key, record, _ttlMs);
135
+ return;
136
+ }
122
137
  this.map.set(key, record);
123
138
  }
124
139
  /** @inheritDoc */
@@ -266,8 +281,8 @@ const NEVER_REPLAYED_HEADERS = new Set([
266
281
  "x-request-id",
267
282
  ]);
268
283
  async function captureResponse(res, maxBytes) {
269
- const buf = new Uint8Array(await res.clone().arrayBuffer());
270
- if (buf.byteLength > maxBytes)
284
+ const buf = await readResponseBodyUpTo(res.clone(), maxBytes);
285
+ if (buf === null)
271
286
  return null;
272
287
  const headers = [];
273
288
  res.headers.forEach((value, name) => {
@@ -326,6 +341,12 @@ export const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
326
341
  * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
327
342
  * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
328
343
  * cached and the reservation is released so the client can retry.
344
+ * The byte cap is enforced while reading the response clone; exceeding it
345
+ * stops capture without waiting for EOF or consuming the client's branch.
346
+ * Replays require every scope aggregated from the route's requireScopes hooks;
347
+ * callers without those scopes continue to the normal authorization chain.
348
+ * Empty Authorization headers do not resolve a principal and cannot bypass
349
+ * the default cookie-bearing-request scope guard.
329
350
  *
330
351
  * @example
331
352
  * ```ts
@@ -376,6 +397,8 @@ export function idempotency(opts = {}) {
376
397
  const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
377
398
  const hooks = {
378
399
  async beforeHandle(ctx) {
400
+ if (!hasReplayScopes(ctx))
401
+ return undefined;
379
402
  const method = ctx.request.method.toUpperCase();
380
403
  if (!methods.has(method))
381
404
  return undefined;
@@ -423,11 +446,11 @@ export function idempotency(opts = {}) {
423
446
  // is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
424
447
  // coarse namespace cannot escalate into handing over a live session.
425
448
  if (!opts.scope &&
426
- scopeRaw === undefined &&
449
+ !scopeRaw &&
427
450
  !allowUnscopedCallers &&
428
451
  ctx.request.headers.has("cookie")) {
429
452
  throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
430
- "The default scope reads the Authorization header, which this request does not carry, " +
453
+ "The default scope reads the Authorization header, which this request does not carry with a non-empty value, " +
431
454
  "so every cookie-authenticated caller would share one idempotency namespace and could " +
432
455
  "replay another caller's stored response (CWE-524). Pass " +
433
456
  "`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read a response clone while enforcing an inclusive byte limit.
3
+ *
4
+ * @param response - A clone whose body may be consumed or cancelled.
5
+ * @param maxBytes - Maximum retained response bytes, validated by the caller.
6
+ * @returns Captured bytes, or null as soon as the stream exceeds the limit.
7
+ * Cancelling a tee branch is not awaited because the other branch may still
8
+ * be waiting for its consumer. The original response remains readable.
9
+ * @throws Propagates stream read errors without converting them to cached data.
10
+ * @internal
11
+ */
12
+ export declare function readResponseBodyUpTo(response: Response, maxBytes: number): Promise<Uint8Array | null>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read a response clone while enforcing an inclusive byte limit.
3
+ *
4
+ * @param response - A clone whose body may be consumed or cancelled.
5
+ * @param maxBytes - Maximum retained response bytes, validated by the caller.
6
+ * @returns Captured bytes, or null as soon as the stream exceeds the limit.
7
+ * Cancelling a tee branch is not awaited because the other branch may still
8
+ * be waiting for its consumer. The original response remains readable.
9
+ * @throws Propagates stream read errors without converting them to cached data.
10
+ * @internal
11
+ */
12
+ export async function readResponseBodyUpTo(response, maxBytes) {
13
+ if (!response.body)
14
+ return new Uint8Array(0);
15
+ const reader = response.body.getReader();
16
+ const chunks = [];
17
+ let total = 0;
18
+ try {
19
+ while (true) {
20
+ const { done, value } = await reader.read();
21
+ if (done)
22
+ break;
23
+ if (value.byteLength === 0)
24
+ continue;
25
+ total += value.byteLength;
26
+ if (total > maxBytes) {
27
+ void reader.cancel().catch(() => undefined);
28
+ return null;
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ }
33
+ finally {
34
+ reader.releaseLock();
35
+ }
36
+ if (chunks.length === 1)
37
+ return chunks[0];
38
+ const bytes = new Uint8Array(total);
39
+ let offset = 0;
40
+ for (const chunk of chunks) {
41
+ bytes.set(chunk, offset);
42
+ offset += chunk.byteLength;
43
+ }
44
+ return bytes;
45
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Check the scope requirements aggregated by App before a stored-response hook.
3
+ *
4
+ * @param context - Current request state, including requirements installed by
5
+ * App.mergeBeforeHandle and the user resolved by upstream authentication.
6
+ * @returns True when no scope guard applies or the current user owns every
7
+ * required scope. False tells replay middleware to defer to the normal hook
8
+ * chain without reading, reserving, or capturing a stored response. The
9
+ * downstream requireScopes hook remains responsible for rejecting access.
10
+ * @internal
11
+ */
12
+ export declare function hasReplayScopes(context: {
13
+ readonly state: Readonly<Record<string, unknown>>;
14
+ }): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Check the scope requirements aggregated by App before a stored-response hook.
3
+ *
4
+ * @param context - Current request state, including requirements installed by
5
+ * App.mergeBeforeHandle and the user resolved by upstream authentication.
6
+ * @returns True when no scope guard applies or the current user owns every
7
+ * required scope. False tells replay middleware to defer to the normal hook
8
+ * chain without reading, reserving, or capturing a stored response. The
9
+ * downstream requireScopes hook remains responsible for rejecting access.
10
+ * @internal
11
+ */
12
+ export function hasReplayScopes(context) {
13
+ const required = context.state.__daloyRequiredScopes;
14
+ if (!Array.isArray(required) || required.length === 0)
15
+ return true;
16
+ const user = context.state.user;
17
+ if (!user || typeof user !== "object")
18
+ return false;
19
+ const owned = user.scopes;
20
+ return (Array.isArray(owned) && required.every((scope) => owned.includes(scope)));
21
+ }
package/dist/jwk.d.ts CHANGED
@@ -9,7 +9,8 @@ export interface JwkSet {
9
9
  }
10
10
  /**
11
11
  * Source for the verifier's public keys. Either an in-memory JWKS, a
12
- * `https://` URL (fetched with TTL caching), or a custom async resolver.
12
+ * `https://` URL (fetched directly with TTL caching; redirects are refused),
13
+ * or a custom async resolver.
13
14
  */
14
15
  export type JwkSource = JwkSet | string | (() => JwkSet | Promise<JwkSet>);
15
16
  /**
@@ -61,7 +62,8 @@ export interface JwkOptions {
61
62
  maxStaleSeconds?: number;
62
63
  /**
63
64
  * Optional `fetch` implementation override (mainly for tests). Defaults
64
- * to global `fetch`.
65
+ * to global `fetch`. Overrides must honor `redirect: "error"` to preserve
66
+ * the configured HTTPS signing-key authority.
65
67
  */
66
68
  fetch?: typeof fetch;
67
69
  /**
@@ -79,6 +81,8 @@ export interface JwkOptions {
79
81
  * algorithms at construction time, requires every token to carry a `kid`
80
82
  * header, matches that `kid` against the JWKS, and cross-checks the JWT
81
83
  * header `alg` against the JWK's own `alg` (when present).
84
+ * URL sources are fetched with redirects disabled; redirecting refreshes
85
+ * follow the existing last-good-key grace policy, never installing new keys.
82
86
  *
83
87
  * @example
84
88
  * ```ts
package/dist/jwk.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `alg`, the two MUST agree (RFC 7517 §4.4 cross-check).
17
17
  * - `exp` / `nbf` / `iat` validated on every verify.
18
18
  * - JWKS URLs MUST be `https://` (refused at construction otherwise).
19
+ * Redirects are refused so the HTTPS/key-authority boundary is not bypassed.
19
20
  * - Optional `verify(payload, ctx)` revalidation hook for
20
21
  * revocation lists, token-version counters, etc.
21
22
  *
@@ -93,6 +94,7 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
93
94
  try {
94
95
  const res = await fetchImpl(source, {
95
96
  headers: { accept: "application/json" },
97
+ redirect: "error",
96
98
  });
97
99
  if (!res.ok) {
98
100
  throw new Error(`jwk(): JWKS fetch failed with status ${res.status}.`);
@@ -144,6 +146,8 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
144
146
  * algorithms at construction time, requires every token to carry a `kid`
145
147
  * header, matches that `kid` against the JWKS, and cross-checks the JWT
146
148
  * header `alg` against the JWK's own `alg` (when present).
149
+ * URL sources are fetched with redirects disabled; redirecting refreshes
150
+ * follow the existing last-good-key grace policy, never installing new keys.
147
151
  *
148
152
  * @example
149
153
  * ```ts
package/dist/jwt.d.ts CHANGED
@@ -34,7 +34,11 @@ export interface JwtVerified {
34
34
  /** Decoded claims payload after signature and time-claim checks passed. */
35
35
  readonly payload: Record<string, unknown>;
36
36
  }
37
- /** Key material accepted by the signer/verifier. */
37
+ /**
38
+ * Key material accepted by the signer/verifier. Imported keys must match the
39
+ * selected algorithm's family, hash and curve. Every HMAC key format must meet
40
+ * the 32-byte minimum, and every RSA key must have a 2048-bit modulus or larger.
41
+ */
38
42
  export type JwtKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
39
43
  /** Options for {@link createJwtSigner}. */
40
44
  export interface JwtSignerOptions {
@@ -116,11 +120,14 @@ export interface JwtVerifierOptions {
116
120
  * function refuses payloads without an `exp` claim (unless
117
121
  * `acknowledgeNoExp: true` was set at construction outside production) and
118
122
  * refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
123
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
124
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
119
125
  *
120
126
  * @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
121
127
  * @returns An object whose `sign(payload)` resolves to the compact JWS string.
122
128
  * @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
123
129
  * missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
130
+ * `sign()` also rejects imported keys that violate algorithm or strength policy.
124
131
  * @since 0.21.0
125
132
  */
126
133
  export declare function createJwtSigner(opts: JwtSignerOptions): {
@@ -132,12 +139,15 @@ export declare function createJwtSigner(opts: JwtSignerOptions): {
132
139
  * `alg: "none"` and any token whose header `alg` is not in the allowlist;
133
140
  * refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
134
141
  * a JWK / JWKS-shaped key source (the documented confused-deputy attack).
142
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
143
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
135
144
  *
136
145
  * @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
137
146
  * @returns An object whose `verify(token)` resolves to the decoded
138
147
  * {@link JwtVerified} or rejects with {@link JwtError}.
139
148
  * @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
140
149
  * in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
150
+ * `verify()` also rejects imported keys that violate algorithm or strength policy.
141
151
  * @since 0.21.0
142
152
  */
143
153
  export declare function createJwtVerifier(opts: JwtVerifierOptions): {
package/dist/jwt.js CHANGED
@@ -158,7 +158,7 @@ async function importKey(alg, material, usage) {
158
158
  const params = algParams(alg);
159
159
  const c = getCrypto();
160
160
  if (isCryptoKey(material)) {
161
- assertRsaModulusFloor(alg, material);
161
+ assertKeyPolicy(alg, material);
162
162
  return material;
163
163
  }
164
164
  if (material instanceof Uint8Array) {
@@ -185,11 +185,24 @@ async function importKey(alg, material, usage) {
185
185
  ? { name: "RSASSA-PKCS1-v1_5", hash: params.hash }
186
186
  : { name: "Ed25519" };
187
187
  const imported = await c.subtle.importKey("jwk", material, importAlgorithm, false, [usage]);
188
- assertRsaModulusFloor(alg, imported);
188
+ assertKeyPolicy(alg, imported);
189
189
  return imported;
190
190
  }
191
191
  throw new JwtError("invalid_key", "jwt(): unsupported key material.");
192
192
  }
193
+ function assertKeyPolicy(alg, key) {
194
+ const params = algParams(alg);
195
+ const algorithm = key.algorithm;
196
+ if (algorithm.name !== params.name ||
197
+ (params.hash !== undefined && params.name !== "ECDSA" && algorithm.hash?.name !== params.hash) ||
198
+ (params.namedCurve !== undefined && algorithm.namedCurve !== params.namedCurve)) {
199
+ throw new JwtError("key_algorithm_mismatch", `jwt(): key algorithm does not match ${alg}; the key family, hash and curve must match the declared JWT algorithm.`);
200
+ }
201
+ if (params.name === "HMAC" && (!Number.isFinite(algorithm.length) || algorithm.length < MIN_HS_KEY_BYTES * 8)) {
202
+ throw new JwtError("weak_hs_secret", `jwt(): ${alg} secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2).`);
203
+ }
204
+ assertRsaModulusFloor(alg, key);
205
+ }
193
206
  /**
194
207
  * Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
195
208
  * Only applies to `RS*` / `PS*` algorithms — non-RSA keys are ignored. The
@@ -224,11 +237,14 @@ function buildSignAlgorithm(alg) {
224
237
  * function refuses payloads without an `exp` claim (unless
225
238
  * `acknowledgeNoExp: true` was set at construction outside production) and
226
239
  * refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
240
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
241
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
227
242
  *
228
243
  * @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
229
244
  * @returns An object whose `sign(payload)` resolves to the compact JWS string.
230
245
  * @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
231
246
  * missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
247
+ * `sign()` also rejects imported keys that violate algorithm or strength policy.
232
248
  * @since 0.21.0
233
249
  */
234
250
  export function createJwtSigner(opts) {
@@ -338,12 +354,15 @@ function normalizeStringSet(value) {
338
354
  * `alg: "none"` and any token whose header `alg` is not in the allowlist;
339
355
  * refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
340
356
  * a JWK / JWKS-shaped key source (the documented confused-deputy attack).
357
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
358
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
341
359
  *
342
360
  * @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
343
361
  * @returns An object whose `verify(token)` resolves to the decoded
344
362
  * {@link JwtVerified} or rejects with {@link JwtError}.
345
363
  * @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
346
364
  * in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
365
+ * `verify()` also rejects imported keys that violate algorithm or strength policy.
347
366
  * @since 0.21.0
348
367
  */
349
368
  export function createJwtVerifier(opts) {
package/dist/mtls.d.ts CHANGED
@@ -128,6 +128,8 @@ export interface PeerCertificateLike {
128
128
  * Normalize a Node `getPeerCertificate(true)` result into a
129
129
  * {@link ClientCertificate}. Returns `undefined` for the empty object Node
130
130
  * returns when the peer presented no certificate.
131
+ * Quoted SAN values are decoded without treating their embedded commas as
132
+ * identity separators. Malformed SAN lists yield no identities for allowlists.
131
133
  *
132
134
  * @param raw - The structured peer-certificate object from the TLS socket.
133
135
  * @param verified - Whether the socket reported `authorized === true` (the
@@ -220,6 +222,8 @@ export interface ClientCertAuthOptions {
220
222
  /**
221
223
  * If set, the certificate's SHA-256 fingerprint must match one of these
222
224
  * (compared in constant time; colons/spaces and case are ignored).
225
+ * An empty list denies every certificate; omit this option to disable
226
+ * fingerprint restrictions while retaining the other certificate checks.
223
227
  */
224
228
  allowFingerprints?: readonly string[];
225
229
  /**
package/dist/mtls.js CHANGED
@@ -68,6 +68,8 @@ export function getClientCertificate(request) {
68
68
  * Normalize a Node `getPeerCertificate(true)` result into a
69
69
  * {@link ClientCertificate}. Returns `undefined` for the empty object Node
70
70
  * returns when the peer presented no certificate.
71
+ * Quoted SAN values are decoded without treating their embedded commas as
72
+ * identity separators. Malformed SAN lists yield no identities for allowlists.
71
73
  *
72
74
  * @param raw - The structured peer-certificate object from the TLS socket.
73
75
  * @param verified - Whether the socket reported `authorized === true` (the
@@ -148,13 +150,58 @@ function parseCertDate(value) {
148
150
  function parseNodeSubjectAltName(san) {
149
151
  if (typeof san !== "string" || san.length === 0)
150
152
  return [];
151
- // Node renders SANs as `DNS:a, IP Address:1.2.3.4, URI:spiffe://...`.
152
153
  const out = [];
153
- for (const piece of san.split(",")) {
154
+ const quoted = san.includes('"');
155
+ const pieces = quoted ? [] : san.split(",");
156
+ if (quoted) {
157
+ let start = 0;
158
+ let inQuotes = false;
159
+ let escaped = false;
160
+ for (let index = 0; index < san.length; index++) {
161
+ const char = san[index];
162
+ if (escaped) {
163
+ escaped = false;
164
+ }
165
+ else if (inQuotes && char === "\\") {
166
+ escaped = true;
167
+ }
168
+ else if (char === '"') {
169
+ inQuotes = !inQuotes;
170
+ }
171
+ else if (char === "," && !inQuotes) {
172
+ pieces.push(san.slice(start, index));
173
+ start = index + 1;
174
+ }
175
+ }
176
+ if (inQuotes || escaped)
177
+ return [];
178
+ pieces.push(san.slice(start));
179
+ }
180
+ for (const piece of pieces) {
154
181
  const trimmed = piece.trim();
155
182
  if (trimmed.length === 0)
156
183
  continue;
157
- out.push(trimmed.replace(/^IP Address:/i, "IP:"));
184
+ const colon = trimmed.indexOf(":");
185
+ if (colon < 1)
186
+ return [];
187
+ if (!quoted) {
188
+ out.push(trimmed.replace(/^IP Address:/i, "IP:"));
189
+ continue;
190
+ }
191
+ const type = trimmed.slice(0, colon);
192
+ let value = trimmed.slice(colon + 1);
193
+ if (value.startsWith('"')) {
194
+ try {
195
+ value = JSON.parse(value);
196
+ }
197
+ catch {
198
+ return [];
199
+ }
200
+ }
201
+ else if (quoted && value.includes('"')) {
202
+ return [];
203
+ }
204
+ out.push(`${type.toLowerCase() === "ip address" ? "IP" : type}:${value}`);
158
205
  }
159
206
  return out;
160
207
  }
@@ -330,7 +377,7 @@ export function clientCertAuth(opts = {}) {
330
377
  const message = opts.message ?? "Client certificate not permitted";
331
378
  const stateKey = opts.stateKey ?? "clientCertificate";
332
379
  const now = opts.now ?? Date.now;
333
- const allowFingerprints = (opts.allowFingerprints ?? []).map((f) => normalizeFingerprint(f) ?? "");
380
+ const allowFingerprints = opts.allowFingerprints?.map((f) => normalizeFingerprint(f) ?? "");
334
381
  const allowSubjectCNs = opts.allowSubjectCNs;
335
382
  const allowIssuerCNs = opts.allowIssuerCNs;
336
383
  const allowSANs = opts.allowSANs;
@@ -371,7 +418,7 @@ export function clientCertAuth(opts = {}) {
371
418
  if (allowIssuerCNs && !matchesAllowedCN(cert.issuerCN, allowIssuerCNs)) {
372
419
  throw new ForbiddenError(message);
373
420
  }
374
- if (allowFingerprints.length > 0 && !matchesFingerprint(cert, allowFingerprints)) {
421
+ if (allowFingerprints !== undefined && !matchesFingerprint(cert, allowFingerprints)) {
375
422
  throw new ForbiddenError(message);
376
423
  }
377
424
  if (allowSANs && !matchesSAN(cert.subjectAltNames, allowSANs)) {
@@ -355,6 +355,10 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
355
355
  * `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
356
356
  * failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
357
357
  * {@link ResponseCacheOptions.maxBodyBytes} are never cached.
358
+ * The byte cap is enforced while reading the response clone; exceeding it
359
+ * stops capture without waiting for EOF or consuming the client's branch.
360
+ * Replays require every scope aggregated from the route's requireScopes hooks;
361
+ * callers without those scopes continue to the normal authorization chain.
358
362
  *
359
363
  * A response that declares `Vary` is stored as a **variant**: the request's
360
364
  * values for those fields are recorded alongside it, and the entry is replayed
@@ -65,6 +65,8 @@
65
65
  * @since 0.37.0
66
66
  */
67
67
  import { markSchemaValidatedResponse } from "./internal-response.js";
68
+ import { readResponseBodyUpTo } from "./internal-body.js";
69
+ import { hasReplayScopes } from "./internal-replay.js";
68
70
  /** Internal `ctx.state` key carrying the pending cache key between hooks. */
69
71
  const PENDING_STATE_KEY = "__responseCachePending";
70
72
  /**
@@ -420,6 +422,10 @@ function isPromiseLike(value) {
420
422
  * `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
421
423
  * failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
422
424
  * {@link ResponseCacheOptions.maxBodyBytes} are never cached.
425
+ * The byte cap is enforced while reading the response clone; exceeding it
426
+ * stops capture without waiting for EOF or consuming the client's branch.
427
+ * Replays require every scope aggregated from the route's requireScopes hooks;
428
+ * callers without those scopes continue to the normal authorization chain.
423
429
  *
424
430
  * A response that declares `Vary` is stored as a **variant**: the request's
425
431
  * values for those fields are recorded alongside it, and the entry is replayed
@@ -522,6 +528,8 @@ export function responseCache(opts = {}) {
522
528
  }
523
529
  const hooks = {
524
530
  async beforeHandle(ctx) {
531
+ if (!hasReplayScopes(ctx))
532
+ return undefined;
525
533
  const method = ctx.request.method.toUpperCase();
526
534
  if (!methods.has(method))
527
535
  return undefined;
@@ -641,8 +649,8 @@ export function responseCache(opts = {}) {
641
649
  res.headers.set(statusHeaderName, "MISS");
642
650
  return undefined;
643
651
  }
644
- const buf = new Uint8Array(await res.clone().arrayBuffer());
645
- if (buf.byteLength > maxBodyBytes) {
652
+ const buf = await readResponseBodyUpTo(res.clone(), maxBodyBytes);
653
+ if (buf === null) {
646
654
  if (statusHeaderName)
647
655
  res.headers.set(statusHeaderName, "MISS");
648
656
  return undefined;
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:3be788cc-3073-595a-871d-593791c06327",
4
+ "serialNumber": "urn:uuid:e5e6316e-26ff-52ae-9520-4b82d88c8397",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-08-29T14:05:07.830Z",
7
+ "timestamp": "2026-09-08T07:08:54.894Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.3.1"
12
+ "version": "1.3.2"
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.3.1",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.3.2",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.3.1",
24
+ "version": "1.3.2",
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.3.1",
26
+ "purl": "pkg:npm/@daloyjs/core@1.3.2",
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.3.1",
49
+ "tagId": "swidtag--daloyjs-core-1.3.2",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.3.1",
51
+ "version": "1.3.2",
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.3.1",
60
+ "ref": "pkg:npm/@daloyjs/core@1.3.2",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.3.1",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.1-3be788cc-3073-595a-871d-593791c06327",
5
+ "name": "@daloyjs/core-1.3.2",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.2-e5e6316e-26ff-52ae-9520-4b82d88c8397",
7
7
  "creationInfo": {
8
- "created": "2026-08-29T14:05:07.830Z",
8
+ "created": "2026-09-08T07:08:54.894Z",
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.3.1",
19
+ "versionInfo": "1.3.2",
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.3.1"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.3.2"
31
31
  }
32
32
  ]
33
33
  }
@@ -252,6 +252,8 @@ export interface WebhookSenderOptions {
252
252
  * dead-letters a single {@link WebhookEvent}, resolving to a
253
253
  * {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
254
254
  * failure).
255
+ * Intermediate response bodies are cancelled before retry backoff without
256
+ * waiting for producer cancellation; the final response remains caller-owned.
255
257
  *
256
258
  * @example
257
259
  * ```ts
@@ -133,6 +133,8 @@ function randomId() {
133
133
  * dead-letters a single {@link WebhookEvent}, resolving to a
134
134
  * {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
135
135
  * failure).
136
+ * Intermediate response bodies are cancelled before retry backoff without
137
+ * waiting for producer cancellation; the final response remains caller-owned.
136
138
  *
137
139
  * @example
138
140
  * ```ts
@@ -277,6 +279,7 @@ export function createWebhookSender(options) {
277
279
  });
278
280
  if (!retryable)
279
281
  break;
282
+ void response.body?.cancel().catch(() => undefined);
280
283
  await sleep(delayMs);
281
284
  continue;
282
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
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 \u2014 distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -266,8 +266,8 @@
266
266
  "red-team:live": "node --import tsx red-team-live/run.ts",
267
267
  "red-team:live:mcp": "node --import tsx red-team-live/mcp-attacks.ts",
268
268
  "red-team:live:wave2": "node --import tsx red-team-live/skill-wave2-attacks.ts",
269
- "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
270
- "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",
269
+ "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
270
+ "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",
271
271
  "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit && tsc -p red-team-live/tsconfig.json --noEmit",
272
272
  "typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
273
273
  "format": "prettier --write .",