@daloyjs/core 1.0.0-rc.7 → 1.0.0-rc.9

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.
@@ -164,17 +164,71 @@ export interface IdempotencyOptions {
164
164
  * unauthenticated idempotent writes). Returning a stable per-user id is
165
165
  * preferable to the raw credential when tokens rotate between retries.
166
166
  *
167
+ * **Supply this whenever `Authorization` is not per-user.** The default assumes
168
+ * that header names one caller. If it is shared — a per-tenant API key, a
169
+ * service token, a gateway credential — with end users distinguished some other
170
+ * way (a session cookie, a subject claim your app reads), then the default
171
+ * partitions per *tenant* and every user inside one tenant shares a namespace.
172
+ * A caller who knows another's `Idempotency-Key` can then replay their stored
173
+ * response. This is not detectable from the header alone, which is why it is
174
+ * your call rather than a framework guard: a resolvable-but-coarse scope looks
175
+ * identical to a correctly per-user one. The cookie-only case *is* guarded —
176
+ * see {@link allowUnscopedCallers}.
177
+ *
178
+ * Independently of scoping, a replay never re-issues `Set-Cookie`, so a coarse
179
+ * namespace cannot escalate from disclosing a response body into handing over a
180
+ * live session.
181
+ *
167
182
  * @since 0.40.0
168
183
  */
169
184
  scope?: (ctx: BaseContext<any, any>) => string | undefined | Promise<string | undefined>;
185
+ /**
186
+ * Accept callers the default {@link scope} resolver cannot identify, letting
187
+ * them share one idempotency namespace. Default `false`.
188
+ *
189
+ * The guard this disables exists because a cookie-authenticated request
190
+ * carries no `Authorization` header, so the default resolver returns
191
+ * `undefined` and the namespace collapses. The retry fingerprint (method +
192
+ * path + body) is then all that separates two users — and two users
193
+ * submitting the same payload fingerprint identically, so one replays the
194
+ * other's stored response (CWE-524). Rather than share the namespace
195
+ * silently, a cookie-bearing request with no resolvable scope throws with an
196
+ * actionable message.
197
+ *
198
+ * Set this to `true` only when unscoped callers are genuinely interchangeable
199
+ * — a public, unauthenticated idempotent write where no response body is
200
+ * caller-specific. Supplying {@link scope} is almost always the right answer
201
+ * instead. A custom `scope` bypasses the guard entirely, including when it
202
+ * returns `undefined`, because an explicit resolver owns its own posture.
203
+ *
204
+ * @since 1.0.0-rc.8
205
+ */
206
+ allowUnscopedCallers?: boolean;
170
207
  }
171
208
  /**
172
209
  * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
173
- * deployments. Expired records are dropped on access; the map is opportunistically
174
- * pruned so it cannot grow without bound.
210
+ * deployments. Expired records are dropped on access.
211
+ *
212
+ * Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
213
+ * store is still at the cap the oldest surviving record is evicted. The sweep
214
+ * alone is not a bound — it only drops records that have *expired*, so a stream
215
+ * of unique keys inside the TTL grew the map linearly no matter how often it ran,
216
+ * with each entry pinning a stored response body.
217
+ *
218
+ * Evicting a live record can only cost exactly-once semantics for a retry that
219
+ * arrives after the eviction — it re-executes rather than replaying. That is the
220
+ * right trade against unbounded memory, but it is a reason to supply a shared
221
+ * (e.g. Redis) store for any deployment where the key volume approaches the cap.
175
222
  */
176
223
  export declare class MemoryIdempotencyStore implements IdempotencyStore {
177
224
  private readonly map;
225
+ private readonly maxEntries;
226
+ /**
227
+ * @param maxEntries - Maximum live records retained. Must be a positive
228
+ * integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
229
+ * @throws Error when `maxEntries` is not a positive integer.
230
+ */
231
+ constructor(maxEntries?: number);
178
232
  /**
179
233
  * @inheritDoc
180
234
  * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
@@ -192,6 +246,19 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
192
246
  /** Test helper. Number of stored records (including expired). */
193
247
  size(): number;
194
248
  }
