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

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.
@@ -874,9 +874,16 @@ class NodeWebSocketConnection {
874
874
  },
875
875
  onClose: (code, reason) => {
876
876
  if (this.readyState === WS_READY_STATE.OPEN) {
877
- // Echo close per RFC 6455 §5.5.1.
877
+ // Echo close per RFC 6455 §5.5.1 ("SHOULD use the same status code").
878
+ // A peer that closed with an *empty* payload surfaces as the 1005
879
+ // sentinel, which §7.4.1 forbids on the wire — echoing it produced a
880
+ // CLOSE(1005) that a conforming peer (and this framework's own
881
+ // decoder) must reject with 1002. An empty close is answered with an
882
+ // empty close.
878
883
  this.readyState = WS_READY_STATE.CLOSING;
879
- this._writeFrame(WS_OPCODE.CLOSE, encodeClosePayload(code, reason));
884
+ this._writeFrame(WS_OPCODE.CLOSE, code === WS_CLOSE_CODE.NO_STATUS_RECEIVED
885
+ ? new Uint8Array(0)
886
+ : encodeClosePayload(code, reason));
880
887
  }
881
888
  this.readyState = WS_READY_STATE.CLOSED;
882
889
  this._fireClose(code, reason);
@@ -11,7 +11,7 @@
11
11
  * The middleware is dependency-free and runtime-portable. It observes outgoing
12
12
  * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
13
  * status produced by *any* later middleware or handler, not just its own) and
14
- * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
14
+ * enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
15
15
  * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
16
  * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
17
  * implementation for multi-instance deployments.
@@ -19,7 +19,7 @@
19
19
  * @module
20
20
  * @since 0.37.0
21
21
  */
22
- import type { BaseContext, Hooks } from "./types.js";
22
+ import type { Hooks, IdentityGateContext } from "./types.js";
23
23
  /**
24
24
  * One client's auto-ban bookkeeping. A record tracks the current strike count
25
25
  * inside the rolling strike window, when that window expires, the timestamp the
@@ -128,8 +128,22 @@ export interface AutoBanOptions {
128
128
  * Derive the client identity from `ctx`, or `undefined` to skip the request
129
129
  * (fail-open — never banned, never counted). Defaults to the proxy-header
130
130
  * resolver when {@link trustProxyHeaders} is set.
131
+ *
132
+ * Called first in `preBody`, where the gate is immune to mount order (see
133
+ * {@link IdentityGateContext}). If it returns `undefined` there, it is called
134
+ * again in `beforeHandle` — by then `session()` and other `beforeHandle` layers
135
+ * have populated `ctx.state`, so a generator keyed on a resolved session works
136
+ * rather than silently disabling the ban. Requests enforced by that second
137
+ * attempt are order-sensitive again, because `beforeHandle` is the phase a
138
+ * `responseCache()` hit short-circuits; key off headers, params or query where
139
+ * you can and the `preBody` pass handles it. Returning `undefined` from *both*
140
+ * still skips the request.
141
+ *
142
+ * `ctx.body` is not available in either phase — `preBody` runs before parsing,
143
+ * and the type reflects that. Derive the key from the request line, headers, or
144
+ * state instead.
131
145
  */
132
- keyGenerator?: (ctx: BaseContext<any, any>) => string | undefined;
146
+ keyGenerator?: (ctx: IdentityGateContext) => string | undefined;
133
147
  /**
134
148
  * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
149
  * default because those headers are client-spoofable unless every request
@@ -152,6 +166,32 @@ export interface AutoBanOptions {
152
166
  * [1, 64]; validated at construction.
153
167
  */
154
168
  trustedHops?: number;
169
+ /**
170
+ * What to do when the default key generator cannot resolve a forwarded
171
+ * identity — the request carried no `X-Forwarded-For`, or a chain shorter than
172
+ * {@link trustedHops} declares.
173
+ *
174
+ * - `"peer"` (default) — fall back to the immediate TCP peer address, in its
175
+ * own `peer:` keyspace. The peer cannot be spoofed, and a request that
176
+ * skipped the declared proxy chain came *from* that peer, so strikes are
177
+ * attributed to the real origin of the traffic.
178
+ * - `"skip"` — never count and never ban such a request.
179
+ *
180
+ * `"peer"` is the default because `"skip"` is a silent bypass: an attacker who
181
+ * can reach the origin directly gets unlimited strikes simply by omitting a
182
+ * header. Choose `"skip"` only when unresolved requests are known-benign and
183
+ * arrive from a shared address — for instance a load balancer that does not
184
+ * always set `X-Forwarded-For`, where every such request would otherwise share
185
+ * the balancer's single `peer:` bucket and a few `401`s could ban the lot.
186
+ * Prefer fixing the proxy configuration over choosing `"skip"`.
187
+ *
188
+ * Ignored when {@link keyGenerator} is supplied — a custom generator owns its
189
+ * own unresolved-identity posture, and returning `undefined` from it still
190
+ * means skip.
191
+ *
192
+ * @since 1.0.0-rc.8
193
+ */
194
+ onUnresolvedIdentity?: "peer" | "skip";
155
195
  /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
