@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
@@ -160,6 +160,45 @@ function base64DecodeUtf8(value) {
160
160
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
161
161
  return new TextDecoder().decode(bytes);
162
162
  }
163
+ var NAMED_ENTITIES = {
164
+ lt: "<",
165
+ gt: ">",
166
+ amp: "&",
167
+ quot: '"',
168
+ apos: "'",
169
+ "#39": "'",
170
+ nbsp: "\xA0",
171
+ sol: "/",
172
+ bsol: "\\",
173
+ colon: ":",
174
+ lpar: "(",
175
+ rpar: ")",
176
+ equals: "=",
177
+ grave: "`",
178
+ Tab: " ",
179
+ NewLine: "\n",
180
+ semi: ";",
181
+ excl: "!",
182
+ num: "#",
183
+ dollar: "$",
184
+ percnt: "%",
185
+ ast: "*"
186
+ };
187
+ function decodeHtmlEntities(input) {
188
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
189
+ if (body[0] === "#") {
190
+ const hex = body[1] === "x" || body[1] === "X";
191
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
192
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
193
+ try {
194
+ return String.fromCodePoint(code);
195
+ } catch {
196
+ return whole;
197
+ }
198
+ }
199
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
200
+ });
201
+ }
163
202
  var RequestResolver = class {
164
203
  #req;
165
204
  #cookies;
@@ -282,6 +321,8 @@ var RequestResolver = class {
282
321
  } catch {
283
322
  return value;
284
323
  }
324
+ case "htmlentitydecode":
325
+ return decodeHtmlEntities(String(value));
285
326
  case "intval":
286
327
  return parseInt(String(value), 10) || 0;
287
328
  case "getArrayValues":
@@ -2198,25 +2239,428 @@ function toEnvelope(value) {
2198
2239
  return null;
2199
2240
  }
2200
2241
 
2201
- // src/protect/rules/validate.js
2202
- var LIMITS = {
2242
+ // src/protect/rules/contract.js
2243
+ var SOURCES = Object.freeze({
2244
+ raw: Object.freeze({ keyed: false }),
2245
+ all: Object.freeze({ keyed: false }),
2246
+ false: Object.freeze({ keyed: false }),
2247
+ rules: Object.freeze({ keyed: false, group: true }),
2248
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2249
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2250
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2251
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2252
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2253
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
2254
+ files: Object.freeze({
2255
+ keyed: true,
2256
+ keys: "any",
2257
+ wildcard: true,
2258
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2259
+ }),
2260
+ server: Object.freeze({
2261
+ keyed: true,
2262
+ keys: Object.freeze([
2263
+ "REQUEST_URI",
2264
+ "REQUEST_METHOD",
2265
+ "HTTP_USER_AGENT",
2266
+ "HTTP_REFERER",
2267
+ "HTTP_HOST",
2268
+ "REMOTE_ADDR",
2269
+ "ip",
2270
+ "CONTENT_TYPE",
2271
+ "CONTENT_LENGTH"
2272
+ ]),
2273
+ key_prefixes: Object.freeze(["HTTP_"])
2274
+ }),
2275
+ response: Object.freeze({
2276
+ keyed: true,
2277
+ keys: Object.freeze(["status", "body", "headers"]),
2278
+ key_prefixes: Object.freeze(["header."])
2279
+ }),
2280
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2281
+ });
2282
+ var MATCH_TYPES = Object.freeze([
2283
+ "array_in_array",
2284
+ "array_key_value",
2285
+ "contains",
2286
+ "cors_reflected",
2287
+ "cross_origin",
2288
+ "ctype_alnum",
2289
+ "ctype_digit",
2290
+ "ctype_special",
2291
+ "equals",
2292
+ "equals_strict",
2293
+ "hostname",
2294
+ "in_array",
2295
+ "inline_js_xss",
2296
+ "inline_xss",
2297
+ "internal_host",
2298
+ "is_numeric",
2299
+ "isset",
2300
+ "less_than",
2301
+ "more_than",
2302
+ "not_contains",
2303
+ "not_in_array",
2304
+ "off_origin",
2305
+ "quotes",
2306
+ "regex",
2307
+ "stripos"
2308
+ ]);
2309
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2310
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2311
+ "isset",
2312
+ "quotes",
2313
+ "is_numeric",
2314
+ "ctype_alnum",
2315
+ "ctype_digit",
2316
+ "ctype_special",
2317
+ "inline_xss",
2318
+ "inline_js_xss",
2319
+ "internal_host",
2320
+ "cross_origin",
2321
+ "off_origin",
2322
+ "cors_reflected"
2323
+ ]);
2324
+ var OPERAND_SHAPES = Object.freeze([
2325
+ "non-empty-string",
2326
+ "regex-literal",
2327
+ "string-or-string-list",
2328
+ "scalar",
2329
+ "string",
2330
+ "numeric",
2331
+ "scalar-or-non-empty-list",
2332
+ "non-empty-list",
2333
+ "object",
2334
+ "non-empty-object",
2335
+ "non-empty-string-list",
2336
+ "header-name-map",
2337
+ "header-name-list",
2338
+ "cookie-flags"
2339
+ ]);
2340
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2341
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2342
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2343
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2344
+ var MATCH_OPERANDS = Object.freeze({
2345
+ array_key_value: Object.freeze({
2346
+ required: Object.freeze(["key", "match"]),
2347
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2348
+ }),
2349
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2350
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2351
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2352
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2353
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2354
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2355
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
2356
+ // `array_in_array` inert and `not_in_array` true for every value.
2357
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2358
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2359
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2360
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2361
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2362
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
2363
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2364
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2365
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2366
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2367
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2368
+ });
2369
+ function operandShapeProblem(shape, value) {
2370
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2371
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2372
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2373
+ switch (shape) {
2374
+ case "non-empty-string":
2375
+ return isFilledString(value) ? null : "must be a non-empty string";
2376
+ case "regex-literal":
2377
+ if (!isFilledString(value)) return "must be a non-empty string";
2378
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2379
+ case "string-or-string-list":
2380
+ if (isFilledString(value)) return null;
2381
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2382
+ return "must be a non-empty string, or a non-empty list of them";
2383
+ case "scalar":
2384
+ return isScalar(value) ? null : "must be a string, number or boolean";
2385
+ case "string":
2386
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2387
+ case "numeric":
2388
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2389
+ case "scalar-or-non-empty-list":
2390
+ if (isScalar(value)) return null;
2391
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2392
+ return "must be a scalar, or a non-empty list of them";
2393
+ case "non-empty-list":
2394
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2395
+ case "header-name-map": {
2396
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2397
+ const names = Object.keys(value);
2398
+ if (names.length === 0) return "must be an object with at least one entry";
2399
+ const bad = names.find((name) => !isHeaderName(name));
2400
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2401
+ }
2402
+ case "header-name-list": {
2403
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2404
+ const bad = value.find((name) => !isHeaderName(name));
2405
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2406
+ }
2407
+ case "cookie-flags": {
2408
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2409
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2410
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2411
+ for (const flag of ["httpOnly", "secure"]) {
2412
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2413
+ return `flag "${flag}" must be true or false`;
2414
+ }
2415
+ }
2416
+ const sameSite = value.sameSite;
2417
+ const sameSiteOff = sameSite === false;
2418
+ if (sameSite !== void 0 && !sameSiteOff) {
2419
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2420
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2421
+ }
2422
+ }
2423
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2424
+ return "turns off every flag, so it would harden nothing";
2425
+ }
2426
+ return null;
2427
+ }
2428
+ case "object":
2429
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2430
+ case "non-empty-object":
2431
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2432
+ case "non-empty-string-list":
2433
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2434
+ default:
2435
+ return null;
2436
+ }
2437
+ }
2438
+ var MUTATIONS = Object.freeze([
2439
+ "base64_decode",
2440
+ "getArrayValues",
2441
+ "htmlentitydecode",
2442
+ "intval",
2443
+ "json_decode",
2444
+ "json_encode",
2445
+ "urldecode"
2446
+ ]);
2447
+ var PHASES = Object.freeze(["request", "response", "egress"]);
2448
+ var ACTIONS = Object.freeze([
2449
+ "block",
2450
+ "redact",
2451
+ "encode",
2452
+ "set-header",
2453
+ "remove-header",
2454
+ "harden-cookie"
2455
+ ]);
2456
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
2457
+ var RULE_PROPERTIES = Object.freeze([
2458
+ "id",
2459
+ "rule_id",
2460
+ "title",
2461
+ "category",
2462
+ "phase",
2463
+ "action",
2464
+ "rule_v2",
2465
+ "when",
2466
+ "message",
2467
+ "enforcement",
2468
+ "source_revision",
2469
+ "prefilter",
2470
+ "max_bytes",
2471
+ "bypass_limit",
2472
+ "set_headers",
2473
+ "remove_headers",
2474
+ "cookie_flags",
2475
+ "ensure"
2476
+ ]);
2477
+ var ACTION_PROPERTIES = Object.freeze({
2478
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2479
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2480
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2481
+ "set-header": Object.freeze({
2482
+ required: Object.freeze(["set_headers"]),
2483
+ defaulted: Object.freeze(["ensure"]),
2484
+ phases: Object.freeze(["response"]),
2485
+ shapes: Object.freeze({ set_headers: "header-name-map" })
2486
+ }),
2487
+ "remove-header": Object.freeze({
2488
+ required: Object.freeze(["remove_headers"]),
2489
+ defaulted: Object.freeze([]),
2490
+ phases: Object.freeze(["response"]),
2491
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
2492
+ }),
2493
+ "harden-cookie": Object.freeze({
2494
+ required: Object.freeze([]),
2495
+ defaulted: Object.freeze(["cookie_flags"]),
2496
+ phases: Object.freeze(["response"]),
2497
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2498
+ })
2499
+ });
2500
+ var LIMITS = Object.freeze({
2203
2501
  maxRules: 5e3,
2204
2502
  maxWhitelists: 2e3,
2205
2503
  maxConditionsPerRule: 250,
2206
2504
  maxNestingDepth: 12,
2207
2505
  maxRegexLength: 1e3,
2208
2506
  maxValueLength: 8192
2209
- };
2210
- var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
2211
- var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
2507
+ });
2508
+ var GROUP_PARAMETER = "rules";
2509
+ function isGroup(condition) {
2510
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2511
+ }
2512
+ function conditionShapeProblem(condition) {
2513
+ if (!condition || typeof condition !== "object") return "condition is not an object";
2514
+ const named = condition.parameter === GROUP_PARAMETER;
2515
+ const nested = Array.isArray(condition.rules);
2516
+ if (named && !nested) {
2517
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2518
+ }
2519
+ if (nested && !named) {
2520
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2521
+ }
2522
+ if (named && nested) {
2523
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2524
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2525
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
2526
+ }
2527
+ return null;
2528
+ }
2529
+ function parameterProblem(parameter) {
2530
+ if (parameter === void 0 || parameter === null) return null;
2531
+ if (Array.isArray(parameter)) {
2532
+ if (parameter.length === 0) return "parameter list is empty";
2533
+ for (const member of parameter) {
2534
+ const problem = parameterProblem(member);
2535
+ if (problem !== null) return problem;
2536
+ }
2537
+ return null;
2538
+ }
2539
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2540
+ if (parameter === GROUP_PARAMETER) {
2541
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2542
+ }
2543
+ const dot = parameter.indexOf(".");
2544
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
2545
+ const source = SOURCES[name];
2546
+ if (source === void 0) return `unknown parameter source "${name}"`;
2547
+ if (dot === -1) {
2548
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2549
+ }
2550
+ if (source.keyed !== true) return `source "${name}" takes no key`;
2551
+ const key = parameter.slice(dot + 1);
2552
+ if (key === "") return `source "${name}" needs a key after the dot`;
2553
+ return keyProblem(name, source, key);
2554
+ }
2555
+ function keyProblem(name, source, key) {
2556
+ if (key.endsWith("*") && source.wildcard !== true) {
2557
+ return `"${name}" does not fan out over a wildcard key`;
2558
+ }
2559
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2560
+ if (source.keys === "any") return null;
2561
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2562
+ for (const prefix of source.key_prefixes ?? []) {
2563
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2564
+ }
2565
+ const accepted = [
2566
+ ...Array.isArray(source.keys) ? source.keys : [],
2567
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2568
+ ].join(", ");
2569
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2570
+ }
2571
+ function matchProblem(match, parameter) {
2572
+ if (!match || typeof match !== "object") return "condition has no match object";
2573
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2574
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2575
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2576
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
2577
+ return `match type "${match.type}" needs a parameter`;
2578
+ }
2579
+ if (parameterless && parameter !== void 0 && parameter !== null) {
2580
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
2581
+ }
2582
+ const operands = MATCH_OPERANDS[match.type];
2583
+ if (operands) {
2584
+ for (const required of operands.required) {
2585
+ if (match[required] === void 0 || match[required] === null) {
2586
+ return `match type "${match.type}" needs "${required}"`;
2587
+ }
2588
+ }
2589
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2590
+ if (match[operand] === void 0) continue;
2591
+ const problem = operandShapeProblem(shape, match[operand]);
2592
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
2593
+ }
2594
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2595
+ return `match type "${match.type}" needs a value`;
2596
+ }
2597
+ return null;
2598
+ }
2599
+ function mutationsProblem(mutations) {
2600
+ if (mutations === void 0 || mutations === null) return null;
2601
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2602
+ for (const mutation of mutations) {
2603
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2604
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
2605
+ }
2606
+ }
2607
+ return null;
2608
+ }
2609
+ function whenProblem(when) {
2610
+ if (when === void 0 || when === null) return null;
2611
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2612
+ const keys = Object.keys(when);
2613
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2614
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2615
+ if (unknown.length > 0) {
2616
+ 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`;
2617
+ }
2618
+ if ("path" in when && !isNonEmptyString(when.path)) {
2619
+ return "when.path must be a non-empty string";
2620
+ }
2621
+ if ("method" in when) {
2622
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2623
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2624
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2625
+ }
2626
+ return null;
2627
+ }
2628
+ function isNonEmptyString(value) {
2629
+ return typeof value === "string" && value.trim() !== "";
2630
+ }
2631
+ function actionProblem(action, rule) {
2632
+ if (action === void 0 || action === null) return null;
2633
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2634
+ const spec = ACTION_PROPERTIES[action];
2635
+ for (const required of spec?.required ?? []) {
2636
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2637
+ return `action "${action}" needs "${required}"`;
2638
+ }
2639
+ }
2640
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2641
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2642
+ const problem = operandShapeProblem(shape, rule[property]);
2643
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2644
+ }
2645
+ const phase = rule?.phase ?? "request";
2646
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2647
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2648
+ }
2649
+ return null;
2650
+ }
2651
+
2652
+ // src/protect/rules/validate.js
2653
+ var LIMITS2 = LIMITS;
2654
+ var PHASES2 = new Set(PHASES);
2655
+ var ACTIONS2 = new Set(ACTIONS);
2212
2656
  function validateBundle(bundle, opts = {}) {
2213
2657
  const rejected = [];
2214
2658
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2215
2659
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2216
2660
  const firewall = [];
2217
2661
  for (const rule of inFirewall) {
2218
- if (firewall.length >= LIMITS.maxRules) {
2219
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
2662
+ if (firewall.length >= LIMITS2.maxRules) {
2663
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2220
2664
  continue;
2221
2665
  }
2222
2666
  const reason = ruleProblem(rule);
@@ -2225,8 +2669,8 @@ function validateBundle(bundle, opts = {}) {
2225
2669
  }
2226
2670
  const whitelists = [];
2227
2671
  for (const wl of inWhitelists) {
2228
- if (whitelists.length >= LIMITS.maxWhitelists) {
2229
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
2672
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2673
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2230
2674
  continue;
2231
2675
  }
2232
2676
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -2248,8 +2692,11 @@ function idOf(rule) {
2248
2692
  }
2249
2693
  function ruleProblem(rule) {
2250
2694
  if (!rule || typeof rule !== "object") return "not an object";
2251
- if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2252
- if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
2695
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2696
+ const actionReason = actionProblem(rule.action, rule);
2697
+ if (actionReason) return actionReason;
2698
+ const scopeReason = whenProblem(rule.when);
2699
+ if (scopeReason) return scopeReason;
2253
2700
  const capOverride = rule.max_bytes;
2254
2701
  if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2255
2702
  return conditionsProblem(rule.rule_v2);
@@ -2257,28 +2704,33 @@ function ruleProblem(rule) {
2257
2704
  function conditionsProblem(conditions, depth = 0) {
2258
2705
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2259
2706
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2260
- if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
2261
- if (conditions.length > LIMITS.maxConditionsPerRule) {
2262
- return `more than ${LIMITS.maxConditionsPerRule} conditions`;
2707
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2708
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2709
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2263
2710
  }
2264
2711
  for (const c of conditions) {
2265
- if (!c || typeof c !== "object") return "condition is not an object";
2266
- if (Array.isArray(c.rules)) {
2712
+ const shapeReason = conditionShapeProblem(c);
2713
+ if (shapeReason) return shapeReason;
2714
+ if (isGroup(c)) {
2267
2715
  const nested = conditionsProblem(c.rules, depth + 1);
2268
2716
  if (nested) return nested;
2269
2717
  continue;
2270
2718
  }
2719
+ const parameterReason = parameterProblem(c.parameter);
2720
+ if (parameterReason) return parameterReason;
2721
+ const mutationReason = mutationsProblem(c.mutations);
2722
+ if (mutationReason) return mutationReason;
2723
+ const matchReason = matchProblem(c.match, c.parameter);
2724
+ if (matchReason) return matchReason;
2271
2725
  const m = c.match;
2272
- if (!m || typeof m !== "object") return "condition has no match object";
2273
- if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
2274
2726
  if (m.type === "regex") {
2275
2727
  if (typeof m.value !== "string") return "regex match.value must be a string";
2276
- if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
2277
- } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
2278
- return `match.value longer than ${LIMITS.maxValueLength} chars`;
2728
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2729
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2730
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2279
2731
  }
2280
2732
  if (m.match) {
2281
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
2733
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2282
2734
  if (nested) return nested;
2283
2735
  }
2284
2736
  }
@@ -2306,64 +2758,67 @@ function reportRejections(rejected, options, label) {
2306
2758
  `${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" : ""}`
2307
2759
  ), "onError");
2308
2760
  }