249
+ /**
250
+ * Internal Symbol stamped on the hooks {@link idempotency} returns, so `App`'s
251
+ * boot guards can see where a stored-response replay sits in a route's effective
252
+ * hook chain.
253
+ *
254
+ * A replay is returned from `beforeHandle` and ends the hook chain, which means
255
+ * anything enforcing from the *same* phase but mounted later never runs. That is
256
+ * how a `rateLimit()` behind a replay stops counting. The marker lets the boot
257
+ * path refuse that order rather than leave the limiter silently infinite.
258
+ *
259
+ * @internal
260
+ */
261
+ export declare const IDEMPOTENCY_HOOK_MARKER: unique symbol;
195
262
  /**
196
263
  * Idempotency-key middleware. Mount it ahead of the routes that need
197
264
  * exactly-once semantics under retries (typically the payment / write
@@ -55,13 +55,44 @@ export function _resetSharedIdempotencyStoresForTests() {
55
55
  SHARED_IDEMPOTENCY_STORES.clear();
56
56
  }
57
57
  // ---------- Default store ----------
58
+ /**
59
+ * Default cap on live records held by {@link MemoryIdempotencyStore}.
60
+ *
61
+ * Each record can hold a base64 response body up to
62
+ * {@link IdempotencyOptions.maxResponseBytes} (1 MiB by default), so the cap is
63
+ * what actually bounds this store's footprint. Matches the size at which the
64
+ * store already attempted an expiry sweep.
65
+ */
66
+ const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
58
67
  /**
59
68
  * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
60
- * deployments. Expired records are dropped on access; the map is opportunistically
61
- * pruned so it cannot grow without bound.
69
+ * deployments. Expired records are dropped on access.
70
+ *
71
+ * Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
72
+ * store is still at the cap the oldest surviving record is evicted. The sweep
73
+ * alone is not a bound — it only drops records that have *expired*, so a stream
74
+ * of unique keys inside the TTL grew the map linearly no matter how often it ran,
75
+ * with each entry pinning a stored response body.
76
+ *
77
+ * Evicting a live record can only cost exactly-once semantics for a retry that
78
+ * arrives after the eviction — it re-executes rather than replaying. That is the
79
+ * right trade against unbounded memory, but it is a reason to supply a shared
80
+ * (e.g. Redis) store for any deployment where the key volume approaches the cap.
62
81
  */
63
82
  export class MemoryIdempotencyStore {
64
83
  map = new Map();
84
+ maxEntries;
85
+ /**
86
+ * @param maxEntries - Maximum live records retained. Must be a positive
87
+ * integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
88
+ * @throws Error when `maxEntries` is not a positive integer.
89
+ */
90
+ constructor(maxEntries = DEFAULT_MAX_IDEMPOTENCY_ENTRIES) {
91
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
92
+ throw new Error(`MemoryIdempotencyStore: maxEntries must be a positive integer; got ${String(maxEntries)}.`);
93
+ }
94
+ this.maxEntries = maxEntries;
95
+ }
65
96
  /**
66
97
  * @inheritDoc
67
98
  * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
@@ -71,9 +102,19 @@ export class MemoryIdempotencyStore {
71
102
  const existing = this.read(key);
72
103
  if (existing)
73
104
  return existing;
74
- this.map.set(key, record);
75
- if (this.map.size > 10_000)
105
+ if (this.map.size >= this.maxEntries) {
76
106
  this.prune();
107
+ // Still full: every record is live, so drop the oldest. `Map` iterates in
108
+ // insertion order, and `complete()` overwrites in place rather than
109
+ // re-inserting, so the first key is the least recently reserved.
110
+ while (this.map.size >= this.maxEntries) {
111
+ const oldest = this.map.keys().next();
112
+ if (oldest.done)
113
+ break;
114
+ this.map.delete(oldest.value);
115
+ }
116
+ }
117
+ this.map.set(key, record);
77
118
  return null;
78
119
  }
79
120
  /** @inheritDoc */
@@ -185,25 +226,87 @@ function validateKey(key, headerName, maxLen) {
185
226
  throw new BadRequestError(`${headerName} header contains invalid characters.`);
186
227
  }
187
228
  }