156
196
  store?: AutoBanStore;
157
197
  /**
@@ -201,8 +241,12 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
201
241
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
202
242
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
203
243
  * a misconfiguration can never collapse every caller into one shared bucket and
204
- * ban the whole world at once. A request the key generator cannot attribute is
205
- * skipped (never counted, never banned).
244
+ * ban the whole world at once. When the default generator cannot resolve a
245
+ * forwarded identity no `X-Forwarded-For`, or a chain shorter than
246
+ * {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
247
+ * unspoofable TCP peer instead of being discarded; see
248
+ * {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
249
+ * returns `undefined` still skips the request.
206
250
  *
207
251
  * @example
208
252
  * ```ts
package/dist/auto-ban.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * The middleware is dependency-free and runtime-portable. It observes outgoing
12
12
  * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
13
  * status produced by *any* later middleware or handler, not just its own) and
14
- * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
14
+ * enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
15
15
  * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
16
  * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
17
  * implementation for multi-instance deployments.
@@ -20,7 +20,7 @@
20
20
  * @since 0.37.0
21
21
  */
22
22
  import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
- import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
23
+ import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
24
24
  const DEFAULT_WINDOW_MS = 10 * 60_000;
25
25
  const DEFAULT_MAX_STRIKES = 5;
26
26
  const DEFAULT_BAN_MS = 15 * 60_000;