2761
+ function fromSource(bundle, reason) {
2762
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
2763
+ }
2309
2764
  async function resolveRules(options, store, ctx = {}) {
2310
2765
  const timeoutMs = ctx.timeoutMs;
2311
2766
  if (options.siteUuid) {
2312
2767
  const prior = await store.read();
2313
2768
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
2314
2769
  const res = await client.getRules();
2315
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2770
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2316
2771
  if (res.success && !res.notModified) {
2317
2772
  const rejected = liveUpdateRejections(res, options);
2318
2773
  if (rejected.length > 0) {
2319
2774
  reportRejections(rejected, options, "rule update rejected");
2320
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2321
- if (options.rules) return normalizeBundle(options.rules, options);
2322
- return emptyBundle();
2775
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2776
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2777
+ return fromSource(emptyBundle(), "update rejected");
2323
2778
  }
2324
2779
  const bundle = normalizeBundle(res, options);
2325
2780
  await store.write({ bundle, etag: res.etag ?? null });
2326
- return bundle;
2781
+ return fromSource(bundle);
2327
2782
  }
2328
2783
  if (prior?.bundle) {
2329
2784
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2330
- return normalizeBundle(prior.bundle, options);
2785
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2331
2786
  }
2332
2787
  if (options.rules) {
2333
2788
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2334
- return normalizeBundle(options.rules, options);
2789
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
2335
2790
  }
2336
2791
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2337
- return emptyBundle();
2792
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2338
2793
  }
2339
2794
  if (options.token) {
2340
2795
  const prior = await store.read();
2341
2796
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2342
2797
  const res = await client.getRules();
2343
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2798
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2344
2799
  if (res.success && !res.notModified) {
2345
2800
  const rejected = liveUpdateRejections(res, options);
2346
2801
  if (rejected.length > 0) {
2347
2802
  reportRejections(rejected, options, "rule update rejected");
2348
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2349
- if (options.rules) return normalizeBundle(options.rules, options);
2350
- return emptyBundle();
2803
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2804
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2805
+ return fromSource(emptyBundle(), "update rejected");
2351
2806
  }
2352
2807
  const bundle = normalizeBundle(res, options);
2353
2808
  await store.write({ bundle, etag: res.etag ?? null });
2354
- return bundle;
2809
+ return fromSource(bundle);
2355
2810
  }
2356
2811
  if (prior?.bundle) {
2357
2812
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2358
- return normalizeBundle(prior.bundle, options);
2813
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2359
2814
  }
2360
2815
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2361
- return emptyBundle();
2816
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2362
2817
  }
2363
2818
  if (options.rules) {
2364
- return normalizeBundle(options.rules, options);
2819
+ return fromSource(normalizeBundle(options.rules, options));
2365
2820
  }
2366
- return emptyBundle();
2821
+ return fromSource(emptyBundle());
2367
2822
  }