229
+ /**
230
+ * Response headers that must never be stored for replay.
231
+ *
232
+ * `set-cookie` is the security-critical entry. A stored response was replayed
233
+ * with every header the original produced, so a `Set-Cookie` issued to the first
234
+ * caller was re-issued to whoever replayed the record. Combined with any
235
+ * coarse-scoped namespace (a shared tenant `Authorization`, or an explicit
236
+ * `allowUnscopedCallers: true`) that promotes a body disclosure into handing over
237
+ * a live session — account takeover rather than data leakage. Even for a
238
+ * correctly scoped, genuinely same-caller retry it is wrong: the replay would
239
+ * resurrect a cookie the handler set once, silently rolling back a session
240
+ * rotation performed at login or on a privilege change.
241
+ *
242
+ * Unlike `responseCache()`, which refuses to store a `Set-Cookie` response at
243
+ * all, idempotency strips and stores: declining to store would release the
244
+ * reservation and let a retry re-execute the handler, which for a payment or
245
+ * order endpoint is the double-charge this middleware exists to prevent.
246
+ * Stripping keeps exactly-once semantics and drops only the header that must not
247
+ * be replayed.
248
+ *
249
+ * The remainder are hop-by-hop or per-request fields (RFC 9110 §7.6.1) that
250
+ * describe the one connection or the one request that populated the record;
251
+ * replaying `x-request-id` in particular hands every later caller a correlation
252
+ * id belonging to someone else's request.
253
+ */
254
+ const NEVER_REPLAYED_HEADERS = new Set([
255
+ "set-cookie",
256
+ "set-cookie2",
257
+ "age",
258
+ "connection",
259
+ "keep-alive",
260
+ "proxy-authenticate",
261
+ "proxy-authorization",
262
+ "te",
263
+ "trailer",
264
+ "transfer-encoding",
265
+ "upgrade",
266
+ "x-request-id",
267
+ ]);
188
268
  async function captureResponse(res, maxBytes) {
189
269
  const buf = new Uint8Array(await res.clone().arrayBuffer());
190
270
  if (buf.byteLength > maxBytes)
191
271
  return null;
192
272
  const headers = [];
193
273
  res.headers.forEach((value, name) => {
194
- headers.push([name, value]);
274
+ // Filter on capture, not on replay, so a credential never reaches the store
275
+ // in the first place — a shared (Redis) store would otherwise persist one
276
+ // caller's session cookie for the whole TTL.
277
+ if (!NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
278
+ headers.push([name, value]);
195
279
  });
196
280
  return { status: res.status, headers, body: buf.byteLength ? bytesToBase64(buf) : "" };
197
281
  }
198
282
  function buildReplayResponse(stored, replayHeaderName) {
199
283
  const headers = new Headers();
200
- for (const [name, value] of stored.headers)
284
+ for (const [name, value] of stored.headers) {
285
+ // Filtered on capture already; re-checked here so a record written by an
286
+ // older build — or by any other writer sharing the same Redis store — cannot
287
+ // replay a credential either.
288
+ if (NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
289
+ continue;
201
290
  headers.set(name, value);
291
+ }
202
292
  headers.set(replayHeaderName, "true");
203
293
  const body = stored.body ? base64ToBytes(stored.body) : null;
204
294
  return markSchemaValidatedResponse(new Response(body, { status: stored.status, headers }));
205
295
  }
206
296
  // ---------- Middleware ----------
297
+ /**
298
+ * Internal Symbol stamped on the hooks {@link idempotency} returns, so `App`'s
299
+ * boot guards can see where a stored-response replay sits in a route's effective
300
+ * hook chain.
301
+ *
302
+ * A replay is returned from `beforeHandle` and ends the hook chain, which means
303
+ * anything enforcing from the *same* phase but mounted later never runs. That is
304
+ * how a `rateLimit()` behind a replay stops counting. The marker lets the boot
305
+ * path refuse that order rather than leave the limiter silently infinite.
306
+ *
307
+ * @internal
308
+ */
309
+ export const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
207
310
  /**
208
311
  * Idempotency-key middleware. Mount it ahead of the routes that need
209
312
  * exactly-once semantics under retries (typically the payment / write
@@ -252,6 +355,7 @@ export function idempotency(opts = {}) {
252
355
  const replayHeaderName = (opts.replayHeaderName ?? "idempotency-replayed").toLowerCase();
253
356
  const methods = new Set((opts.methods ?? ["POST", "PUT", "PATCH", "DELETE"]).map((m) => m.toUpperCase()));
254
357
  const requireKey = opts.requireKey === true;
358
+ const allowUnscopedCallers = opts.allowUnscopedCallers === true;
255
359
  const cacheableStatus = opts.cacheableStatus ?? ((status) => status < 500);
256
360
  const ttlMs = ttlSeconds * 1_000;
257
361
  let store;
@@ -270,7 +374,7 @@ export function idempotency(opts = {}) {
270
374
  store = new MemoryIdempotencyStore();
271
375
  }
272
376
  const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
273
- return {
377
+ const hooks = {
274
378
  async beforeHandle(ctx) {
275
379
  const method = ctx.request.method.toUpperCase();
276
380
  if (!methods.has(method))
@@ -291,6 +395,44 @@ export function idempotency(opts = {}) {
291
395
  const scopeRaw = opts.scope
292
396
  ? await opts.scope(ctx)
293
397
  : (ctx.request.headers.get("authorization") ?? undefined);
398
+ // A credentialed request the default resolver cannot see is the dangerous
399
+ // case: cookie-session auth sends no `Authorization`, so `scopeRaw` is
400
+ // undefined, the namespace collapses to the shared one, and the retry
401
+ // fingerprint (method + path + body) becomes the only thing separating two
402
+ // users. Two users legitimately submit the same payload fingerprint
403
+ // identically — so client B replays client A's stored response, which is
404
+ // exactly the CWE-524 disclosure `scope` exists to prevent.
405
+ //
406
+ // Fail loudly rather than share the namespace. This mirrors
407
+ // `responseCache()`, which treats `Cookie` as a credential alongside
408
+ // `Authorization` for the same reason. Only fires when a cookie is present
409
+ // *and* nothing resolved, so the documented bearer-token path is untouched
410
+ // and a genuinely anonymous caller still shares the unscoped namespace.
411
+ //
412
+ // Deliberately NOT widened to "any cookie-bearing request". A resolvable
413
+ // scope can still be too coarse — a per-tenant API key with
414
+ // cookie-identified end users partitions per tenant while every user inside
415
+ // one shares a namespace — but that is indistinguishable from the far more
416
+ // common shape of a *per-user* bearer token arriving alongside incidental
417
+ // browser cookies (analytics, consent, CSRF), where the default is already
418
+ // correct. Keying the guard on the cookie's presence rejects that setup with
419
+ // a 500, so the check stays where the default is provably useless rather
420
+ // than merely possibly coarse. Callers whose `Authorization` is shared
421
+ // across users must pass `scope` — see the TSDoc on
422
+ // {@link IdempotencyOptions.scope}. Independently of scoping, `Set-Cookie`
423
+ // is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
424
+ // coarse namespace cannot escalate into handing over a live session.
425
+ if (!opts.scope &&
426
+ scopeRaw === undefined &&
427
+ !allowUnscopedCallers &&
428
+ ctx.request.headers.has("cookie")) {
429
+ 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, " +
431
+ "so every cookie-authenticated caller would share one idempotency namespace and could " +
432
+ "replay another caller's stored response (CWE-524). Pass " +
433
+ "`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
434
+ "`allowUnscopedCallers: true` if these callers are genuinely interchangeable.");
435
+ }
294
436
  const scopeTag = scopeRaw ? `${await sha256Hex(scopeRaw)}:` : "";
295
437
  const storeKey = `${keyPrefix}${scopeTag}${key}`;
296
438
  const now = Date.now();
@@ -354,6 +496,8 @@ export function idempotency(opts = {}) {
354
496
  return undefined;
355
497
  },
356
498
  };
499
+ hooks[IDEMPOTENCY_HOOK_MARKER] = true;
500
+ return hooks;
357
501
  }
358
502
  function isPromiseLike(value) {
359
503
  return (value !== null &&
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DA
12
12
  export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
13
13
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
14
14
  export type { DependencyHooks, DependencyOptions } from "./dependency.js";
15
- export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
15
+ export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, IdentityGateContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
16
16
  export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
17
17
  export type { ProblemDetails, ProblemRenderOptions, HttpErrorOptions } from "./errors.js";
18
18
  export type { StandardSchemaV1 } from "./schema.js";
@@ -101,5 +101,5 @@ export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACI
101
101
  export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
102
102
  export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
103
103
  export type { TenancyOptions, TenantResolver, TenantScopeOptions, SubdomainTenantOptions, PathPrefixTenantOptions, ClaimTenantOptions, UnresolvedStatus, InvalidStatus, } from "./tenancy.js";
104
- export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
104
+ export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
105
105
  export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
package/dist/index.js CHANGED
@@ -51,4 +51,4 @@ export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATI
51
51
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
52
52
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
53
53
  export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
54
- export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
54
+ export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
@@ -44,7 +44,7 @@
44
44
  * @module
45
45
  * @since 0.37.0
46
46
  */
47
- import type { BaseContext, Hooks } from "./types.js";
47
+ import type { Hooks, IdentityGateContext } from "./types.js";
48
48
  /**
49
49
  * A pluggable source of abusive IP / CIDR entries. Implementations return the
50
50
  * raw list each refresh; parsing, validation, and de-duplication are handled by
@@ -105,7 +105,7 @@ export interface IpReputationOptions {
105
105
  * Custom client-IP resolver. Overrides {@link IpReputationOptions.trustProxyHeaders}.
106
106
  * Defaults to failing open (no IP → not blocked).
107
107
  */
108
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
108
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
109
109
  /**
110
110
  * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
111
111
  * enable behind a trusted proxy that overwrites these headers.
@@ -222,7 +222,12 @@ export function ipReputation(opts) {
222
222
  const ready = opts.loadOnStart === false ? Promise.resolve() : refresh();
223
223
  return {
224
224
  hooks: {
225
- beforeHandle(ctx) {
225
+ // `preBody`, not `beforeHandle`: a denylist gate that short-circuits from
226
+ // `beforeHandle` is preempted by any earlier `beforeHandle` middleware that
227
+ // returns a Response first — a `responseCache()` HIT mounted above it would
228
+ // serve a denylisted address the cached body. `preBody` always runs first,
229
+ // so the feed holds regardless of mount order.
230
+ preBody(ctx) {
226
231
  const ip = resolveIp(ctx);
227
232
  if (!ip)
228
233
  return undefined; // fail-open on unresolved IP
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @since 0.19.0
9
9
  */
10
- import type { BaseContext, Hooks } from "./types.js";
10
+ import type { Hooks, IdentityGateContext } from "./types.js";
11
11
  /**
12
12
  * Options for {@link ipRestriction}. At least one of `allow` or `deny` must
13
13
  * be provided; supplying both runs deny-first then allow-otherwise (deny
@@ -36,7 +36,7 @@ export interface IpRestrictionOptions {
36
36
  * Provide a function to read adapter connection metadata or a trusted
37
37
  * custom header (e.g. a CDN-specific identifier).
38
38
  */
39
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
39
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
40
40
  /**
41
41
  * Read `X-Forwarded-For` / `X-Real-IP` in the default resolver. Defaults
42
42
  * to `false` because those headers are client-spoofable unless every
@@ -101,7 +101,7 @@ export interface IpMatcher {
101
101
  *
102
102
  * @param opts Allow/deny lists plus IP-resolution options; see
103
103
  * {@link IpRestrictionOptions}. Deny matches always win over allow.
104
- * @returns A {@link Hooks} object whose `beforeHandle` enforces the lists,
104
+ * @returns A {@link Hooks} object whose `preBody` hook enforces the lists,
105
105
  * failing closed (403) when the client IP cannot be resolved or parsed.
106
106
  * @throws Error at setup time when neither `allow` nor `deny` is provided,
107
107
  * or when a pattern is not a valid IP/CIDR.
@@ -29,7 +29,7 @@ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js"
29
29
  *
30
30
  * @param opts Allow/deny lists plus IP-resolution options; see
31
31
  * {@link IpRestrictionOptions}. Deny matches always win over allow.
32
- * @returns A {@link Hooks} object whose `beforeHandle` enforces the lists,
32
+ * @returns A {@link Hooks} object whose `preBody` hook enforces the lists,
33
33
  * failing closed (403) when the client IP cannot be resolved or parsed.
34
34
  * @throws Error at setup time when neither `allow` nor `deny` is provided,
35
35
  * or when a pattern is not a valid IP/CIDR.
@@ -45,7 +45,12 @@ export function ipRestriction(opts) {
45
45
  const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
46
46
  const message = opts.message ?? "IP address not permitted";
47
47
  return {
48
- beforeHandle(ctx) {
48
+ // `preBody`, not `beforeHandle`: a gate that returns a Response from
49
+ // `beforeHandle` can be preempted by any earlier `beforeHandle` middleware
50
+ // that short-circuits first — a `responseCache()` HIT mounted above it would
51
+ // serve a deny-listed address the cached body. `preBody` always runs first,
52
+ // so the allow/deny lists hold regardless of mount order.
53
+ preBody(ctx) {
49
54
  const raw = resolveIp(ctx);
50
55
  if (!raw)
51
56
  throw new ForbiddenError(message);
package/dist/mcp.d.ts CHANGED
@@ -35,32 +35,24 @@ export declare const MCP_PROTOCOL_VERSIONS: readonly string[];
35
35
  *
36
36
  * @since 1.0.0
37
37
  */
38
- export declare const MCP_META_KEYS: Readonly<{
39
- /** Protocol version for this request. Required on every modern request. */
38
+ export declare const MCP_META_KEYS: {
40
39
  readonly protocolVersion: "io.modelcontextprotocol/protocolVersion";
41
- /** Self-reported client name/version. Advisory only; never a security input. */
42
40
  readonly clientInfo: "io.modelcontextprotocol/clientInfo";
43
- /** Client capabilities relevant to this request. Required on every modern request. */
44
41
  readonly clientCapabilities: "io.modelcontextprotocol/clientCapabilities";
45
- /** Minimum log level the server should emit for this request. */
46
42
  readonly logLevel: "io.modelcontextprotocol/logLevel";
47
- /** Self-reported server name/version, returned in each modern result's `_meta`. */
48
43
  readonly serverInfo: "io.modelcontextprotocol/serverInfo";
49
- }>;
44
+ };
50
45
  /**
51
46
  * JSON-RPC error codes defined by the MCP specification in its reserved
52
47
  * `-32020`..`-32099` sub-range.
53
48
  *
54
49
  * @since 1.0.0
55
50
  */
56
- export declare const MCP_ERROR_CODES: Readonly<{
57
- /** HTTP headers disagree with the request body, or a required header is missing. */
51
+ export declare const MCP_ERROR_CODES: {
58
52
  readonly headerMismatch: -32020;
59
- /** The request needs a client capability the client did not declare. */
60
53
  readonly missingRequiredClientCapability: -32021;
61
- /** The requested protocol version is not implemented by this server. */
62
54
  readonly unsupportedProtocolVersion: -32022;
63
- }>;
55
+ };
64
56
  /**
65
57
  * Default maximum accepted JSON-RPC request body for a DaloyJS MCP endpoint.
66
58
  * The cap is intentionally small because MCP calls should carry parameters,
@@ -69,6 +69,17 @@ const ALLOWED_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
69
69
  // response-splitting via the `Location` header.
70
70
  // eslint-disable-next-line no-control-regex
71
71
  const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
72
+ // Reject *percent-encoded* C0 controls and DEL (`%00`-`%1F`, `%7F`) that arrive
73
+ // as literal text in the target (e.g. a still-encoded query value passed
74
+ // straight through). `CONTROL_CHAR_RE` only sees decoded characters, so an
75
+ // encoded tab would otherwise be written verbatim into the `Location` header.
76
+ // Per WHATWG URL that stays same-origin, but legacy WebKit stacks strip
77
+ // decoded tabs/newlines and can re-interpret `/%09/host` as protocol-relative
78
+ // — the trick behind historical Safari open-redirect CVEs. The range is
79
+ // deliberately narrow: UTF-8 continuation bytes live in `%80`-`%BF`, so
80
+ // legitimate percent-encoded non-ASCII paths (e.g. `/s%C3%A9arch`) are
81
+ // unaffected.
82
+ const ENCODED_CONTROL_CHAR_RE = /%(?:0[0-9a-f]|1[0-9a-f]|7f)/i;
72
83
  // Any code point above U+00FF (outside Latin-1). Such characters cannot be
73
84
  // written to a `Location` header — which is serialized as an ISO-8859-1
74
85
  // ByteString, so `Headers.set` throws a raw `TypeError` — and they cover the
@@ -91,6 +102,14 @@ function classify(target, allowedPaths, allowedOrigins) {
91
102
  if (CONTROL_CHAR_RE.test(target)) {
92
103
  return { ok: false, reason: "invalid-control-characters" };
93
104
  }
105
+ // Encoded control characters (`%09`, `%00`, …) arriving as literal text:
106
+ // spec-compliant browsers keep `/%09/host` same-origin, but legacy WebKit
107
+ // strips decoded tabs/newlines and can fold it into an origin-escaping
108
+ // protocol-relative URL. Refuse rather than rely on every user agent
109
+ // parsing it the WHATWG way.
110
+ if (ENCODED_CONTROL_CHAR_RE.test(target)) {
111
+ return { ok: false, reason: "invalid-control-characters" };
112
+ }
94
113
  // Protocol-relative (`//evil.com`) is the classic open-redirect bypass.
95
114
  if (target.startsWith("//"))
96
115
  return { ok: false, reason: "protocol-relative" };
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
4
+ "serialNumber": "urn:uuid:85b1efae-7e9c-5b6d-8081-af5e65efa14a",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-29T20:03:05.244Z",
7
+ "timestamp": "2026-08-01T11:35:20.075Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-rc.7"
12
+ "version": "1.0.0-rc.9"
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.7",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.7",
24
+ "version": "1.0.0-rc.9",
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.7",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
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.7",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.9",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.7",
51
+ "version": "1.0.0-rc.9",
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.7",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.9",
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.0.0-rc.7",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.7-01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
5
+ "name": "@daloyjs/core-1.0.0-rc.9",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.9-85b1efae-7e9c-5b6d-8081-af5e65efa14a",
7
7
  "creationInfo": {
8
- "created": "2026-07-29T20:03:05.244Z",
8
+ "created": "2026-08-01T11:35:20.075Z",
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.7",
19
+ "versionInfo": "1.0.0-rc.9",
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.7"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.9"
31
31
  }
32
32
  ]
33
33
  }
package/dist/types.d.ts CHANGED
@@ -305,6 +305,37 @@ export interface PreBodyContext<P extends string = string> {
305
305
  headers: Headers;
306
306
  };
307
307
  }
308
+ /**
309
+ * Context handed to the caller-supplied resolvers of the network-identity
310
+ * access-control gates — `geoBlock`, `ipRestriction`, `botGuard`, `autoBan` and
311
+ * `ipReputation`.
312
+ *
313
+ * Those gates enforce from {@link Hooks.preBody} so a `responseCache()` hit
314
+ * cannot preempt them (see SECURITY.md, "Hook phase decides what a
315
+ * short-circuiting middleware can preempt"). Their callbacks therefore run
316
+ * before body I/O and before *any* `beforeHandle` middleware, which means:
317
+ *
318
+ * - `body` is always `undefined` — nothing has been parsed yet.
319
+ * - `state` holds only what `onRequest` / an earlier `preBody` layer put there.
320
+ * In particular it does **not** hold anything `session()` or another
321
+ * `beforeHandle` layer resolves.
322
+ *
323
+ * The alias exists so that is a compile error instead of a runtime surprise.
324
+ * Typing these callbacks on the full {@link BaseContext} — whose `body` widens
325
+ * to `any` — let `(ctx) => ctx.body.email` type-check and then silently evaluate
326
+ * to `undefined` at run time. The consequence differed per gate and two of the
327
+ * five failed *silently*: `ipReputation` fails open on an unresolved IP, and
328
+ * `autoBan` stopped recording strikes altogether because it never got an
329
+ * identity to attribute them to.
330
+ *
331
+ * Resolve identity from `request` (headers, URL), `params`, `query`, or state a
332
+ * `preBody` layer set. If a value genuinely requires the parsed body, it cannot
333
+ * be a `preBody` gate input — see {@link "./auto-ban.js".AutoBanOptions.keyGenerator},
334
+ * which falls back to a later phase for exactly that case.
335
+ *
336
+ * @since 1.0.0-rc.8
337
+ */
338
+ export type IdentityGateContext = PreBodyContext<any>;
308
339
  /**
309
340
  * Lifecycle hooks fired around request handling. Hooks compose pipeline-style
310
341
  * — the global hooks (`AppOptions.hooks`) run first, then group hooks added