@@ -90,9 +90,32 @@ function assertPositiveInteger(name, value) {
90
90
  * trusted proxy hops from the right of `X-Forwarded-For` (falling back to
91
91
  * `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
92
92
  * {@link resolveForwardedClientIp}.
93
+ *
94
+ * When the forwarded chain cannot satisfy the declaration,
95
+ * {@link resolveForwardedClientIp} returns `undefined` — correct for *identity*,
96
+ * because such a request never traversed the declared topology. For *abuse
97
+ * accounting* that answer used to mean "skip", which handed an attacker unlimited
98
+ * strikes for free: reach the origin directly, past the CDN that appends the
99
+ * header, and every failed credential attempt went uncounted.
100
+ *
101
+ * So the fallback is the immediate TCP peer, prefixed to keep it in its own
102
+ * keyspace. The peer address cannot be spoofed — it is the socket actually
103
+ * talking to the adapter — and in exactly the direct-to-origin case that
104
+ * produced the bypass, the peer *is* the attacker, so accounting becomes precise
105
+ * rather than absent. Set `onUnresolvedIdentity: "skip"` to restore the previous
106
+ * behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
107
+ * the right call.
93
108
  */
94
- function forwardedKey(hops) {
95
- return (ctx) => resolveForwardedClientIp(ctx.request, hops);
109
+ function forwardedKey(hops, peerFallback) {
110
+ return (ctx) => {
111
+ const forwarded = resolveForwardedClientIp(ctx.request, hops);
112
+ if (forwarded !== undefined)
113
+ return forwarded;
114
+ if (!peerFallback)
115
+ return undefined;
116
+ const peer = readRemoteAddress(ctx);
117
+ return peer === undefined ? undefined : `peer:${peer}`;
118
+ };
96
119
  }
97
120
  /**
98
121
  * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
@@ -103,8 +126,12 @@ function forwardedKey(hops) {
103
126
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
104
127
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
105
128
  * a misconfiguration can never collapse every caller into one shared bucket and
106
- * ban the whole world at once. A request the key generator cannot attribute is
107
- * skipped (never counted, never banned).
129
+ * ban the whole world at once. When the default generator cannot resolve a
130
+ * forwarded identity no `X-Forwarded-For`, or a chain shorter than
131
+ * {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
132
+ * unspoofable TCP peer instead of being discarded; see
133
+ * {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
134
+ * returns `undefined` still skips the request.
108
135
  *
109
136
  * @example
110
137
  * ```ts
@@ -150,12 +177,16 @@ export function autoBan(opts = {}) {
150
177
  }
151
178
  const watch = new Set(watchStatuses);
152
179
  const hops = resolveForwardedTrust("autoBan()", opts);
180
+ const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
181
+ if (onUnresolved !== "peer" && onUnresolved !== "skip") {
182
+ throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
183
+ }
153
184
  let keyOf;
154
185
  if (opts.keyGenerator) {
155
186
  keyOf = opts.keyGenerator;
156
187
  }
157
188
  else if (hops !== undefined) {
158
- keyOf = forwardedKey(hops);
189
+ keyOf = forwardedKey(hops, onUnresolved === "peer");
159
190
  }
160
191
  else {
161
192
  throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
@@ -175,23 +206,40 @@ export function autoBan(opts = {}) {
175
206
  store = shared;
176
207
  }
177
208
  const prefix = `${groupId}:`;
178
- return {
179
- async beforeHandle(ctx) {
180
- const identity = keyOf(ctx);
181
- if (identity === undefined)
182
- return undefined;
183
- const key = `${prefix}${identity}`;
184
- const state = ctx.state;
185
- state[STATE_KEY] = key;
186
- const record = await store.get(key);
187
- const now = Date.now();
188
- if (record && record.bannedUntilMs > now) {
189
- state[STATE_REJECTED] = true;
190
- if (banStatus === 403)
191
- throw new ForbiddenError(message);
192
- const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
193
- throw new TooManyRequestsError(retryAfter ? retry : undefined);
194
- }
209
+ /**
210
+ * Resolve the identity, stash the key for `onSend`, and reject an active ban.
211
+ *
212
+ * Shared by the `preBody` gate and the `beforeHandle` fallback below so both
213
+ * phases enforce identically. Returns `true` once an identity was found, so
214
+ * the fallback knows whether `preBody` already handled the request.
215
+ */
216
+ const enforce = async (ctx) => {
217
+ const identity = keyOf(ctx);
218
+ if (identity === undefined)
219
+ return false;
220
+ const key = `${prefix}${identity}`;
221
+ const state = ctx.state;
222
+ state[STATE_KEY] = key;
223
+ const record = await store.get(key);
224
+ const now = Date.now();
225
+ if (record && record.bannedUntilMs > now) {
226
+ state[STATE_REJECTED] = true;
227
+ if (banStatus === 403)
228
+ throw new ForbiddenError(message);
229
+ const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
230
+ throw new TooManyRequestsError(retryAfter ? retry : undefined);
231
+ }
232
+ return true;
233
+ };
234
+ const hooks = {
235
+ // `preBody`, not `beforeHandle`: the ban check must not be preemptable by an
236
+ // earlier `beforeHandle` middleware that short-circuits — a
237
+ // `responseCache()` HIT mounted above it would serve a banned client the
238
+ // cached body, so the ban would only ever apply to uncached routes.
239
+ // `preBody` always runs before any `beforeHandle`. Strike accounting stays in
240
+ // `onSend`, which observes the final status either way.
241
+ async preBody(ctx) {
242
+ await enforce(ctx);
195
243
  return undefined;
196
244
  },
197
245
  async onSend(res, ctx) {
@@ -227,4 +275,31 @@ export function autoBan(opts = {}) {
227
275
  return undefined;
228
276
  },
229
277
  };
278
+ // A custom `keyGenerator` may legitimately be unable to answer in `preBody` —
279
+ // typically because it reads state a `beforeHandle` layer resolves, such as
280
+ // `session()`. Without a second attempt that request gets no identity, so
281
+ // `onSend` finds no key and records no strike: the ban silently never arms.
282
+ // That is a worse failure than the ordering hazard the phase move closed, so
283
+ // retry in `beforeHandle` when, and only when, `preBody` came up empty.
284
+ //
285
+ // The default forwarded resolver never needs this — `onUnresolvedIdentity`
286
+ // already falls back to the TCP peer — so the hook is registered only for a
287
+ // custom generator and the common path pays nothing.
288
+ //
289
+ // Residual, deliberately accepted: a request enforced by this fallback IS
290
+ // order-sensitive again, because `beforeHandle` is the phase a
291
+ // `responseCache()` hit short-circuits. It applies solely to requests whose
292
+ // identity could not be resolved earlier, and enforcing late beats not
293
+ // enforcing at all. Resolve identity from headers/params/query where you can
294
+ // and `preBody` handles it, immune to mount order.
295
+ if (opts.keyGenerator) {
296
+ hooks.beforeHandle = async (ctx) => {
297
+ const state = ctx.state;
298
+ if (state[STATE_KEY] !== undefined)
299
+ return undefined; // preBody had it
300
+ await enforce(ctx);
301
+ return undefined;
302
+ };
303
+ }
304
+ return hooks;
230
305
  }
@@ -35,7 +35,7 @@
35
35
  * @module
36
36
  * @since 0.37.0
37
37
  */
38
- import type { BaseContext, Hooks } from "./types.js";
38
+ import type { Hooks, IdentityGateContext } from "./types.js";
39
39
  /**
40
40
  * Pluggable DNS resolver used to verify declared crawlers. The default
41
41
  * implementation lazily imports `node:dns/promises`; provide your own on
@@ -150,7 +150,7 @@ export interface BotGuardOptions {
150
150
  /**
151
151
  * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
152
152
  */
153
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
153
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
154
154
  /**
155
155
  * Custom DNS resolver for crawler verification. Defaults to a lazy
156
156
  * `node:dns/promises`-backed resolver.
package/dist/bot-guard.js CHANGED
@@ -260,7 +260,12 @@ export function botGuard(opts = {}) {
260
260
  throw new ForbiddenError(message);
261
261
  };
262
262
  return {
263
- async beforeHandle(ctx) {
263
+ // `preBody`, not `beforeHandle`: a bot gate that short-circuits from
264
+ // `beforeHandle` loses to any earlier `beforeHandle` middleware that returns
265
+ // a Response first — a `responseCache()` HIT mounted above it would hand a
266
+ // blocked scraper the cached body. `preBody` always precedes `beforeHandle`,
267
+ // so the gate holds regardless of mount order.
268
+ async preBody(ctx) {
264
269
  const ua = ctx.request.headers.get("user-agent") ?? "";
265
270
  // Allowlist wins over every other rule.
266
271
  if (allowed.length > 0 && matchesUserAgent(ua, allowed))
@@ -24,7 +24,7 @@
24
24
  * @module
25
25
  * @since 0.37.0
26
26
  */
27
- import type { BaseContext, Hooks } from "./types.js";
27
+ import type { Hooks, IdentityGateContext } from "./types.js";
28
28
  /**
29
29
  * Why a request was (or would have been) blocked by {@link geoBlock}.
30
30
  *
@@ -57,7 +57,7 @@ export interface GeoBlockDecision {
57
57
  *
58
58
  * @since 0.37.0
59
59
  */
60
- export type CountryFromContext = (ctx: BaseContext<any, any>) => string | undefined | null | Promise<string | undefined | null>;
60
+ export type CountryFromContext = (ctx: IdentityGateContext) => string | undefined | null | Promise<string | undefined | null>;
61
61
  /**
62
62
  * Operator-supplied IP → country lookup (e.g. a MaxMind reader). Return
63
63
  * `undefined`/`null`/`""` when the IP cannot be mapped to a country.
@@ -110,7 +110,7 @@ export interface GeoBlockOptions {
110
110
  * default Daloy fails closed because Web-standard `Request` objects do not
111
111
  * expose the peer address. Ignored when `resolveCountry` is used.
112
112
  */
113
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
113
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
114
114
  /**
115
115
  * Read `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Defaults
116
116
  * to `false` because those headers are client-spoofable unless every
package/dist/geo-block.js CHANGED
@@ -107,7 +107,13 @@ export function geoBlock(opts) {
107
107
  const hops = resolveForwardedTrust("geoBlock()", opts);
108
108
  const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
109
109
  return {
110
- async beforeHandle(ctx) {
110
+ // Runs in `preBody`, not `beforeHandle`. A `beforeHandle` hook that returns
111
+ // a Response ends the chain, so a country gate in that phase is preempted by
112
+ // any earlier `beforeHandle` middleware that short-circuits — a
113
+ // `responseCache()` HIT above it would serve a denied country the cached
114
+ // body. `preBody` always precedes `beforeHandle`, which makes the gate
115
+ // immune to mount order. See {@link geoBlock}'s security note.
116
+ async preBody(ctx) {
111
117
  let ip;
112
118
  let rawCountry;
113
119
  if (resolveCountry) {
@@ -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:
@@ -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,20 +226,69 @@ 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 }));
@@ -252,6 +342,7 @@ export function idempotency(opts = {}) {
252
342
  const replayHeaderName = (opts.replayHeaderName ?? "idempotency-replayed").toLowerCase();
253
343
  const methods = new Set((opts.methods ?? ["POST", "PUT", "PATCH", "DELETE"]).map((m) => m.toUpperCase()));
254
344
  const requireKey = opts.requireKey === true;
345
+ const allowUnscopedCallers = opts.allowUnscopedCallers === true;
255
346
  const cacheableStatus = opts.cacheableStatus ?? ((status) => status < 500);
256
347
  const ttlMs = ttlSeconds * 1_000;
257
348
  let store;
@@ -291,6 +382,44 @@ export function idempotency(opts = {}) {
291
382
  const scopeRaw = opts.scope
292
383
  ? await opts.scope(ctx)
293
384
  : (ctx.request.headers.get("authorization") ?? undefined);
385
+ // A credentialed request the default resolver cannot see is the dangerous
386
+ // case: cookie-session auth sends no `Authorization`, so `scopeRaw` is
387
+ // undefined, the namespace collapses to the shared one, and the retry
388
+ // fingerprint (method + path + body) becomes the only thing separating two
389
+ // users. Two users legitimately submit the same payload fingerprint
390
+ // identically — so client B replays client A's stored response, which is
391
+ // exactly the CWE-524 disclosure `scope` exists to prevent.
392
+ //
393
+ // Fail loudly rather than share the namespace. This mirrors
394
+ // `responseCache()`, which treats `Cookie` as a credential alongside
395
+ // `Authorization` for the same reason. Only fires when a cookie is present
396
+ // *and* nothing resolved, so the documented bearer-token path is untouched
397
+ // and a genuinely anonymous caller still shares the unscoped namespace.
398
+ //
399
+ // Deliberately NOT widened to "any cookie-bearing request". A resolvable
400
+ // scope can still be too coarse — a per-tenant API key with
401
+ // cookie-identified end users partitions per tenant while every user inside
402
+ // one shares a namespace — but that is indistinguishable from the far more
403
+ // common shape of a *per-user* bearer token arriving alongside incidental
404
+ // browser cookies (analytics, consent, CSRF), where the default is already
405
+ // correct. Keying the guard on the cookie's presence rejects that setup with
406
+ // a 500, so the check stays where the default is provably useless rather
407
+ // than merely possibly coarse. Callers whose `Authorization` is shared
408
+ // across users must pass `scope` — see the TSDoc on
409
+ // {@link IdempotencyOptions.scope}. Independently of scoping, `Set-Cookie`
410
+ // is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
411
+ // coarse namespace cannot escalate into handing over a live session.
412
+ if (!opts.scope &&
413
+ scopeRaw === undefined &&
414
+ !allowUnscopedCallers &&
415
+ ctx.request.headers.has("cookie")) {
416
+ throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
417
+ "The default scope reads the Authorization header, which this request does not carry, " +
418
+ "so every cookie-authenticated caller would share one idempotency namespace and could " +
419
+ "replay another caller's stored response (CWE-524). Pass " +
420
+ "`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
421
+ "`allowUnscopedCallers: true` if these callers are genuinely interchangeable.");
422
+ }
294
423
  const scopeTag = scopeRaw ? `${await sha256Hex(scopeRaw)}:` : "";
295
424
  const storeKey = `${keyPrefix}${scopeTag}${key}`;
296
425
  const now = Date.now();
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:91539bfb-7f26-5f45-8628-f7951991c7ca",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-29T20:03:05.244Z",
7
+ "timestamp": "2026-07-30T16:25:43.763Z",
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.8"
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.8",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.7",
24
+ "version": "1.0.0-rc.8",
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.8",
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.8",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.7",
51
+ "version": "1.0.0-rc.8",
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.8",
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.8",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.8-91539bfb-7f26-5f45-8628-f7951991c7ca",
7
7
  "creationInfo": {
8
- "created": "2026-07-29T20:03:05.244Z",
8
+ "created": "2026-07-30T16:25:43.763Z",
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.8",
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.8"
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
package/dist/waf.js CHANGED
@@ -188,15 +188,32 @@ const MAX_DECODE_PASSES = 2;
188
188
  */
189
189
  const CONTROL_CHAR_PROBE = /[\u0000-\u0008\u000e-\u001f\u007f]/;
190
190
  const CONTROL_CHAR_GLOBAL = /[\u0000-\u0008\u000e-\u001f\u007f]/g;
191
+ /**
192
+ * Non-ASCII probe used to skip {@link String.prototype.normalize} on the pure
193
+ * ASCII hot path. Fullwidth Latin (U+FF01–U+FF5E), compatibility ideographs,
194
+ * and other NFKC-collapsible code points only appear when this matches.
195
+ *
196
+ * Hoisted so the hot path neither re-creates the RegExp nor pays a
197
+ * literal-evaluation cost per inspected value.
198
+ */
199
+ const NON_ASCII_PROBE = /[^\x00-\x7F]/;
191
200
  /**
192
201
  * Expand a single inbound string into the variants the WAF should scan.
193
202
  *
194
203
  * Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
195
204
  * a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
196
205
  * form so comment-split keywords (e.g. OR wrapped in block comments) score
197
- * the same as the whitespace-separated form, and a control-character→space
206
+ * the same as the whitespace-separated form, a control-character→space
198
207
  * form so embedded NUL / escape bytes cannot split keywords past the
199
- * whitespace-anchored signatures (e.g. `1'%00OR%001=1` → `1' OR 1=1`).
208
+ * whitespace-anchored signatures (e.g. `1'%00OR%001=1` → `1' OR 1=1`), and
209
+ * an NFKC-normalized form so fullwidth / compatibility-homoglyph keywords
210
+ * (e.g. `union select`) score the same as their ASCII counterparts.
211
+ *
212
+ * The NFKC fold is applied to the decode chain *before* the `+` / comment /
213
+ * control-character passes, and its output joins that chain, so the transforms
214
+ * **compose**: a payload mixing homoglyphs with comment- or NUL-splitting
215
+ * (`'%00OR%00'1'='1`) still converges on the ASCII form the signatures
216
+ * anchor on. Closing each evasion only in isolation leaves the combination open.
200
217
  *
201
218
  * Scanning variants is pure defense-in-depth: the handler still receives
202
219
  * whatever the framework's single-decode path produced. Each variant is
@@ -228,6 +245,25 @@ function inspectionVariants(value, maxValueLength) {
228
245
  }
229
246
  // Snapshot before secondary transforms so we only expand the decode chain.
230
247
  const decodedChain = out.slice();
248
+ // Fold compatibility characters FIRST, and extend the chain with the folded
249
+ // forms, so the secondary transforms below run on them too. Order matters:
250
+ // pushing the NFKC form after the loop (or without extending `decodedChain`)
251
+ // leaves each evasion closed only in isolation, and composing two of them
252
+ // reopens the hole — `'%00OR%00'1'='1` folds to a NUL-split ASCII
253
+ // tautology that the control-char pass would catch, and control-strips to a
254
+ // fullwidth tautology that the fold would catch, but neither variant is ever
255
+ // subjected to the other transform. Extending the chain makes the passes
256
+ // compose, so any combination of fold + decode + comment/control/`+`
257
+ // splitting converges on the same ASCII form the signatures anchor on.
258
+ for (const v of out.slice()) {
259
+ if (NON_ASCII_PROBE.test(v)) {
260
+ const nfkc = v.normalize("NFKC");
261
+ if (nfkc !== v) {
262
+ push(nfkc);
263
+ decodedChain.push(nfkc);
264
+ }
265
+ }
266
+ }
231
267
  for (const v of decodedChain) {
232
268
  if (v.includes("+"))
233
269
  push(v.replace(/\+/g, " "));
@@ -20,13 +20,24 @@ export declare const WS_OPCODE: {
20
20
  readonly PING: 9;
21
21
  readonly PONG: 10;
22
22
  };
23
- /** Common RFC 6455 / IANA close codes. */
23
+ /**
24
+ * Common RFC 6455 / IANA close codes.
25
+ *
26
+ * `NO_STATUS_RECEIVED` (1005) and `ABNORMAL_CLOSURE` (1006) are **receive-only
27
+ * sentinels**: RFC 6455 §7.4.1 reserves them for reporting a local condition to
28
+ * the application and forbids them in a CLOSE frame on the wire. Passing either
29
+ * to `close()` or {@link encodeClosePayload} throws
30
+ * {@link WebSocketProtocolError} — to close with no status code, send an empty
31
+ * payload instead.
32
+ */
24
33
  export declare const WS_CLOSE_CODE: {
25
34
  readonly NORMAL_CLOSURE: 1000;
26
35
  readonly GOING_AWAY: 1001;
27
36
  readonly PROTOCOL_ERROR: 1002;
28
37
  readonly UNSUPPORTED_DATA: 1003;
38
+ /** Receive-only sentinel — never send this on the wire. */
29
39
  readonly NO_STATUS_RECEIVED: 1005;
40
+ /** Receive-only sentinel — never send this on the wire. */
30
41
  readonly ABNORMAL_CLOSURE: 1006;
31
42
  readonly INVALID_PAYLOAD: 1007;
32
43
  readonly POLICY_VIOLATION: 1008;
@@ -479,13 +490,37 @@ export declare function encodeFrame(opts: {
479
490
  payload?: Uint8Array;
480
491
  mask?: boolean;
481
492
  }): Uint8Array;
493
+ /**
494
+ * Whether `code` may legally appear in a CLOSE frame on the wire per
495
+ * RFC 6455 §7.1.6 / §7.4.
496
+ *
497
+ * Valid: `1000`–`1014` from the registered range, minus the three codes
498
+ * §7.4.1 reserves for local reporting only (`1004` unassigned, `1005`
499
+ * "no status received", `1006` "abnormal closure"), plus the `3000`–`4999`
500
+ * library/application range. Everything else — `0`–`999`, `1015`+, and all of
501
+ * `2000`–`2999` — is a protocol violation.
502
+ *
503
+ * Used on both sides of the codec so the framework can never *emit* a code it
504
+ * would reject on receipt.
505
+ *
506
+ * @param code - Candidate close status code.
507
+ * @returns `true` when the code is legal in a wire CLOSE frame.
508
+ * @since 1.0.0-rc.8
509
+ */
510
+ export declare function isValidWireCloseCode(code: number): boolean;
482
511
  /**
483
512
  * Encode a CLOSE frame payload (`uint16 code` + optional UTF-8 reason).
484
513
  *
485
- * @param code - RFC 6455 close status code, written big-endian.
514
+ * @param code - RFC 6455 close status code, written big-endian. Must be legal
515
+ * on the wire — see {@link isValidWireCloseCode}. To close with *no* status
516
+ * code, send an empty payload rather than passing `1005`.
486
517
  * @param reason - Optional human-readable reason. Defaults to `""`.
487
518
  * @returns The 2+N byte close payload.
488
- * @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes.
519
+ * @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes, or
520
+ * when `code` is not valid on the wire. Validating here as well as in
521
+ * {@link decodeClosePayload} keeps the codec symmetric: without it the
522
+ * framework could emit a frame its own decoder — and any conforming peer —
523
+ * must reject with `1002`.
489
524
  */
490
525
  export declare function encodeClosePayload(code: number, reason?: string): Uint8Array;
491
526
  /**
@@ -493,8 +528,16 @@ export declare function encodeClosePayload(code: number, reason?: string): Uint8
493
528
  *
494
529
  * @param payload - Unmasked close-frame payload bytes.
495
530
  * @returns The close `code` and decoded UTF-8 `reason`.
496
- * @throws WebSocketProtocolError when the payload is exactly 1 byte or the
497
- * reason is not valid UTF-8.
531
+ * @throws WebSocketProtocolError when the payload is exactly 1 byte, the
532
+ * reason is not valid UTF-8, or the status code is not valid on the wire —
533
+ * see {@link isValidWireCloseCode}. Without this check an endpoint would
534
+ * echo an attacker-supplied invalid code (e.g. 999) back in its own CLOSE
535
+ * frame instead of failing the connection with a 1002 protocol error.
536
+ *
537
+ * Note the asymmetry in the empty-payload case: a peer that closes with no
538
+ * status code yields the `1005` *sentinel*, which is deliberately not legal to
539
+ * send back. An endpoint echoing that close must reply with an empty payload,
540
+ * not with `1005`.
498
541
  */
499
542
  export declare function decodeClosePayload(payload: Uint8Array): {
500
543
  code: number;
package/dist/websocket.js CHANGED
@@ -53,13 +53,24 @@ export const WS_OPCODE = {
53
53
  PING: 0x9,
54
54
  PONG: 0xa,
55
55
  };
56
- /** Common RFC 6455 / IANA close codes. */
56
+ /**
57
+ * Common RFC 6455 / IANA close codes.
58
+ *
59
+ * `NO_STATUS_RECEIVED` (1005) and `ABNORMAL_CLOSURE` (1006) are **receive-only
60
+ * sentinels**: RFC 6455 §7.4.1 reserves them for reporting a local condition to
61
+ * the application and forbids them in a CLOSE frame on the wire. Passing either
62
+ * to `close()` or {@link encodeClosePayload} throws
63
+ * {@link WebSocketProtocolError} — to close with no status code, send an empty
64
+ * payload instead.
65
+ */
57
66
  export const WS_CLOSE_CODE = {
58
67
  NORMAL_CLOSURE: 1000,
59
68
  GOING_AWAY: 1001,
60
69
  PROTOCOL_ERROR: 1002,
61
70
  UNSUPPORTED_DATA: 1003,
71
+ /** Receive-only sentinel — never send this on the wire. */
62
72
  NO_STATUS_RECEIVED: 1005,
73
+ /** Receive-only sentinel — never send this on the wire. */
63
74
  ABNORMAL_CLOSURE: 1006,
64
75
  INVALID_PAYLOAD: 1007,
65
76
  POLICY_VIOLATION: 1008,
@@ -667,15 +678,45 @@ export function encodeFrame(opts) {
667
678
  }
668
679
  return out;
669
680
  }
681
+ /**
682
+ * Whether `code` may legally appear in a CLOSE frame on the wire per
683
+ * RFC 6455 §7.1.6 / §7.4.
684
+ *
685
+ * Valid: `1000`–`1014` from the registered range, minus the three codes
686
+ * §7.4.1 reserves for local reporting only (`1004` unassigned, `1005`
687
+ * "no status received", `1006` "abnormal closure"), plus the `3000`–`4999`
688
+ * library/application range. Everything else — `0`–`999`, `1015`+, and all of
689
+ * `2000`–`2999` — is a protocol violation.
690
+ *
691
+ * Used on both sides of the codec so the framework can never *emit* a code it
692
+ * would reject on receipt.
693
+ *
694
+ * @param code - Candidate close status code.
695
+ * @returns `true` when the code is legal in a wire CLOSE frame.
696
+ * @since 1.0.0-rc.8
697
+ */
698
+ export function isValidWireCloseCode(code) {
699
+ return ((code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) ||
700
+ (code >= 3000 && code <= 4999));
701
+ }
670
702
  /**
671
703
  * Encode a CLOSE frame payload (`uint16 code` + optional UTF-8 reason).
672
704
  *
673
- * @param code - RFC 6455 close status code, written big-endian.
705
+ * @param code - RFC 6455 close status code, written big-endian. Must be legal
706
+ * on the wire — see {@link isValidWireCloseCode}. To close with *no* status
707
+ * code, send an empty payload rather than passing `1005`.
674
708
  * @param reason - Optional human-readable reason. Defaults to `""`.
675
709
  * @returns The 2+N byte close payload.
676
- * @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes.
710
+ * @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes, or
711
+ * when `code` is not valid on the wire. Validating here as well as in
712
+ * {@link decodeClosePayload} keeps the codec symmetric: without it the
713
+ * framework could emit a frame its own decoder — and any conforming peer —
714
+ * must reject with `1002`.
677
715
  */
678
716
  export function encodeClosePayload(code, reason = "") {
717
+ if (!isValidWireCloseCode(code)) {
718
+ throw new WebSocketProtocolError(`Invalid close status code ${code}`);
719
+ }
679
720
  const reasonBytes = enc.encode(reason);
680
721
  if (reasonBytes.length > WS_MAX_CONTROL_PAYLOAD - 2) {
681
722
  throw new WebSocketProtocolError("Close reason exceeds 123 bytes");
@@ -691,8 +732,16 @@ export function encodeClosePayload(code, reason = "") {
691
732
  *
692
733
  * @param payload - Unmasked close-frame payload bytes.
693
734
  * @returns The close `code` and decoded UTF-8 `reason`.
694
- * @throws WebSocketProtocolError when the payload is exactly 1 byte or the
695
- * reason is not valid UTF-8.
735
+ * @throws WebSocketProtocolError when the payload is exactly 1 byte, the
736
+ * reason is not valid UTF-8, or the status code is not valid on the wire —
737
+ * see {@link isValidWireCloseCode}. Without this check an endpoint would
738
+ * echo an attacker-supplied invalid code (e.g. 999) back in its own CLOSE
739
+ * frame instead of failing the connection with a 1002 protocol error.
740
+ *
741
+ * Note the asymmetry in the empty-payload case: a peer that closes with no
742
+ * status code yields the `1005` *sentinel*, which is deliberately not legal to
743
+ * send back. An endpoint echoing that close must reply with an empty payload,
744
+ * not with `1005`.
696
745
  */
697
746
  export function decodeClosePayload(payload) {
698
747
  if (payload.length === 0)
@@ -700,6 +749,9 @@ export function decodeClosePayload(payload) {
700
749
  if (payload.length === 1)
701
750
  throw new WebSocketProtocolError("Close payload must be empty or ≥2 bytes");
702
751
  const code = (payload[0] << 8) | payload[1];
752
+ if (!isValidWireCloseCode(code)) {
753
+ throw new WebSocketProtocolError(`Invalid close status code ${code}`);
754
+ }
703
755
  const reason = new TextDecoder("utf-8", { fatal: true }).decode(payload.subarray(2));
704
756
  return { code, reason };
705
757
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-rc.7",
4
- "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops distributed via pnpm.",
3
+ "version": "1.0.0-rc.8",
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": {
7
7
  "access": "public"
@@ -258,7 +258,7 @@
258
258
  "red-team:live": "node --import tsx red-team-live/run.ts",
259
259
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
260
260
  "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",
261
- "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit",
261
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit && tsc -p red-team-live/tsconfig.json --noEmit",
262
262
  "typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
263
263
  "format": "prettier --write .",
264
264
  "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
@@ -297,10 +297,12 @@
297
297
  "verify:sbom": "node --import tsx scripts/verify-sbom.ts",
298
298
  "verify:breaking-changes": "node --import tsx scripts/verify-breaking-changes.ts",
299
299
  "verify:docs-links": "node --import tsx scripts/verify-docs-links.ts",
300
+ "verify:jsr-packaging": "npx --yes jsr publish --dry-run --allow-dirty",
300
301
  "scan:staged-secrets": "node --import tsx scripts/scan-staged-secrets.ts",
301
302
  "hooks:install": "node --import tsx scripts/install-git-hooks.ts",
302
303
  "audit": "pnpm audit --prod",
303
- "prepublishOnly": "pnpm build && pnpm gen:sbom"
304
+ "prepublishOnly": "pnpm build && pnpm gen:sbom",
305
+ "typecheck:red-team-live": "tsc -p red-team-live/tsconfig.json --noEmit"
304
306
  },
305
307
  "files": [
306
308
  "dist",