2368
2823
  function normalizeBundle(b, options = {}) {
2369
2824
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2416,8 +2871,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
2416
2871
  const run = async () => {
2417
2872
  if (stopped) return;
2418
2873
  try {
2419
- await tick();
2420
- failures = 0;
2874
+ const status = await tick();
2875
+ if (status && status.ok === false) failures++;
2876
+ else failures = 0;
2421
2877
  } catch (err) {
2422
2878
  failures++;
2423
2879
  notify(onError, err, "onError");
@@ -2444,7 +2900,8 @@ function makeRefreshHandler(tick, secret) {
2444
2900
  if (provided !== secret) return new Response("forbidden", { status: 403 });
2445
2901
  let refreshed = true;
2446
2902
  try {
2447
- await tick();
2903
+ const status = await tick();
2904
+ if (status && status.ok === false) refreshed = false;
2448
2905
  } catch {
2449
2906
  refreshed = false;
2450
2907
  }
@@ -2472,6 +2929,12 @@ function ruleParameters(rule) {
2472
2929
  walk(rule?.rule_v2);
2473
2930
  return [...out];
2474
2931
  }
2932
+ function revisionOf(rule) {
2933
+ const revision = rule?.source_revision;
2934
+ if (typeof revision === "string" && revision !== "") return revision;
2935
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
2936
+ return null;
2937
+ }
2475
2938
  function routeOf(path) {
2476
2939
  if (typeof path !== "string" || path === "") return null;
2477
2940
  const cut = path.search(/[?#]/);
@@ -2480,16 +2943,30 @@ function routeOf(path) {
2480
2943
  function createDetectionReporter(opts) {
2481
2944
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
2482
2945
  if (!siteUuid) {
2483
- return { record() {
2484
- }, flush() {
2485
- }, stop() {
2486
- }, dropped: () => 0 };
2946
+ return {
2947
+ record() {
2948
+ },
2949
+ flush() {
2950
+ },
2951
+ stop() {
2952
+ },
2953
+ setRulesEtag() {
2954
+ },
2955
+ dropped: () => 0,
2956
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
2957
+ };
2487
2958
  }
2488
2959
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
2489
2960
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
2490
2961
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
2491
2962
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
2492
2963
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
2964
+ let rulesEtag = opts.rulesEtag ?? null;
2965
+ let sent = 0;
2966
+ let delivered = 0;
2967
+ let failed = 0;
2968
+ let droppedTotal = 0;
2969
+ let lastDeliveredAt = null;
2493
2970
  let queue = [];
2494
2971
  let timer = null;
2495
2972
  let stopped = false;
@@ -2503,6 +2980,8 @@ function createDetectionReporter(opts) {
2503
2980
  const batch = queue.splice(0, MAX_BATCH);
2504
2981
  const droppedWith = dropped;
2505
2982
  dropped = 0;
2983
+ droppedTotal += droppedWith;
2984
+ sent += batch.length;
2506
2985
  void (async () => {
2507
2986
  try {
2508
2987
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -2511,15 +2990,22 @@ function createDetectionReporter(opts) {
2511
2990
  "Content-Type": "application/json",
2512
2991
  Accept: "application/json",
2513
2992
  "User-Agent": "@patchstack/connect",
2514
- // Same credential path as the rules fetch, and unauthenticated when none resolves: the
2515
- // server accepts the UUID, and reporting must never hinge on getting a token.
2993
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
2994
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
2995
+ // why the runtime does not build a reporter when no credential resolves, rather than
2996
+ // posting into a 401.
2516
2997
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2517
2998
  },
2518
2999
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
2519
3000
  });
2520
- if (res && typeof res.then === "function") res.catch(() => {
2521
- });
3001
+ if (res && res.ok) {
3002
+ delivered += batch.length;
3003
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3004
+ } else {
3005
+ failed += batch.length;
3006
+ }
2522
3007
  } catch {
3008
+ failed += batch.length;
2523
3009
  }
2524
3010
  })();
2525
3011
  };
@@ -2548,7 +3034,12 @@ function createDetectionReporter(opts) {
2548
3034
  // The state this detection was handled under, which is the whole point: `false` is a rule that
2549
3035
  // saw traffic it would have stopped.
2550
3036
  enforced: detection.mode === "block",
2551
- rules_etag: opts.rulesEtag ?? null,
3037
+ rules_etag: rulesEtag,
3038
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
3039
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
3040
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
3041
+ // bundle carried none.
3042
+ rule_revision: revisionOf(detection.rule),
2552
3043
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
2553
3044
  });
2554
3045
  if (queue.length >= MAX_BATCH) {
@@ -2562,7 +3053,24 @@ function createDetectionReporter(opts) {
2562
3053
  stopped = true;
2563
3054
  flush();
2564
3055
  },
2565
- dropped: () => dropped
3056
+ /**
3057
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
3058
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
3059
+ *
3060
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
3061
+ * the only moment the two are known to agree.
3062
+ *
3063
+ * @param {string | null | undefined} next
3064
+ */
3065
+ setRulesEtag(next) {
3066
+ rulesEtag = next ?? null;
3067
+ },
3068
+ dropped: () => dropped,
3069
+ /**
3070
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
3071
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
3072
+ */
3073
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
2566
3074
  };
2567
3075
  }
2568
3076
 
@@ -2827,22 +3335,32 @@ async function createProtection(options = {}) {
2827
3335
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2828
3336
  const pulseAuth = await resolvePulseAuth(options);
2829
3337
  if (options.siteUuid && !pulseAuth) {
2830
- 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.";
3338
+ 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.";
2831
3339
  notify(onError, new Error(message), "onError");
2832
3340
  console.warn(message);
2833
3341
  }
2834
3342
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3343
+ let detectionReporting = "off";
2835
3344
  if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
2836
- detections = createDetectionReporter({
2837
- siteUuid: options.siteUuid,
2838
- baseUrl: options.pulseRulesUrl,
2839
- pulseAuth,
2840
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
2841
- // it rather than to whatever is current when the report is read.
2842
- rulesEtag: (await store.read())?.etag ?? null,
2843
- fetchImpl: options.fetchImpl,
2844
- flushMs: options.detectionFlushMs
2845
- });
3345
+ if (!pulseAuth) {
3346
+ detectionReporting = "unavailable-no-credential";
3347
+ 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.";
3348
+ notify(onError, new Error(message), "onError");
3349
+ console.warn(message);
3350
+ } else {
3351
+ detectionReporting = "on";
3352
+ detections = createDetectionReporter({
3353
+ siteUuid: options.siteUuid,
3354
+ baseUrl: options.pulseRulesUrl,
3355
+ pulseAuth,
3356
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3357
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
3358
+ // see the refresh tick below.
3359
+ rulesEtag: (await store.read())?.etag ?? null,
3360
+ fetchImpl: options.fetchImpl,
3361
+ flushMs: options.detectionFlushMs
3362
+ });
3363
+ }
2846
3364
  }
2847
3365
  let mode = resolveMode(options, bundle);
2848
3366
  let requestRules;
@@ -3176,11 +3694,17 @@ async function createProtection(options = {}) {
3176
3694
  );
3177
3695
  };
3178
3696
  },
3179
- // Node / Connect middleware — buffers the body itself (request phase).
3697
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
3698
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
3699
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
3180
3700
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
3181
3701
  node(nodeOptions = {}) {
3182
3702
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
3183
3703
  return (req, res, next) => {
3704
+ if (req.readableEnded || req.body !== void 0) {
3705
+ screenNodeRequest(req, res, next, "", req.body);
3706
+ return;
3707
+ }
3184
3708
  const chunks = [];
3185
3709
  let size = 0;
3186
3710
  let overflow = false;
@@ -3198,38 +3722,41 @@ async function createProtection(options = {}) {
3198
3722
  });
3199
3723
  req.on("end", () => {
3200
3724
  if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3201
- const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3202
- let shaped;
3203
- let result;
3204
- try {
3205
- shaped = fromNodeRequest(req, rawBody);
3206
- result = engine.evaluate(shaped);
3207
- } catch (err) {
3208
- notify(onError, err, "onError");
3209
- return next();
3210
- }
3211
- decide(
3212
- "request",
3213
- result,
3214
- () => {
3215
- res.statusCode = 403;
3216
- if (isDocumentNavigation((n) => req.headers?.[n])) {
3217
- res.setHeader("content-type", "text/html; charset=utf-8");
3218
- res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3219
- } else {
3220
- res.setHeader("content-type", "application/json");
3221
- res.end(JSON.stringify(blockBody(result)));
3222
- }
3223
- },
3224
- () => {
3225
- if (req.body === void 0) req.body = shaped.body;
3226
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3227
- next();
3228
- },
3229
- nodeRequestMeta(req)
3230
- );
3725
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
3231
3726
  });
3232
3727
  };
3728
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
3729
+ let shaped;
3730
+ let result;
3731
+ try {
3732
+ shaped = fromNodeRequest(req, rawBody);
3733
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3734
+ result = engine.evaluate(shaped);
3735
+ } catch (err) {
3736
+ notify(onError, err, "onError");
3737
+ return next();
3738
+ }
3739
+ decide(
3740
+ "request",
3741
+ result,
3742
+ () => {
3743
+ res.statusCode = 403;
3744
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
3745
+ res.setHeader("content-type", "text/html; charset=utf-8");
3746
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3747
+ } else {
3748
+ res.setHeader("content-type", "application/json");
3749
+ res.end(JSON.stringify(blockBody(result)));
3750
+ }
3751
+ },
3752
+ () => {
3753
+ if (req.body === void 0) req.body = shaped.body;
3754
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3755
+ next();
3756
+ },
3757
+ nodeRequestMeta(req)
3758
+ );
3759
+ }
3233
3760
  }
