@patchstack/connect 0.3.29 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +37 -21
  2. package/README.md +24 -10
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1062 -189
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +419 -104
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +28 -9
  10. package/dist/index.d.ts +28 -9
  11. package/dist/index.js +412 -97
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1039 -235
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +663 -132
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +665 -134
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
  37. package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/protect.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  pulseAuthHeader
3
- } from "./chunk-MJOTFUDE.js";
3
+ } from "./chunk-LLKP5EJS.js";
4
4
 
5
5
  // src/protect/safe-origin.js
6
6
  function isSafeOrigin(value) {
@@ -131,6 +131,45 @@ function base64DecodeUtf8(value) {
131
131
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
132
132
  return new TextDecoder().decode(bytes);
133
133
  }
134
+ var NAMED_ENTITIES = {
135
+ lt: "<",
136
+ gt: ">",
137
+ amp: "&",
138
+ quot: '"',
139
+ apos: "'",
140
+ "#39": "'",
141
+ nbsp: "\xA0",
142
+ sol: "/",
143
+ bsol: "\\",
144
+ colon: ":",
145
+ lpar: "(",
146
+ rpar: ")",
147
+ equals: "=",
148
+ grave: "`",
149
+ Tab: " ",
150
+ NewLine: "\n",
151
+ semi: ";",
152
+ excl: "!",
153
+ num: "#",
154
+ dollar: "$",
155
+ percnt: "%",
156
+ ast: "*"
157
+ };
158
+ function decodeHtmlEntities(input) {
159
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
160
+ if (body[0] === "#") {
161
+ const hex = body[1] === "x" || body[1] === "X";
162
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
163
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
164
+ try {
165
+ return String.fromCodePoint(code);
166
+ } catch {
167
+ return whole;
168
+ }
169
+ }
170
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
171
+ });
172
+ }
134
173
  var RequestResolver = class {
135
174
  #req;
136
175
  #cookies;
@@ -253,6 +292,8 @@ var RequestResolver = class {
253
292
  } catch {
254
293
  return value;
255
294
  }
295
+ case "htmlentitydecode":
296
+ return decodeHtmlEntities(String(value));
256
297
  case "intval":
257
298
  return parseInt(String(value), 10) || 0;
258
299
  case "getArrayValues":
@@ -2106,25 +2147,428 @@ function toEnvelope(value) {
2106
2147
  return null;
2107
2148
  }
2108
2149
 
2109
- // src/protect/rules/validate.js
2110
- var LIMITS = {
2150
+ // src/protect/rules/contract.js
2151
+ var SOURCES = Object.freeze({
2152
+ raw: Object.freeze({ keyed: false }),
2153
+ all: Object.freeze({ keyed: false }),
2154
+ false: Object.freeze({ keyed: false }),
2155
+ rules: Object.freeze({ keyed: false, group: true }),
2156
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2157
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2158
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2159
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2160
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2161
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
2162
+ files: Object.freeze({
2163
+ keyed: true,
2164
+ keys: "any",
2165
+ wildcard: true,
2166
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2167
+ }),
2168
+ server: Object.freeze({
2169
+ keyed: true,
2170
+ keys: Object.freeze([
2171
+ "REQUEST_URI",
2172
+ "REQUEST_METHOD",
2173
+ "HTTP_USER_AGENT",
2174
+ "HTTP_REFERER",
2175
+ "HTTP_HOST",
2176
+ "REMOTE_ADDR",
2177
+ "ip",
2178
+ "CONTENT_TYPE",
2179
+ "CONTENT_LENGTH"
2180
+ ]),
2181
+ key_prefixes: Object.freeze(["HTTP_"])
2182
+ }),
2183
+ response: Object.freeze({
2184
+ keyed: true,
2185
+ keys: Object.freeze(["status", "body", "headers"]),
2186
+ key_prefixes: Object.freeze(["header."])
2187
+ }),
2188
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2189
+ });
2190
+ var MATCH_TYPES = Object.freeze([
2191
+ "array_in_array",
2192
+ "array_key_value",
2193
+ "contains",
2194
+ "cors_reflected",
2195
+ "cross_origin",
2196
+ "ctype_alnum",
2197
+ "ctype_digit",
2198
+ "ctype_special",
2199
+ "equals",
2200
+ "equals_strict",
2201
+ "hostname",
2202
+ "in_array",
2203
+ "inline_js_xss",
2204
+ "inline_xss",
2205
+ "internal_host",
2206
+ "is_numeric",
2207
+ "isset",
2208
+ "less_than",
2209
+ "more_than",
2210
+ "not_contains",
2211
+ "not_in_array",
2212
+ "off_origin",
2213
+ "quotes",
2214
+ "regex",
2215
+ "stripos"
2216
+ ]);
2217
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2218
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2219
+ "isset",
2220
+ "quotes",
2221
+ "is_numeric",
2222
+ "ctype_alnum",
2223
+ "ctype_digit",
2224
+ "ctype_special",
2225
+ "inline_xss",
2226
+ "inline_js_xss",
2227
+ "internal_host",
2228
+ "cross_origin",
2229
+ "off_origin",
2230
+ "cors_reflected"
2231
+ ]);
2232
+ var OPERAND_SHAPES = Object.freeze([
2233
+ "non-empty-string",
2234
+ "regex-literal",
2235
+ "string-or-string-list",
2236
+ "scalar",
2237
+ "string",
2238
+ "numeric",
2239
+ "scalar-or-non-empty-list",
2240
+ "non-empty-list",
2241
+ "object",
2242
+ "non-empty-object",
2243
+ "non-empty-string-list",
2244
+ "header-name-map",
2245
+ "header-name-list",
2246
+ "cookie-flags"
2247
+ ]);
2248
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2249
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2250
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2251
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2252
+ var MATCH_OPERANDS = Object.freeze({
2253
+ array_key_value: Object.freeze({
2254
+ required: Object.freeze(["key", "match"]),
2255
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2256
+ }),
2257
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2258
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2259
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2260
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2261
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2262
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2263
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
2264
+ // `array_in_array` inert and `not_in_array` true for every value.
2265
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2266
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2267
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2268
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2269
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2270
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
2271
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2272
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2273
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2274
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2275
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2276
+ });
2277
+ function operandShapeProblem(shape, value) {
2278
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2279
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2280
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2281
+ switch (shape) {
2282
+ case "non-empty-string":
2283
+ return isFilledString(value) ? null : "must be a non-empty string";
2284
+ case "regex-literal":
2285
+ if (!isFilledString(value)) return "must be a non-empty string";
2286
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2287
+ case "string-or-string-list":
2288
+ if (isFilledString(value)) return null;
2289
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2290
+ return "must be a non-empty string, or a non-empty list of them";
2291
+ case "scalar":
2292
+ return isScalar(value) ? null : "must be a string, number or boolean";
2293
+ case "string":
2294
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2295
+ case "numeric":
2296
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2297
+ case "scalar-or-non-empty-list":
2298
+ if (isScalar(value)) return null;
2299
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2300
+ return "must be a scalar, or a non-empty list of them";
2301
+ case "non-empty-list":
2302
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2303
+ case "header-name-map": {
2304
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2305
+ const names = Object.keys(value);
2306
+ if (names.length === 0) return "must be an object with at least one entry";
2307
+ const bad = names.find((name) => !isHeaderName(name));
2308
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2309
+ }
2310
+ case "header-name-list": {
2311
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2312
+ const bad = value.find((name) => !isHeaderName(name));
2313
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2314
+ }
2315
+ case "cookie-flags": {
2316
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2317
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2318
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2319
+ for (const flag of ["httpOnly", "secure"]) {
2320
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2321
+ return `flag "${flag}" must be true or false`;
2322
+ }
2323
+ }
2324
+ const sameSite = value.sameSite;
2325
+ const sameSiteOff = sameSite === false;
2326
+ if (sameSite !== void 0 && !sameSiteOff) {
2327
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2328
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2329
+ }
2330
+ }
2331
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2332
+ return "turns off every flag, so it would harden nothing";
2333
+ }
2334
+ return null;
2335
+ }
2336
+ case "object":
2337
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2338
+ case "non-empty-object":
2339
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2340
+ case "non-empty-string-list":
2341
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2342
+ default:
2343
+ return null;
2344
+ }
2345
+ }
2346
+ var MUTATIONS = Object.freeze([
2347
+ "base64_decode",
2348
+ "getArrayValues",
2349
+ "htmlentitydecode",
2350
+ "intval",
2351
+ "json_decode",
2352
+ "json_encode",
2353
+ "urldecode"
2354
+ ]);
2355
+ var PHASES = Object.freeze(["request", "response", "egress"]);
2356
+ var ACTIONS = Object.freeze([
2357
+ "block",
2358
+ "redact",
2359
+ "encode",
2360
+ "set-header",
2361
+ "remove-header",
2362
+ "harden-cookie"
2363
+ ]);
2364
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
2365
+ var RULE_PROPERTIES = Object.freeze([
2366
+ "id",
2367
+ "rule_id",
2368
+ "title",
2369
+ "category",
2370
+ "phase",
2371
+ "action",
2372
+ "rule_v2",
2373
+ "when",
2374
+ "message",
2375
+ "enforcement",
2376
+ "source_revision",
2377
+ "prefilter",
2378
+ "max_bytes",
2379
+ "bypass_limit",
2380
+ "set_headers",
2381
+ "remove_headers",
2382
+ "cookie_flags",
2383
+ "ensure"
2384
+ ]);
2385
+ var ACTION_PROPERTIES = Object.freeze({
2386
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2387
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2388
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2389
+ "set-header": Object.freeze({
2390
+ required: Object.freeze(["set_headers"]),
2391
+ defaulted: Object.freeze(["ensure"]),
2392
+ phases: Object.freeze(["response"]),
2393
+ shapes: Object.freeze({ set_headers: "header-name-map" })
2394
+ }),
2395
+ "remove-header": Object.freeze({
2396
+ required: Object.freeze(["remove_headers"]),
2397
+ defaulted: Object.freeze([]),
2398
+ phases: Object.freeze(["response"]),
2399
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
2400
+ }),
2401
+ "harden-cookie": Object.freeze({
2402
+ required: Object.freeze([]),
2403
+ defaulted: Object.freeze(["cookie_flags"]),
2404
+ phases: Object.freeze(["response"]),
2405
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2406
+ })
2407
+ });
2408
+ var LIMITS = Object.freeze({
2111
2409
  maxRules: 5e3,
2112
2410
  maxWhitelists: 2e3,
2113
2411
  maxConditionsPerRule: 250,
2114
2412
  maxNestingDepth: 12,
2115
2413
  maxRegexLength: 1e3,
2116
2414
  maxValueLength: 8192
2117
- };
2118
- var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
2119
- var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
2415
+ });
2416
+ var GROUP_PARAMETER = "rules";
2417
+ function isGroup(condition) {
2418
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2419
+ }
2420
+ function conditionShapeProblem(condition) {
2421
+ if (!condition || typeof condition !== "object") return "condition is not an object";
2422
+ const named = condition.parameter === GROUP_PARAMETER;
2423
+ const nested = Array.isArray(condition.rules);
2424
+ if (named && !nested) {
2425
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2426
+ }
2427
+ if (nested && !named) {
2428
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2429
+ }
2430
+ if (named && nested) {
2431
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2432
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2433
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
2434
+ }
2435
+ return null;
2436
+ }
2437
+ function parameterProblem(parameter) {
2438
+ if (parameter === void 0 || parameter === null) return null;
2439
+ if (Array.isArray(parameter)) {
2440
+ if (parameter.length === 0) return "parameter list is empty";
2441
+ for (const member of parameter) {
2442
+ const problem = parameterProblem(member);
2443
+ if (problem !== null) return problem;
2444
+ }
2445
+ return null;
2446
+ }
2447
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2448
+ if (parameter === GROUP_PARAMETER) {
2449
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2450
+ }
2451
+ const dot = parameter.indexOf(".");
2452
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
2453
+ const source = SOURCES[name];
2454
+ if (source === void 0) return `unknown parameter source "${name}"`;
2455
+ if (dot === -1) {
2456
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2457
+ }
2458
+ if (source.keyed !== true) return `source "${name}" takes no key`;
2459
+ const key = parameter.slice(dot + 1);
2460
+ if (key === "") return `source "${name}" needs a key after the dot`;
2461
+ return keyProblem(name, source, key);
2462
+ }
2463
+ function keyProblem(name, source, key) {
2464
+ if (key.endsWith("*") && source.wildcard !== true) {
2465
+ return `"${name}" does not fan out over a wildcard key`;
2466
+ }
2467
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2468
+ if (source.keys === "any") return null;
2469
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2470
+ for (const prefix of source.key_prefixes ?? []) {
2471
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2472
+ }
2473
+ const accepted = [
2474
+ ...Array.isArray(source.keys) ? source.keys : [],
2475
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2476
+ ].join(", ");
2477
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2478
+ }
2479
+ function matchProblem(match, parameter) {
2480
+ if (!match || typeof match !== "object") return "condition has no match object";
2481
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2482
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2483
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2484
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
2485
+ return `match type "${match.type}" needs a parameter`;
2486
+ }
2487
+ if (parameterless && parameter !== void 0 && parameter !== null) {
2488
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
2489
+ }
2490
+ const operands = MATCH_OPERANDS[match.type];
2491
+ if (operands) {
2492
+ for (const required of operands.required) {
2493
+ if (match[required] === void 0 || match[required] === null) {
2494
+ return `match type "${match.type}" needs "${required}"`;
2495
+ }
2496
+ }
2497
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2498
+ if (match[operand] === void 0) continue;
2499
+ const problem = operandShapeProblem(shape, match[operand]);
2500
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
2501
+ }
2502
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2503
+ return `match type "${match.type}" needs a value`;
2504
+ }
2505
+ return null;
2506
+ }
2507
+ function mutationsProblem(mutations) {
2508
+ if (mutations === void 0 || mutations === null) return null;
2509
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2510
+ for (const mutation of mutations) {
2511
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2512
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
2513
+ }
2514
+ }
2515
+ return null;
2516
+ }
2517
+ function whenProblem(when) {
2518
+ if (when === void 0 || when === null) return null;
2519
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2520
+ const keys = Object.keys(when);
2521
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2522
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2523
+ if (unknown.length > 0) {
2524
+ return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
2525
+ }
2526
+ if ("path" in when && !isNonEmptyString(when.path)) {
2527
+ return "when.path must be a non-empty string";
2528
+ }
2529
+ if ("method" in when) {
2530
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2531
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2532
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2533
+ }
2534
+ return null;
2535
+ }
2536
+ function isNonEmptyString(value) {
2537
+ return typeof value === "string" && value.trim() !== "";
2538
+ }
2539
+ function actionProblem(action, rule) {
2540
+ if (action === void 0 || action === null) return null;
2541
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2542
+ const spec = ACTION_PROPERTIES[action];
2543
+ for (const required of spec?.required ?? []) {
2544
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2545
+ return `action "${action}" needs "${required}"`;
2546
+ }
2547
+ }
2548
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2549
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2550
+ const problem = operandShapeProblem(shape, rule[property]);
2551
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2552
+ }
2553
+ const phase = rule?.phase ?? "request";
2554
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2555
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2556
+ }
2557
+ return null;
2558
+ }
2559
+
2560
+ // src/protect/rules/validate.js
2561
+ var LIMITS2 = LIMITS;
2562
+ var PHASES2 = new Set(PHASES);
2563
+ var ACTIONS2 = new Set(ACTIONS);
2120
2564
  function validateBundle(bundle, opts = {}) {
2121
2565
  const rejected = [];
2122
2566
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2123
2567
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2124
2568
  const firewall = [];
2125
2569
  for (const rule of inFirewall) {
2126
- if (firewall.length >= LIMITS.maxRules) {
2127
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
2570
+ if (firewall.length >= LIMITS2.maxRules) {
2571
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2128
2572
  continue;
2129
2573
  }
2130
2574
  const reason = ruleProblem(rule);
@@ -2133,8 +2577,8 @@ function validateBundle(bundle, opts = {}) {
2133
2577
  }
2134
2578
  const whitelists = [];
2135
2579
  for (const wl of inWhitelists) {
2136
- if (whitelists.length >= LIMITS.maxWhitelists) {
2137
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
2580
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2581
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2138
2582
  continue;
2139
2583
  }
2140
2584
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -2156,8 +2600,11 @@ function idOf(rule) {
2156
2600
  }
2157
2601
  function ruleProblem(rule) {
2158
2602
  if (!rule || typeof rule !== "object") return "not an object";
2159
- if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2160
- if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
2603
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2604
+ const actionReason = actionProblem(rule.action, rule);
2605
+ if (actionReason) return actionReason;
2606
+ const scopeReason = whenProblem(rule.when);
2607
+ if (scopeReason) return scopeReason;
2161
2608
  const capOverride = rule.max_bytes;
2162
2609
  if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2163
2610
  return conditionsProblem(rule.rule_v2);
@@ -2165,28 +2612,33 @@ function ruleProblem(rule) {
2165
2612
  function conditionsProblem(conditions, depth = 0) {
2166
2613
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2167
2614
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2168
- if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
2169
- if (conditions.length > LIMITS.maxConditionsPerRule) {
2170
- return `more than ${LIMITS.maxConditionsPerRule} conditions`;
2615
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2616
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2617
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2171
2618
  }
2172
2619
  for (const c of conditions) {
2173
- if (!c || typeof c !== "object") return "condition is not an object";
2174
- if (Array.isArray(c.rules)) {
2620
+ const shapeReason = conditionShapeProblem(c);
2621
+ if (shapeReason) return shapeReason;
2622
+ if (isGroup(c)) {
2175
2623
  const nested = conditionsProblem(c.rules, depth + 1);
2176
2624
  if (nested) return nested;
2177
2625
  continue;
2178
2626
  }
2627
+ const parameterReason = parameterProblem(c.parameter);
2628
+ if (parameterReason) return parameterReason;
2629
+ const mutationReason = mutationsProblem(c.mutations);
2630
+ if (mutationReason) return mutationReason;
2631
+ const matchReason = matchProblem(c.match, c.parameter);
2632
+ if (matchReason) return matchReason;
2179
2633
  const m = c.match;
2180
- if (!m || typeof m !== "object") return "condition has no match object";
2181
- if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
2182
2634
  if (m.type === "regex") {
2183
2635
  if (typeof m.value !== "string") return "regex match.value must be a string";
2184
- if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
2185
- } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
2186
- return `match.value longer than ${LIMITS.maxValueLength} chars`;
2636
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2637
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2638
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2187
2639
  }
2188
2640
  if (m.match) {
2189
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
2641
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2190
2642
  if (nested) return nested;
2191
2643
  }
2192
2644
  }
@@ -2214,64 +2666,67 @@ function reportRejections(rejected, options, label) {
2214
2666
  `${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
2215
2667
  ), "onError");
2216
2668
  }
2669
+ function fromSource(bundle, reason) {
2670
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
2671
+ }
2217
2672
  async function resolveRules(options, store, ctx = {}) {
2218
2673
  const timeoutMs = ctx.timeoutMs;
2219
2674
  if (options.siteUuid) {
2220
2675
  const prior = await store.read();
2221
2676
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
2222
2677
  const res = await client.getRules();
2223
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2678
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2224
2679
  if (res.success && !res.notModified) {
2225
2680
  const rejected = liveUpdateRejections(res, options);
2226
2681
  if (rejected.length > 0) {
2227
2682
  reportRejections(rejected, options, "rule update rejected");
2228
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2229
- if (options.rules) return normalizeBundle(options.rules, options);
2230
- return emptyBundle();
2683
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2684
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2685
+ return fromSource(emptyBundle(), "update rejected");
2231
2686
  }
2232
2687
  const bundle = normalizeBundle(res, options);
2233
2688
  await store.write({ bundle, etag: res.etag ?? null });
2234
- return bundle;
2689
+ return fromSource(bundle);
2235
2690
  }
2236
2691
  if (prior?.bundle) {
2237
2692
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2238
- return normalizeBundle(prior.bundle, options);
2693
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2239
2694
  }
2240
2695
  if (options.rules) {
2241
2696
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2242
- return normalizeBundle(options.rules, options);
2697
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
2243
2698
  }
2244
2699
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2245
- return emptyBundle();
2700
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2246
2701
  }
2247
2702
  if (options.token) {
2248
2703
  const prior = await store.read();
2249
2704
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2250
2705
  const res = await client.getRules();
2251
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2706
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2252
2707
  if (res.success && !res.notModified) {
2253
2708
  const rejected = liveUpdateRejections(res, options);
2254
2709
  if (rejected.length > 0) {
2255
2710
  reportRejections(rejected, options, "rule update rejected");
2256
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2257
- if (options.rules) return normalizeBundle(options.rules, options);
2258
- return emptyBundle();
2711
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2712
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2713
+ return fromSource(emptyBundle(), "update rejected");
2259
2714
  }
2260
2715
  const bundle = normalizeBundle(res, options);
2261
2716
  await store.write({ bundle, etag: res.etag ?? null });
2262
- return bundle;
2717
+ return fromSource(bundle);
2263
2718
  }
2264
2719
  if (prior?.bundle) {
2265
2720
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2266
- return normalizeBundle(prior.bundle, options);
2721
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2267
2722
  }
2268
2723
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2269
- return emptyBundle();
2724
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2270
2725
  }
2271
2726
  if (options.rules) {
2272
- return normalizeBundle(options.rules, options);
2727
+ return fromSource(normalizeBundle(options.rules, options));
2273
2728
  }
2274
- return emptyBundle();
2729
+ return fromSource(emptyBundle());
2275
2730
  }
2276
2731
  function normalizeBundle(b, options = {}) {
2277
2732
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2324,8 +2779,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
2324
2779
  const run = async () => {
2325
2780
  if (stopped) return;
2326
2781
  try {
2327
- await tick();
2328
- failures = 0;
2782
+ const status = await tick();
2783
+ if (status && status.ok === false) failures++;
2784
+ else failures = 0;
2329
2785
  } catch (err) {
2330
2786
  failures++;
2331
2787
  notify(onError, err, "onError");
@@ -2352,7 +2808,8 @@ function makeRefreshHandler(tick, secret) {
2352
2808
  if (provided !== secret) return new Response("forbidden", { status: 403 });
2353
2809
  let refreshed = true;
2354
2810
  try {
2355
- await tick();
2811
+ const status = await tick();
2812
+ if (status && status.ok === false) refreshed = false;
2356
2813
  } catch {
2357
2814
  refreshed = false;
2358
2815
  }
@@ -2380,6 +2837,12 @@ function ruleParameters(rule) {
2380
2837
  walk(rule?.rule_v2);
2381
2838
  return [...out];
2382
2839
  }
2840
+ function revisionOf(rule) {
2841
+ const revision = rule?.source_revision;
2842
+ if (typeof revision === "string" && revision !== "") return revision;
2843
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
2844
+ return null;
2845
+ }
2383
2846
  function routeOf(path) {
2384
2847
  if (typeof path !== "string" || path === "") return null;
2385
2848
  const cut = path.search(/[?#]/);
@@ -2388,16 +2851,30 @@ function routeOf(path) {
2388
2851
  function createDetectionReporter(opts) {
2389
2852
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
2390
2853
  if (!siteUuid) {
2391
- return { record() {
2392
- }, flush() {
2393
- }, stop() {
2394
- }, dropped: () => 0 };
2854
+ return {
2855
+ record() {
2856
+ },
2857
+ flush() {
2858
+ },
2859
+ stop() {
2860
+ },
2861
+ setRulesEtag() {
2862
+ },
2863
+ dropped: () => 0,
2864
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
2865
+ };
2395
2866
  }
2396
2867
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
2397
2868
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
2398
2869
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
2399
2870
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
2400
2871
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
2872
+ let rulesEtag = opts.rulesEtag ?? null;
2873
+ let sent = 0;
2874
+ let delivered = 0;
2875
+ let failed = 0;
2876
+ let droppedTotal = 0;
2877
+ let lastDeliveredAt = null;
2401
2878
  let queue = [];
2402
2879
  let timer = null;
2403
2880
  let stopped = false;
@@ -2411,6 +2888,8 @@ function createDetectionReporter(opts) {
2411
2888
  const batch = queue.splice(0, MAX_BATCH);
2412
2889
  const droppedWith = dropped;
2413
2890
  dropped = 0;
2891
+ droppedTotal += droppedWith;
2892
+ sent += batch.length;
2414
2893
  void (async () => {
2415
2894
  try {
2416
2895
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -2419,15 +2898,22 @@ function createDetectionReporter(opts) {
2419
2898
  "Content-Type": "application/json",
2420
2899
  Accept: "application/json",
2421
2900
  "User-Agent": "@patchstack/connect",
2422
- // Same credential path as the rules fetch, and unauthenticated when none resolves: the
2423
- // server accepts the UUID, and reporting must never hinge on getting a token.
2901
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
2902
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
2903
+ // why the runtime does not build a reporter when no credential resolves, rather than
2904
+ // posting into a 401.
2424
2905
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2425
2906
  },
2426
2907
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
2427
2908
  });
2428
- if (res && typeof res.then === "function") res.catch(() => {
2429
- });
2909
+ if (res && res.ok) {
2910
+ delivered += batch.length;
2911
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
2912
+ } else {
2913
+ failed += batch.length;
2914
+ }
2430
2915
  } catch {
2916
+ failed += batch.length;
2431
2917
  }
2432
2918
  })();
2433
2919
  };
@@ -2456,7 +2942,12 @@ function createDetectionReporter(opts) {
2456
2942
  // The state this detection was handled under, which is the whole point: `false` is a rule that
2457
2943
  // saw traffic it would have stopped.
2458
2944
  enforced: detection.mode === "block",
2459
- rules_etag: opts.rulesEtag ?? null,
2945
+ rules_etag: rulesEtag,
2946
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
2947
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
2948
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
2949
+ // bundle carried none.
2950
+ rule_revision: revisionOf(detection.rule),
2460
2951
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
2461
2952
  });
2462
2953
  if (queue.length >= MAX_BATCH) {
@@ -2470,7 +2961,24 @@ function createDetectionReporter(opts) {
2470
2961
  stopped = true;
2471
2962
  flush();
2472
2963
  },
2473
- dropped: () => dropped
2964
+ /**
2965
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
2966
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
2967
+ *
2968
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
2969
+ * the only moment the two are known to agree.
2970
+ *
2971
+ * @param {string | null | undefined} next
2972
+ */
2973
+ setRulesEtag(next) {
2974
+ rulesEtag = next ?? null;
2975
+ },
2976
+ dropped: () => dropped,
2977
+ /**
2978
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
2979
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
2980
+ */
2981
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
2474
2982
  };
2475
2983
  }
2476
2984
 
@@ -2735,22 +3243,32 @@ async function createProtection(options = {}) {
2735
3243
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2736
3244
  const pulseAuth = await resolvePulseAuth(options);
2737
3245
  if (options.siteUuid && !pulseAuth) {
2738
- const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates may be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
3246
+ const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates will be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
2739
3247
  notify(onError, new Error(message), "onError");
2740
3248
  console.warn(message);
2741
3249
  }
2742
3250
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3251
+ let detectionReporting = "off";
2743
3252
  if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
2744
- detections = createDetectionReporter({
2745
- siteUuid: options.siteUuid,
2746
- baseUrl: options.pulseRulesUrl,
2747
- pulseAuth,
2748
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
2749
- // it rather than to whatever is current when the report is read.
2750
- rulesEtag: (await store.read())?.etag ?? null,
2751
- fetchImpl: options.fetchImpl,
2752
- flushMs: options.detectionFlushMs
2753
- });
3253
+ if (!pulseAuth) {
3254
+ detectionReporting = "unavailable-no-credential";
3255
+ const message = "Patchstack: detection reporting is enabled for site " + options.siteUuid + " but no API credential resolved, so no report could be delivered. Reporting is off.";
3256
+ notify(onError, new Error(message), "onError");
3257
+ console.warn(message);
3258
+ } else {
3259
+ detectionReporting = "on";
3260
+ detections = createDetectionReporter({
3261
+ siteUuid: options.siteUuid,
3262
+ baseUrl: options.pulseRulesUrl,
3263
+ pulseAuth,
3264
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3265
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
3266
+ // see the refresh tick below.
3267
+ rulesEtag: (await store.read())?.etag ?? null,
3268
+ fetchImpl: options.fetchImpl,
3269
+ flushMs: options.detectionFlushMs
3270
+ });
3271
+ }
2754
3272
  }
2755
3273
  let mode = resolveMode(options, bundle);
2756
3274
  let requestRules;
@@ -3084,11 +3602,17 @@ async function createProtection(options = {}) {
3084
3602
  );
3085
3603
  };
3086
3604
  },
3087
- // Node / Connect middleware — buffers the body itself (request phase).
3605
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
3606
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
3607
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
3088
3608
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
3089
3609
  node(nodeOptions = {}) {
3090
3610
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
3091
3611
  return (req, res, next) => {
3612
+ if (req.readableEnded || req.body !== void 0) {
3613
+ screenNodeRequest(req, res, next, "", req.body);
3614
+ return;
3615
+ }
3092
3616
  const chunks = [];
3093
3617
  let size = 0;
3094
3618
  let overflow = false;
@@ -3106,38 +3630,41 @@ async function createProtection(options = {}) {
3106
3630
  });
3107
3631
  req.on("end", () => {
3108
3632
  if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3109
- const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3110
- let shaped;
3111
- let result;
3112
- try {
3113
- shaped = fromNodeRequest(req, rawBody);
3114
- result = engine.evaluate(shaped);
3115
- } catch (err) {
3116
- notify(onError, err, "onError");
3117
- return next();
3118
- }
3119
- decide(
3120
- "request",
3121
- result,
3122
- () => {
3123
- res.statusCode = 403;
3124
- if (isDocumentNavigation((n) => req.headers?.[n])) {
3125
- res.setHeader("content-type", "text/html; charset=utf-8");
3126
- res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3127
- } else {
3128
- res.setHeader("content-type", "application/json");
3129
- res.end(JSON.stringify(blockBody(result)));
3130
- }
3131
- },
3132
- () => {
3133
- if (req.body === void 0) req.body = shaped.body;
3134
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3135
- next();
3136
- },
3137
- nodeRequestMeta(req)
3138
- );
3633
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
3139
3634
  });
3140
3635
  };
3636
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
3637
+ let shaped;
3638
+ let result;
3639
+ try {
3640
+ shaped = fromNodeRequest(req, rawBody);
3641
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3642
+ result = engine.evaluate(shaped);
3643
+ } catch (err) {
3644
+ notify(onError, err, "onError");
3645
+ return next();
3646
+ }
3647
+ decide(
3648
+ "request",
3649
+ result,
3650
+ () => {
3651
+ res.statusCode = 403;
3652
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
3653
+ res.setHeader("content-type", "text/html; charset=utf-8");
3654
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3655
+ } else {
3656
+ res.setHeader("content-type", "application/json");
3657
+ res.end(JSON.stringify(blockBody(result)));
3658
+ }
3659
+ },
3660
+ () => {
3661
+ if (req.body === void 0) req.body = shaped.body;
3662
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3663
+ next();
3664
+ },
3665
+ nodeRequestMeta(req)
3666
+ );
3667
+ }
3141
3668
  }
3142
3669
  };
3143
3670
  if (options.egress) {
@@ -3158,7 +3685,7 @@ async function createProtection(options = {}) {
3158
3685
  let reporter = null;
3159
3686
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
3160
3687
  try {
3161
- ({ reportManifest: reporter } = await import("./refresh-manifest-VRBE6RH6.js"));
3688
+ ({ reportManifest: reporter } = await import("./refresh-manifest-ZSP76JWQ.js"));
3162
3689
  } catch (err) {
3163
3690
  notify(onError, err, "onError");
3164
3691
  }
@@ -3174,24 +3701,22 @@ async function createProtection(options = {}) {
3174
3701
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3175
3702
  mode = resolveMode(options, next);
3176
3703
  applyBundle(next);
3704
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3705
+ return next.source ?? { ok: true };
3177
3706
  };
3178
3707
  if (live) {
3179
3708
  protection.refresh = () => runRefreshTick();
3180
3709
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
3181
3710
  }
3182
- if (options.refreshMs > 0 && live) {
3183
- const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3184
- protection.stopRefresh = () => {
3185
- loop.stop();
3186
- firewallLog?.stop();
3187
- detections?.stop();
3188
- };
3189
- } else if (firewallLog) {
3190
- protection.stopRefresh = () => {
3191
- firewallLog.stop();
3192
- detections?.stop();
3193
- };
3194
- }
3711
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3712
+ protection.stop = () => {
3713
+ loop?.stop();
3714
+ firewallLog?.stop();
3715
+ detections?.stop();
3716
+ };
3717
+ protection.stopRefresh = protection.stop;
3718
+ protection.detectionReporting = detectionReporting;
3719
+ if (detections) protection.detectionHealth = () => detections.health();
3195
3720
  return protection;
3196
3721
  }
3197
3722
  function resolveMode(options, bundle) {
@@ -3202,22 +3727,35 @@ function resolveMode(options, bundle) {
3202
3727
  if (options?.mode === "dry-run") return "dry-run";
3203
3728
  return "dry-run";
3204
3729
  }
3730
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
3731
+ async function readCredentialField(options, field) {
3732
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3733
+ let readFileSync;
3734
+ let join;
3735
+ try {
3736
+ const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
3737
+ readFileSync = fs.readFileSync;
3738
+ join = pathMod.join;
3739
+ } catch {
3740
+ return void 0;
3741
+ }
3742
+ const cwd = options?.cwd ?? process.cwd();
3743
+ for (const filename of CREDENTIAL_FILES) {
3744
+ try {
3745
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
3746
+ if (typeof value === "string" && value.length > 0) return value;
3747
+ } catch {
3748
+ }
3749
+ }
3750
+ return void 0;
3751
+ }
3205
3752
  async function resolveApiKey(options) {
3206
3753
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3207
3754
  if (typeof process !== "undefined") {
3208
3755
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
3209
3756
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3210
3757
  }
3211
- try {
3212
- if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3213
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3214
- const cwd = options?.cwd ?? process.cwd();
3215
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3216
- const key = JSON.parse(raw)?.apiKey;
3217
- if (typeof key === "string" && key.length > 0) return key;
3218
- } catch {
3219
- }
3220
- return void 0;
3758
+ return readCredentialField(options, "apiKey");
3221
3759
  }
3222
3760
  async function resolvePulseAuth(options) {
3223
3761
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -3225,15 +3763,8 @@ async function resolvePulseAuth(options) {
3225
3763
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
3226
3764
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3227
3765
  }
3228
- try {
3229
- if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
3230
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3231
- const cwd = options?.cwd ?? process.cwd();
3232
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3233
- const key = JSON.parse(raw)?.pulseAuth;
3234
- if (typeof key === "string" && key.length > 0) return key;
3235
- } catch {
3236
- }
3766
+ const fromFile = await readCredentialField(options, "pulseAuth");
3767
+ if (fromFile !== void 0) return fromFile;
3237
3768
  return resolveApiKey(options);
3238
3769
  }
3239
3770
  function byPhase(rules, phase) {
@@ -3518,11 +4049,13 @@ function applyHeaderMutation(headers, rule) {
3518
4049
  }
3519
4050
  }
3520
4051
  }
4052
+ var SAME_SITE = ["Strict", "Lax", "None"];
3521
4053
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
3522
4054
  let out = String(cookie);
3523
4055
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
3524
4056
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
3525
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
4057
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
4058
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
3526
4059
  return out;
3527
4060
  }
3528
4061
  function rebuildResponse(response, body, redactedHeaders) {
@@ -3536,11 +4069,9 @@ function rebuildResponse(response, body, redactedHeaders) {
3536
4069
  } catch {
3537
4070
  }
3538
4071
  } else if (typeof value === "string") {
3539
- if (headers.get(name) !== value) {
3540
- try {
3541
- headers.set(name, value);
3542
- } catch {
3543
- }
4072
+ try {
4073
+ if (headers.get(name) !== value) headers.set(name, value);
4074
+ } catch {
3544
4075
  }
3545
4076
  } else if (Array.isArray(value)) {
3546
4077
  try {