3234
3761
  };
3235
3762
  if (options.egress) {
@@ -3266,24 +3793,22 @@ async function createProtection(options = {}) {
3266
3793
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3267
3794
  mode = resolveMode(options, next);
3268
3795
  applyBundle(next);
3796
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3797
+ return next.source ?? { ok: true };
3269
3798
  };
3270
3799
  if (live) {
3271
3800
  protection.refresh = () => runRefreshTick();
3272
3801
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
3273
3802
  }
3274
- if (options.refreshMs > 0 && live) {
3275
- const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3276
- protection.stopRefresh = () => {
3277
- loop.stop();
3278
- firewallLog?.stop();
3279
- detections?.stop();
3280
- };
3281
- } else if (firewallLog) {
3282
- protection.stopRefresh = () => {
3283
- firewallLog.stop();
3284
- detections?.stop();
3285
- };
3286
- }
3803
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3804
+ protection.stop = () => {
3805
+ loop?.stop();
3806
+ firewallLog?.stop();
3807
+ detections?.stop();
3808
+ };
3809
+ protection.stopRefresh = protection.stop;
3810
+ protection.detectionReporting = detectionReporting;
3811
+ if (detections) protection.detectionHealth = () => detections.health();
3287
3812
  return protection;
3288
3813
  }
3289
3814
  function resolveMode(options, bundle) {
@@ -3294,22 +3819,35 @@ function resolveMode(options, bundle) {
3294
3819
  if (options?.mode === "dry-run") return "dry-run";
3295
3820
  return "dry-run";
3296
3821
  }
3822
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
3823
+ async function readCredentialField(options, field) {
3824
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3825
+ let readFileSync;
3826
+ let join;
3827
+ try {
3828
+ const [fs, pathMod] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3829
+ readFileSync = fs.readFileSync;
3830
+ join = pathMod.join;
3831
+ } catch {
3832
+ return void 0;
3833
+ }
3834
+ const cwd = options?.cwd ?? process.cwd();
3835
+ for (const filename of CREDENTIAL_FILES) {
3836
+ try {
3837
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
3838
+ if (typeof value === "string" && value.length > 0) return value;
3839
+ } catch {
3840
+ }
3841
+ }
3842
+ return void 0;
3843
+ }
3297
3844
  async function resolveApiKey(options) {
3298
3845
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3299
3846
  if (typeof process !== "undefined") {
3300
3847
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
3301
3848
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3302
3849
  }
3303
- try {
3304
- if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3305
- const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3306
- const cwd = options?.cwd ?? process.cwd();
3307
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3308
- const key = JSON.parse(raw)?.apiKey;
3309
- if (typeof key === "string" && key.length > 0) return key;
3310
- } catch {
3311
- }
3312
- return void 0;
3850
+ return readCredentialField(options, "apiKey");
3313
3851
  }
3314
3852
  async function resolvePulseAuth(options) {
3315
3853
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -3317,15 +3855,8 @@ async function resolvePulseAuth(options) {
3317
3855
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
3318
3856
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3319
3857
  }
3320
- try {
3321
- if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
3322
- const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3323
- const cwd = options?.cwd ?? process.cwd();
3324
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3325
- const key = JSON.parse(raw)?.pulseAuth;
3326
- if (typeof key === "string" && key.length > 0) return key;
3327
- } catch {
3328
- }
3858
+ const fromFile = await readCredentialField(options, "pulseAuth");
3859
+ if (fromFile !== void 0) return fromFile;
3329
3860
  return resolveApiKey(options);
3330
3861
  }
3331
3862
  function byPhase(rules, phase) {
@@ -3610,11 +4141,13 @@ function applyHeaderMutation(headers, rule) {
3610
4141
  }
3611
4142
  }
3612
4143
  }
4144
+ var SAME_SITE = ["Strict", "Lax", "None"];
3613
4145
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
3614
4146
  let out = String(cookie);
3615
4147
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
3616
4148
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
3617
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
4149
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
4150
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
3618
4151
  return out;
3619
4152
  }
3620
4153
  function rebuildResponse(response, body, redactedHeaders) {
@@ -3628,11 +4161,9 @@ function rebuildResponse(response, body, redactedHeaders) {
3628
4161
  } catch {
3629
4162
  }
3630
4163
  } else if (typeof value === "string") {
3631
- if (headers.get(name) !== value) {
3632
- try {
3633
- headers.set(name, value);
3634
- } catch {
3635
- }
4164
+ try {
4165
+ if (headers.get(name) !== value) headers.set(name, value);
4166
+ } catch {
3636
4167
  }
3637
4168
  } else if (Array.isArray(value)) {
3638
4169
  try {