@patchstack/connect 0.3.29 → 0.3.31

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 +42 -23
  2. package/README.md +40 -11
  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 +1265 -287
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +456 -111
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +76 -10
  10. package/dist/index.d.ts +76 -10
  11. package/dist/index.js +449 -104
  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 +1204 -252
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +792 -143
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +794 -145
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-KNZQBC4V.js} +387 -100
  37. package/dist/refresh-manifest-KNZQBC4V.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.cjs CHANGED
@@ -237,6 +237,7 @@ var init_client = __esm({
237
237
  // src/config.ts
238
238
  async function resolveConfig(options) {
239
239
  const fromFile = await readConfigFile(options.cwd);
240
+ const fromSecretFile = await readSecretFile(options.cwd);
240
241
  const fromEnv = readEnv();
241
242
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
242
243
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
@@ -261,8 +262,8 @@ async function resolveConfig(options) {
261
262
  "CONFIG_MISSING"
262
263
  );
263
264
  }
264
- const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
265
- const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
265
+ const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
266
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
266
267
  return {
267
268
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
268
269
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -273,8 +274,15 @@ async function resolveConfig(options) {
273
274
  widget: fromFile.widget !== false
274
275
  };
275
276
  }
277
+ async function readSecretFile(cwd) {
278
+ const file = await readJsonFile(cwd, SECRET_FILENAME);
279
+ return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
280
+ }
276
281
  async function readConfigFile(cwd) {
277
- const target = import_node_path.default.join(cwd, CONFIG_FILENAME);
282
+ return readJsonFile(cwd, CONFIG_FILENAME);
283
+ }
284
+ async function readJsonFile(cwd, filename) {
285
+ const target = import_node_path.default.join(cwd, filename);
278
286
  let raw;
279
287
  try {
280
288
  raw = await (0, import_promises.readFile)(target, "utf8");
@@ -327,7 +335,7 @@ function isUuid(value) {
327
335
  function isEnvironment(value) {
328
336
  return value === "production" || value === "sandbox";
329
337
  }
330
- var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
338
+ var import_promises, import_node_path, CONFIG_FILENAME, SECRET_FILENAME, DEFAULT_ENVIRONMENT;
331
339
  var init_config = __esm({
332
340
  "src/config.ts"() {
333
341
  "use strict";
@@ -336,6 +344,7 @@ var init_config = __esm({
336
344
  init_types();
337
345
  init_client();
338
346
  CONFIG_FILENAME = ".patchstackrc.json";
347
+ SECRET_FILENAME = ".patchstackrc.local.json";
339
348
  DEFAULT_ENVIRONMENT = "production";
340
349
  }
341
350
  });
@@ -358,7 +367,7 @@ async function readDeclaredDependencyNames(cwd) {
358
367
  return [];
359
368
  }
360
369
  const names = /* @__PURE__ */ new Set();
361
- for (const group of ["dependencies", "devDependencies"]) {
370
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
362
371
  const deps = parsed[group];
363
372
  if (typeof deps !== "object" || deps === null) {
364
373
  continue;
@@ -376,6 +385,23 @@ function missingDependencies(declared, packages) {
376
385
  const present = new Set(packages.map((entry) => entry.name));
377
386
  return declared.filter((name) => !present.has(name));
378
387
  }
388
+ function disagreements(packages, other) {
389
+ const otherVersions = /* @__PURE__ */ new Map();
390
+ for (const entry of other) {
391
+ if (!otherVersions.has(entry.name)) otherVersions.set(entry.name, entry.version);
392
+ }
393
+ const seen = /* @__PURE__ */ new Set();
394
+ const conflicts = [];
395
+ for (const entry of packages) {
396
+ if (seen.has(entry.name)) continue;
397
+ seen.add(entry.name);
398
+ const otherVersion = otherVersions.get(entry.name);
399
+ if (otherVersion !== void 0 && otherVersion !== entry.version) {
400
+ conflicts.push({ name: entry.name, version: entry.version, otherVersion });
401
+ }
402
+ }
403
+ return conflicts;
404
+ }
379
405
  var import_promises2, import_node_path2;
380
406
  var init_consistency = __esm({
381
407
  "src/parsers/consistency.ts"() {
@@ -385,11 +411,163 @@ var init_consistency = __esm({
385
411
  }
386
412
  });
387
413
 
414
+ // src/parsers/report.ts
415
+ function newReport() {
416
+ return { unreadable: 0, samples: [] };
417
+ }
418
+ function recordUnreadable(report, sample) {
419
+ if (!report) return;
420
+ report.unreadable++;
421
+ if (report.samples.length < MAX_SAMPLES) report.samples.push(sample);
422
+ }
423
+ function unreadableWarning(filename, report) {
424
+ if (report.unreadable === 0) return null;
425
+ const shown = report.samples.join(", ");
426
+ const one = report.unreadable === 1;
427
+ return `${filename}: ${report.unreadable} entr${one ? "y was" : "ies were"} not in a form this scanner reads (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "it is" : "they are"} absent from this manifest. Anything installed only through ${one ? "that entry" : "those entries"} is not being checked.`;
428
+ }
429
+ var MAX_SAMPLES;
430
+ var init_report = __esm({
431
+ "src/parsers/report.ts"() {
432
+ "use strict";
433
+ MAX_SAMPLES = 3;
434
+ }
435
+ });
436
+
437
+ // src/parsers/bun.ts
438
+ async function parseBunLockfile(lockfilePath, report) {
439
+ let raw;
440
+ try {
441
+ raw = await (0, import_promises3.readFile)(lockfilePath, "utf8");
442
+ } catch (cause) {
443
+ throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
444
+ }
445
+ let parsed;
446
+ try {
447
+ parsed = JSON.parse(stripTrailingCommas(raw));
448
+ } catch (cause) {
449
+ throw new PatchstackError(`Lockfile at ${lockfilePath} is not valid JSON`, "LOCKFILE_PARSE_ERROR", cause);
450
+ }
451
+ const packages = parsed?.packages;
452
+ if (typeof packages !== "object" || packages === null) {
453
+ throw new PatchstackError(`Lockfile at ${lockfilePath} has no "packages" entries`, "LOCKFILE_PARSE_ERROR");
454
+ }
455
+ const direct = directDependencyNames(parsed);
456
+ const entries = [];
457
+ let skipped = 0;
458
+ for (const [key, value] of Object.entries(packages)) {
459
+ const descriptor = Array.isArray(value) ? value[0] : value;
460
+ if (typeof descriptor !== "string") {
461
+ skipped++;
462
+ recordUnreadable(report, key);
463
+ continue;
464
+ }
465
+ const split = splitDescriptor(descriptor);
466
+ if (split === null) {
467
+ skipped++;
468
+ if (!isDeliberatelyExcluded(descriptor)) recordUnreadable(report, key);
469
+ continue;
470
+ }
471
+ entries.push({
472
+ name: split.name,
473
+ version: split.version,
474
+ ...direct.has(split.name) ? { direct: true } : {},
475
+ ...key === split.name ? {} : { path: key }
476
+ });
477
+ }
478
+ if (entries.length === 0) {
479
+ throw new PatchstackError(
480
+ `Lockfile at ${lockfilePath} lists no registry packages${skipped > 0 ? ` (${skipped} non-registry entr${skipped === 1 ? "y" : "ies"} skipped)` : ""}`,
481
+ "LOCKFILE_PARSE_ERROR"
482
+ );
483
+ }
484
+ return entries;
485
+ }
486
+ function splitDescriptor(descriptor) {
487
+ const at = descriptor.lastIndexOf("@");
488
+ if (at <= 0) return null;
489
+ const name = descriptor.slice(0, at);
490
+ const version = descriptor.slice(at + 1);
491
+ if (name === "" || version === "") return null;
492
+ if (NON_REGISTRY_VERSION.test(version)) return null;
493
+ return { name, version };
494
+ }
495
+ function isDeliberatelyExcluded(descriptor) {
496
+ const at = descriptor.lastIndexOf("@");
497
+ if (at <= 0) return false;
498
+ return NON_REGISTRY_VERSION.test(descriptor.slice(at + 1));
499
+ }
500
+ function directDependencyNames(parsed) {
501
+ const names = /* @__PURE__ */ new Set();
502
+ const workspaces = parsed?.workspaces;
503
+ if (typeof workspaces !== "object" || workspaces === null) return names;
504
+ for (const workspace of Object.values(workspaces)) {
505
+ if (typeof workspace !== "object" || workspace === null) continue;
506
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
507
+ const deps = workspace[group];
508
+ if (typeof deps !== "object" || deps === null) continue;
509
+ for (const name of Object.keys(deps)) names.add(name);
510
+ }
511
+ }
512
+ return names;
513
+ }
514
+ function stripTrailingCommas(source) {
515
+ const out = [];
516
+ let inString = false;
517
+ let pendingComma = -1;
518
+ for (let i = 0; i < source.length; i++) {
519
+ const ch = source[i];
520
+ if (inString) {
521
+ out.push(ch);
522
+ if (ch === "\\") {
523
+ const next = source[i + 1];
524
+ if (next !== void 0) {
525
+ out.push(next);
526
+ i++;
527
+ }
528
+ } else if (ch === '"') {
529
+ inString = false;
530
+ }
531
+ continue;
532
+ }
533
+ if (ch === '"') {
534
+ inString = true;
535
+ pendingComma = -1;
536
+ out.push(ch);
537
+ continue;
538
+ }
539
+ if (ch === ",") {
540
+ pendingComma = out.length;
541
+ out.push(ch);
542
+ continue;
543
+ }
544
+ if (ch === "}" || ch === "]") {
545
+ if (pendingComma !== -1) out[pendingComma] = " ";
546
+ pendingComma = -1;
547
+ out.push(ch);
548
+ continue;
549
+ }
550
+ if (!/\s/.test(ch)) pendingComma = -1;
551
+ out.push(ch);
552
+ }
553
+ return out.join("");
554
+ }
555
+ var import_promises3, NON_REGISTRY_VERSION;
556
+ var init_bun = __esm({
557
+ "src/parsers/bun.ts"() {
558
+ "use strict";
559
+ import_promises3 = require("fs/promises");
560
+ init_types();
561
+ init_report();
562
+ NON_REGISTRY_VERSION = /^[a-z][a-z0-9+.-]*:|\//i;
563
+ }
564
+ });
565
+
388
566
  // src/parsers/npm.ts
389
567
  async function parseNpmLockfile(lockfilePath) {
390
568
  let raw;
391
569
  try {
392
- raw = await (0, import_promises3.readFile)(lockfilePath, "utf8");
570
+ raw = await (0, import_promises4.readFile)(lockfilePath, "utf8");
393
571
  } catch (cause) {
394
572
  throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
395
573
  }
@@ -468,11 +646,11 @@ function isDirectV2(pkgPath) {
468
646
  const nmCount = parts.filter((p) => p === "node_modules").length;
469
647
  return nmCount === 1;
470
648
  }
471
- var import_promises3, import_node_path3;
649
+ var import_promises4, import_node_path3;
472
650
  var init_npm = __esm({
473
651
  "src/parsers/npm.ts"() {
474
652
  "use strict";
475
- import_promises3 = require("fs/promises");
653
+ import_promises4 = require("fs/promises");
476
654
  import_node_path3 = __toESM(require("path"), 1);
477
655
  init_types();
478
656
  }
@@ -482,7 +660,7 @@ var init_npm = __esm({
482
660
  async function walkNodeModules(cwd) {
483
661
  const root = import_node_path4.default.join(cwd, "node_modules");
484
662
  try {
485
- const info = await (0, import_promises4.stat)(root);
663
+ const info = await (0, import_promises5.stat)(root);
486
664
  if (!info.isDirectory()) {
487
665
  throw new PatchstackError(
488
666
  `${root} exists but is not a directory.`,
@@ -499,104 +677,134 @@ async function walkNodeModules(cwd) {
499
677
  cause
500
678
  );
501
679
  }
502
- const entries = [];
503
- await walk(root, entries, 0);
504
- return entries;
680
+ const seen = /* @__PURE__ */ new Map();
681
+ try {
682
+ seen.set(await (0, import_promises5.realpath)(cwd), -1);
683
+ } catch {
684
+ }
685
+ return dedupeByShallowest(await walk(root, 0, seen, cwd));
686
+ }
687
+ function dedupeByShallowest(found) {
688
+ const best = /* @__PURE__ */ new Map();
689
+ for (const item of found) {
690
+ const current = best.get(item.realpath);
691
+ if (current === void 0 || item.depth < current.depth) best.set(item.realpath, item);
692
+ }
693
+ return [...best.values()].map(({ entry }) => entry);
505
694
  }
506
- async function walk(dir, acc, depth) {
695
+ async function walk(dir, depth, seen, root) {
507
696
  let names;
508
697
  try {
509
- names = await (0, import_promises4.readdir)(dir);
698
+ names = await (0, import_promises5.readdir)(dir);
510
699
  } catch {
511
- return;
700
+ return [];
512
701
  }
513
- for (const name of names) {
514
- if (name.startsWith(".")) {
515
- continue;
516
- }
702
+ const perEntry = await mapLimit(names, async (name) => {
703
+ if (name.startsWith(".") && !PACKAGE_STORE_DIRS.has(name)) return [];
517
704
  const fullPath = import_node_path4.default.join(dir, name);
518
- if (!await isPlainDirectory(fullPath)) {
519
- continue;
520
- }
705
+ const resolved = await claim(fullPath, seen, depth);
706
+ if (resolved === null) return [];
707
+ if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen, root);
521
708
  if (name.startsWith("@")) {
522
709
  let subNames;
523
710
  try {
524
- subNames = await (0, import_promises4.readdir)(fullPath);
711
+ subNames = await (0, import_promises5.readdir)(fullPath);
525
712
  } catch {
526
- continue;
713
+ return [];
527
714
  }
528
- for (const sub of subNames) {
529
- if (sub.startsWith(".")) {
530
- continue;
531
- }
715
+ const scoped = await mapLimit(subNames, async (sub) => {
716
+ if (sub.startsWith(".")) return [];
532
717
  const scopedDir = import_node_path4.default.join(fullPath, sub);
533
- if (!await isPlainDirectory(scopedDir)) {
534
- continue;
535
- }
536
- await readPackage(scopedDir, depth, acc);
537
- await walkNested(scopedDir, acc, depth);
538
- }
539
- continue;
718
+ const scopedReal = await claim(scopedDir, seen, depth);
719
+ if (scopedReal === null) return [];
720
+ return packageAndNested(scopedDir, scopedReal, depth, seen, root);
721
+ });
722
+ return scoped.flat();
540
723
  }
541
- await readPackage(fullPath, depth, acc);
542
- await walkNested(fullPath, acc, depth);
543
- }
724
+ return packageAndNested(fullPath, resolved, depth, seen, root);
725
+ });
726
+ return perEntry.flat();
727
+ }
728
+ async function packageAndNested(pkgDir, resolved, depth, seen, root) {
729
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
730
+ return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
544
731
  }
545
- async function readPackage(pkgDir, depth, acc) {
732
+ async function mapLimit(items, fn) {
733
+ const results = new Array(items.length);
734
+ let next = 0;
735
+ const workers = Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
736
+ while (next < items.length) {
737
+ const index = next++;
738
+ results[index] = await fn(items[index]);
739
+ }
740
+ });
741
+ await Promise.all(workers);
742
+ return results;
743
+ }
744
+ function installLocation(root, pkgDir, impl = import_node_path4.default) {
745
+ return impl.relative(root, pkgDir).split(impl.sep).join("/");
746
+ }
747
+ async function readPackage(pkgDir, depth, root) {
546
748
  let raw;
547
749
  try {
548
- raw = await (0, import_promises4.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
750
+ raw = await (0, import_promises5.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
549
751
  } catch {
550
- return;
752
+ return null;
551
753
  }
552
754
  let parsed;
553
755
  try {
554
756
  parsed = JSON.parse(raw);
555
757
  } catch {
556
- return;
758
+ return null;
557
759
  }
558
760
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
559
- return;
761
+ return null;
560
762
  }
561
763
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
562
- return;
764
+ return null;
563
765
  }
564
- acc.push({
565
- name: parsed.name,
566
- version: parsed.version,
567
- direct: depth === 0
568
- });
766
+ return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
569
767
  }
570
- async function walkNested(pkgDir, acc, depth) {
768
+ async function walkNested(pkgDir, depth, seen, root) {
571
769
  const nested = import_node_path4.default.join(pkgDir, "node_modules");
572
- if (!await isPlainDirectory(nested)) {
573
- return;
770
+ if (await claim(nested, seen, depth + 1) === null) {
771
+ return [];
574
772
  }
575
- await walk(nested, acc, depth + 1);
773
+ return walk(nested, depth + 1, seen, root);
576
774
  }
577
- async function isPlainDirectory(dir) {
775
+ async function claim(dir, seen, depth) {
776
+ let resolved;
578
777
  try {
579
- const info = await (0, import_promises4.lstat)(dir);
580
- return info.isDirectory() && !info.isSymbolicLink();
778
+ const info = await (0, import_promises5.lstat)(dir);
779
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
780
+ resolved = await (0, import_promises5.realpath)(dir);
781
+ const target = await (0, import_promises5.stat)(resolved);
782
+ if (!target.isDirectory()) return null;
581
783
  } catch {
582
- return false;
784
+ return null;
583
785
  }
786
+ const claimed = seen.get(resolved);
787
+ if (claimed !== void 0 && claimed <= depth) return null;
788
+ seen.set(resolved, depth);
789
+ return resolved;
584
790
  }
585
- var import_promises4, import_node_path4;
791
+ var import_promises5, import_node_path4, PACKAGE_STORE_DIRS, CONCURRENCY;
586
792
  var init_node_modules = __esm({
587
793
  "src/parsers/node_modules.ts"() {
588
794
  "use strict";
589
- import_promises4 = require("fs/promises");
795
+ import_promises5 = require("fs/promises");
590
796
  import_node_path4 = __toESM(require("path"), 1);
591
797
  init_types();
798
+ PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
799
+ CONCURRENCY = 32;
592
800
  }
593
801
  });
594
802
 
595
803
  // src/parsers/pnpm.ts
596
- async function parsePnpmLockfile(lockfilePath) {
804
+ async function parsePnpmLockfile(lockfilePath, report) {
597
805
  let raw;
598
806
  try {
599
- raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
807
+ raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
600
808
  } catch (cause) {
601
809
  throw new PatchstackError(
602
810
  `Could not read lockfile at ${lockfilePath}`,
@@ -617,6 +825,7 @@ async function parsePnpmLockfile(lockfilePath) {
617
825
  for (const key of packageKeys) {
618
826
  const parsed = parsePackageKey(key);
619
827
  if (parsed === null) {
828
+ recordUnreadable(report, key);
620
829
  continue;
621
830
  }
622
831
  entries.push({
@@ -837,12 +1046,13 @@ function extractLeafName(trimmed) {
837
1046
  }
838
1047
  return name.length > 0 ? name : null;
839
1048
  }
840
- var import_promises5, DEP_SECTIONS;
1049
+ var import_promises6, DEP_SECTIONS;
841
1050
  var init_pnpm = __esm({
842
1051
  "src/parsers/pnpm.ts"() {
843
1052
  "use strict";
844
- import_promises5 = require("fs/promises");
1053
+ import_promises6 = require("fs/promises");
845
1054
  init_types();
1055
+ init_report();
846
1056
  DEP_SECTIONS = /* @__PURE__ */ new Set([
847
1057
  "dependencies",
848
1058
  "devDependencies",
@@ -852,10 +1062,10 @@ var init_pnpm = __esm({
852
1062
  });
853
1063
 
854
1064
  // src/parsers/yarn.ts
855
- async function parseYarnLockfile(lockfilePath) {
1065
+ async function parseYarnLockfile(lockfilePath, report) {
856
1066
  let raw;
857
1067
  try {
858
- raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
1068
+ raw = await (0, import_promises7.readFile)(lockfilePath, "utf8");
859
1069
  } catch (cause) {
860
1070
  throw new PatchstackError(
861
1071
  `Could not read lockfile at ${lockfilePath}`,
@@ -863,7 +1073,7 @@ async function parseYarnLockfile(lockfilePath) {
863
1073
  cause
864
1074
  );
865
1075
  }
866
- const blocks = parseBlocks(raw);
1076
+ const blocks = parseBlocks(raw, report);
867
1077
  if (blocks.length === 0) {
868
1078
  throw new PatchstackError(
869
1079
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -892,13 +1102,18 @@ async function parseYarnLockfile(lockfilePath) {
892
1102
  }
893
1103
  return entries;
894
1104
  }
895
- function parseBlocks(raw) {
1105
+ function parseBlocks(raw, report) {
896
1106
  const lines = raw.split(/\r?\n/);
897
1107
  const blocks = [];
898
1108
  let current = null;
899
1109
  const finalize = () => {
900
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
1110
+ if (current === null) {
1111
+ return;
1112
+ }
1113
+ if (current.version.length > 0 && current.names.size > 0) {
901
1114
  blocks.push(current);
1115
+ } else if (current.unreadable) {
1116
+ recordUnreadable(report, current.key);
902
1117
  }
903
1118
  current = null;
904
1119
  };
@@ -915,13 +1130,13 @@ function parseBlocks(raw) {
915
1130
  }
916
1131
  const keyLine = trimmed.slice(0, -1);
917
1132
  const names = /* @__PURE__ */ new Set();
1133
+ let unreadable = false;
918
1134
  for (const spec of splitDescriptors(keyLine)) {
919
- const name = extractName(spec);
920
- if (name !== null) {
921
- names.add(name);
922
- }
1135
+ const classified = classifyDescriptor(spec);
1136
+ if (classified.name !== void 0) names.add(classified.name);
1137
+ else if (!classified.excluded) unreadable = true;
923
1138
  }
924
- current = { names, version: "" };
1139
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
925
1140
  continue;
926
1141
  }
927
1142
  if (current === null) {
@@ -976,20 +1191,36 @@ function splitDescriptors(keyLine) {
976
1191
  }
977
1192
  return parts;
978
1193
  }
979
- function extractName(rawSpec) {
1194
+ function classifyDescriptor(rawSpec) {
980
1195
  let s = rawSpec.trim();
981
1196
  if (s.length === 0) {
982
- return null;
1197
+ return { excluded: true };
983
1198
  }
984
1199
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
985
1200
  s = s.slice(1, -1);
986
1201
  }
987
- const atIdx = s.lastIndexOf("@");
1202
+ if (NON_PACKAGE_KEYS.has(s)) {
1203
+ return { excluded: true };
1204
+ }
1205
+ const atIdx = s.indexOf("@", 1);
988
1206
  if (atIdx <= 0) {
989
- return null;
1207
+ return {};
990
1208
  }
991
1209
  const name = s.slice(0, atIdx);
992
- return name.length > 0 ? name : null;
1210
+ const range = s.slice(atIdx + 1);
1211
+ if (name.length === 0) {
1212
+ return {};
1213
+ }
1214
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
1215
+ return { excluded: true };
1216
+ }
1217
+ const alias = /^npm:(.+)$/i.exec(range);
1218
+ if (alias !== null) {
1219
+ const target = alias[1] ?? "";
1220
+ const targetAt = target.indexOf("@", 1);
1221
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
1222
+ }
1223
+ return { name };
993
1224
  }
994
1225
  function parseVersionField(content) {
995
1226
  if (!content.startsWith("version")) {
@@ -1014,7 +1245,7 @@ async function readDirectDepNames(cwd) {
1014
1245
  const names = /* @__PURE__ */ new Set();
1015
1246
  let raw;
1016
1247
  try {
1017
- raw = await (0, import_promises6.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
1248
+ raw = await (0, import_promises7.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
1018
1249
  } catch {
1019
1250
  return names;
1020
1251
  }
@@ -1039,13 +1270,16 @@ async function readDirectDepNames(cwd) {
1039
1270
  }
1040
1271
  return names;
1041
1272
  }
1042
- var import_promises6, import_node_path5;
1273
+ var import_promises7, import_node_path5, NON_PACKAGE_KEYS, NON_REGISTRY_PROTOCOLS;
1043
1274
  var init_yarn = __esm({
1044
1275
  "src/parsers/yarn.ts"() {
1045
1276
  "use strict";
1046
- import_promises6 = require("fs/promises");
1277
+ import_promises7 = require("fs/promises");
1047
1278
  import_node_path5 = __toESM(require("path"), 1);
1048
1279
  init_types();
1280
+ init_report();
1281
+ NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
1282
+ NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
1049
1283
  }
1050
1284
  });
1051
1285
 
@@ -1080,34 +1314,58 @@ async function scanLockfile(cwd) {
1080
1314
  const declared = await readDeclaredDependencyNames(cwd);
1081
1315
  const warnings = [];
1082
1316
  let firstParsed = null;
1083
- let walkTried = false;
1317
+ let walked = null;
1318
+ let walkWasCandidate = false;
1319
+ const parsedSources = [];
1084
1320
  for (const candidate of candidates) {
1085
1321
  let packages;
1322
+ const report = newReport();
1086
1323
  try {
1087
- packages = await runStrategy(candidate, cwd);
1324
+ packages = await runStrategy(candidate, cwd, report);
1088
1325
  } catch {
1089
1326
  continue;
1090
1327
  }
1091
- walkTried ||= candidate.strategy === "node-modules-walk";
1328
+ const unreadable = unreadableWarning(candidate.filename, report);
1329
+ if (unreadable !== null) warnings.push(unreadable);
1330
+ if (candidate.strategy === "node-modules-walk") {
1331
+ walked = packages;
1332
+ walkWasCandidate = true;
1333
+ }
1092
1334
  firstParsed ??= { packages, filename: candidate.filename };
1093
- const missing = missingDependencies(declared, packages);
1094
- if (missing.length === 0) {
1095
- return manifestWith(packages, warnings);
1335
+ parsedSources.push({ packages, filename: candidate.filename });
1336
+ }
1337
+ for (const source of parsedSources) {
1338
+ const missing = missingDependencies(declared, source.packages);
1339
+ if (missing.length > 0) {
1340
+ warnings.push(staleWarning(source.filename, missing));
1341
+ continue;
1096
1342
  }
1097
- warnings.push(staleWarning(candidate.filename, missing));
1343
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
1344
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
1345
+ );
1346
+ if (conflicts.length === 0) {
1347
+ return manifestWith(source.packages, warnings);
1348
+ }
1349
+ warnings.push(conflictWarning(source.filename, conflicts));
1350
+ break;
1098
1351
  }
1099
- if (!walkTried) {
1352
+ if (walked === null) {
1100
1353
  try {
1101
- const packages = await walkNodeModules(cwd);
1102
- const missing = missingDependencies(declared, packages);
1103
- if (missing.length > 0) {
1104
- warnings.push(staleWarning("node_modules/", missing));
1105
- }
1106
- warnings.push("Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.");
1107
- return manifestWith(packages, warnings);
1354
+ walked = await walkNodeModules(cwd);
1108
1355
  } catch {
1356
+ walked = null;
1109
1357
  }
1110
1358
  }
1359
+ if (walked !== null) {
1360
+ const missing = missingDependencies(declared, walked);
1361
+ if (missing.length > 0) {
1362
+ warnings.push(staleWarning("node_modules/", missing));
1363
+ }
1364
+ warnings.push(
1365
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
1366
+ );
1367
+ return manifestWith(walked, warnings);
1368
+ }
1111
1369
  if (firstParsed === null) {
1112
1370
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
1113
1371
  throw new PatchstackError(
@@ -1123,6 +1381,15 @@ async function scanLockfile(cwd) {
1123
1381
  function manifestWith(packages, warnings) {
1124
1382
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
1125
1383
  }
1384
+ function otherCompleteSources(all, current, declared) {
1385
+ return all.filter(
1386
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1387
+ );
1388
+ }
1389
+ function conflictWarning(filename, conflicts) {
1390
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1391
+ return `${filename} and another lockfile disagree about installed versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.`;
1392
+ }
1126
1393
  function staleWarning(source, missing) {
1127
1394
  const sample = missing.slice(0, 3).join(", ");
1128
1395
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -1137,41 +1404,50 @@ async function presentLockfiles(cwd) {
1137
1404
  );
1138
1405
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
1139
1406
  }
1140
- async function runStrategy(detected, cwd) {
1407
+ async function runStrategy(detected, cwd, report) {
1141
1408
  switch (detected.strategy) {
1142
1409
  case "npm-lockfile":
1143
1410
  return parseNpmLockfile(detected.filePath);
1411
+ case "bun-lockfile":
1412
+ return parseBunLockfile(detected.filePath, report);
1413
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1414
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
1144
1415
  case "pnpm-lockfile":
1145
- return parsePnpmLockfile(detected.filePath);
1416
+ return parsePnpmLockfile(detected.filePath, report);
1146
1417
  case "yarn-lockfile":
1147
- return parseYarnLockfile(detected.filePath);
1418
+ return parseYarnLockfile(detected.filePath, report);
1148
1419
  case "node-modules-walk":
1149
1420
  return walkNodeModules(cwd);
1150
1421
  }
1151
1422
  }
1152
1423
  async function exists(filePath) {
1153
1424
  try {
1154
- await (0, import_promises7.access)(filePath);
1425
+ await (0, import_promises8.access)(filePath);
1155
1426
  return true;
1156
1427
  } catch {
1157
1428
  return false;
1158
1429
  }
1159
1430
  }
1160
- var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
1431
+ var import_promises8, import_node_path6, LOCKFILE_CANDIDATES;
1161
1432
  var init_parsers = __esm({
1162
1433
  "src/parsers/index.ts"() {
1163
1434
  "use strict";
1164
- import_promises7 = require("fs/promises");
1435
+ import_promises8 = require("fs/promises");
1165
1436
  import_node_path6 = __toESM(require("path"), 1);
1166
1437
  init_types();
1167
1438
  init_consistency();
1439
+ init_bun();
1168
1440
  init_npm();
1169
1441
  init_node_modules();
1170
1442
  init_pnpm();
1171
1443
  init_yarn();
1444
+ init_report();
1172
1445
  LOCKFILE_CANDIDATES = [
1173
1446
  { filename: "package-lock.json", strategy: "npm-lockfile" },
1174
- { filename: "bun.lock", strategy: "node-modules-walk" },
1447
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
1448
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
1449
+ // for new workspaces.
1450
+ { filename: "bun.lock", strategy: "bun-lockfile" },
1175
1451
  { filename: "bun.lockb", strategy: "node-modules-walk" },
1176
1452
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
1177
1453
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -1180,21 +1456,33 @@ var init_parsers = __esm({
1180
1456
  });
1181
1457
 
1182
1458
  // src/normalize.ts
1183
- function buildWirePayload(manifest) {
1459
+ function buildWirePayload(manifest, options = {}) {
1460
+ const withPaths = options.installPaths === true;
1184
1461
  const seen = /* @__PURE__ */ new Map();
1185
1462
  const wirePackages = [];
1463
+ const byIdentity = /* @__PURE__ */ new Map();
1464
+ let entriesWithoutPath = 0;
1186
1465
  for (const entry of manifest.packages) {
1466
+ const identity = `${entry.name}@${entry.version}`;
1467
+ const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
1468
+ if (location === void 0) entriesWithoutPath++;
1469
+ const existing = byIdentity.get(identity);
1470
+ if (existing) {
1471
+ if (location !== void 0) addPath(existing, location);
1472
+ continue;
1473
+ }
1187
1474
  const versions = seen.get(entry.name);
1188
1475
  if (versions) {
1189
- if (versions.has(entry.version)) {
1190
- continue;
1191
- }
1192
1476
  versions.add(entry.version);
1193
1477
  } else {
1194
1478
  seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
1195
1479
  }
1196
- wirePackages.push({ name: entry.name, version: entry.version });
1480
+ const wire = { name: entry.name, version: entry.version };
1481
+ if (location !== void 0) addPath(wire, location);
1482
+ byIdentity.set(identity, wire);
1483
+ wirePackages.push(wire);
1197
1484
  }
1485
+ for (const wire of wirePackages) wire.paths?.sort();
1198
1486
  wirePackages.sort((a, b) => {
1199
1487
  if (a.name === b.name) {
1200
1488
  return compareVersions(a.version, b.version);
@@ -1208,7 +1496,18 @@ function buildWirePayload(manifest) {
1208
1496
  }
1209
1497
  }
1210
1498
  return {
1211
- payload: { ecosystem: manifest.ecosystem, packages: wirePackages },
1499
+ payload: {
1500
+ ecosystem: manifest.ecosystem,
1501
+ packages: wirePackages,
1502
+ // Every entry had a location, so a missing path anywhere means the package is not installed there.
1503
+ // One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
1504
+ // no way to tell which entry was the incomplete one.
1505
+ //
1506
+ // No separate check for "locations were not requested": with them off every entry counts as without
1507
+ // one, so this is already false — which is the default, and the safe direction. An explicit
1508
+ // `withPaths &&` here was a guard no test could distinguish from its absence.
1509
+ installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
1510
+ },
1212
1511
  stats: {
1213
1512
  uniqueNames: seen.size,
1214
1513
  duplicateNames,
@@ -1216,6 +1515,10 @@ function buildWirePayload(manifest) {
1216
1515
  }
1217
1516
  };
1218
1517
  }
1518
+ function addPath(wire, installPath) {
1519
+ if (wire.paths === void 0) wire.paths = [installPath];
1520
+ else if (!wire.paths.includes(installPath)) wire.paths.push(installPath);
1521
+ }
1219
1522
  function compareVersions(a, b) {
1220
1523
  if (a === b) {
1221
1524
  return 0;
@@ -1450,6 +1753,45 @@ function base64DecodeUtf8(value) {
1450
1753
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
1451
1754
  return new TextDecoder().decode(bytes);
1452
1755
  }
1756
+ var NAMED_ENTITIES = {
1757
+ lt: "<",
1758
+ gt: ">",
1759
+ amp: "&",
1760
+ quot: '"',
1761
+ apos: "'",
1762
+ "#39": "'",
1763
+ nbsp: "\xA0",
1764
+ sol: "/",
1765
+ bsol: "\\",
1766
+ colon: ":",
1767
+ lpar: "(",
1768
+ rpar: ")",
1769
+ equals: "=",
1770
+ grave: "`",
1771
+ Tab: " ",
1772
+ NewLine: "\n",
1773
+ semi: ";",
1774
+ excl: "!",
1775
+ num: "#",
1776
+ dollar: "$",
1777
+ percnt: "%",
1778
+ ast: "*"
1779
+ };
1780
+ function decodeHtmlEntities(input) {
1781
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
1782
+ if (body[0] === "#") {
1783
+ const hex = body[1] === "x" || body[1] === "X";
1784
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
1785
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
1786
+ try {
1787
+ return String.fromCodePoint(code);
1788
+ } catch {
1789
+ return whole;
1790
+ }
1791
+ }
1792
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
1793
+ });
1794
+ }
1453
1795
  var RequestResolver = class {
1454
1796
  #req;
1455
1797
  #cookies;
@@ -1572,6 +1914,8 @@ var RequestResolver = class {
1572
1914
  } catch {
1573
1915
  return value;
1574
1916
  }
1917
+ case "htmlentitydecode":
1918
+ return decodeHtmlEntities(String(value));
1575
1919
  case "intval":
1576
1920
  return parseInt(String(value), 10) || 0;
1577
1921
  case "getArrayValues":
@@ -2239,6 +2583,30 @@ function expandIPv6(host) {
2239
2583
  }
2240
2584
  return out;
2241
2585
  }
2586
+ var JWT_CANDIDATE = /(?<![A-Za-z0-9_-])(?<![A-Za-z0-9_-]\.)eyJ[A-Za-z0-9_-]{2,2048}\.[A-Za-z0-9_-]{2,8192}\.[A-Za-z0-9_-]{0,2048}(?![A-Za-z0-9_-])(?!\.[A-Za-z0-9_-])/g;
2587
+ function base64UrlToBase64(segment) {
2588
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
2589
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
2590
+ }
2591
+ function jwtClaimSpans(text, claim2, value) {
2592
+ const found = /* @__PURE__ */ new Set();
2593
+ if (typeof text !== "string" || typeof claim2 !== "string" || claim2 === "") return [];
2594
+ const wanted = String(value);
2595
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
2596
+ const parts = token.split(".");
2597
+ let payload;
2598
+ try {
2599
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
2600
+ } catch {
2601
+ continue;
2602
+ }
2603
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
2604
+ if (!Object.prototype.hasOwnProperty.call(payload, claim2)) continue;
2605
+ const actual = payload[claim2];
2606
+ if (typeof actual === "string" && actual === wanted) found.add(token);
2607
+ }
2608
+ return [...found];
2609
+ }
2242
2610
  var warnedScopes = /* @__PURE__ */ new Set();
2243
2611
  function warnUnrecognisedScope(when) {
2244
2612
  const key = Object.keys(when).sort().join(",");
@@ -2267,16 +2635,30 @@ function ruleAppliesTo(when, resolver) {
2267
2635
  return true;
2268
2636
  }
2269
2637
  }
2638
+ function foldPathForScope(value) {
2639
+ const out = String(value).toLowerCase();
2640
+ return out.endsWith("/") ? out.slice(0, -1) : out;
2641
+ }
2642
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
2643
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
2644
+ function isRegexForm(pattern) {
2645
+ const parts = REGEX_FORM.exec(pattern);
2646
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
2647
+ }
2270
2648
  function pathMatches(pattern, path7) {
2271
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
2649
+ if (isRegexForm(pattern)) {
2272
2650
  const re = safeRegExp(pattern);
2273
2651
  return re ? re.test(path7) : false;
2274
2652
  }
2275
- if (pattern.includes("*")) {
2276
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
2277
- return re ? re.test(path7) : false;
2653
+ const target = foldPathForScope(path7);
2654
+ const folded = foldPathForScope(pattern);
2655
+ if (folded.includes("*")) {
2656
+ const trailing = folded.endsWith("/*");
2657
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
2658
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
2659
+ return re ? re.test(target) : false;
2278
2660
  }
2279
- return path7 === pattern;
2661
+ return target === folded;
2280
2662
  }
2281
2663
  function normalizeDefaultPort(host) {
2282
2664
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -2429,6 +2811,8 @@ function matchValue(type, value, matchVal, matchObj) {
2429
2811
  const stripped = strValue.replace(/[ _\-,]/g, "");
2430
2812
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
2431
2813
  }
2814
+ case "jwt_claim_equals":
2815
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
2432
2816
  case "array_key_value":
2433
2817
  return arrayKeyValue(value, matchObj);
2434
2818
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -3195,13 +3579,43 @@ var DEFAULT_RESPONSE_RULES = [
3195
3579
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
3196
3580
  },
3197
3581
  {
3198
- id: "resp-jwt",
3199
- title: "JWT in response body",
3582
+ id: "resp-supabase-secret-key",
3583
+ title: "Supabase secret key in response body",
3584
+ phase: "response",
3585
+ category: "secret-exposure",
3586
+ action: "redact",
3587
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
3588
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
3589
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
3590
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
3591
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
3592
+ //
3593
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
3594
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
3595
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
3596
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
3597
+ // characters, which is not this format.
3598
+ prefilter: ["sb_secret_"],
3599
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
3600
+ },
3601
+ {
3602
+ id: "resp-supabase-service-role-key",
3603
+ title: "Supabase service_role key in response body",
3200
3604
  phase: "response",
3201
3605
  category: "secret-exposure",
3202
3606
  action: "redact",
3607
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
3608
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
3609
+ // by design and a login response carries the user's own access token — so this decides on the
3610
+ // decoded payload rather than on the shape of the token.
3611
+ //
3612
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
3613
+ // tokens and serve the rest of the response.
3614
+ //
3615
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
3616
+ // an opt-in, observe-only rule rather than an on-by-default mask.
3203
3617
  prefilter: ["eyJ"],
3204
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
3618
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
3205
3619
  },
3206
3620
  {
3207
3621
  id: "resp-db-connection-string",
@@ -3426,25 +3840,468 @@ function toEnvelope(value) {
3426
3840
  return null;
3427
3841
  }
3428
3842
 
3429
- // src/protect/rules/validate.js
3430
- var LIMITS = {
3843
+ // src/protect/rules/contract.js
3844
+ var SOURCES = Object.freeze({
3845
+ raw: Object.freeze({ keyed: false }),
3846
+ all: Object.freeze({ keyed: false }),
3847
+ false: Object.freeze({ keyed: false }),
3848
+ rules: Object.freeze({ keyed: false, group: true }),
3849
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3850
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3851
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3852
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3853
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
3854
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
3855
+ files: Object.freeze({
3856
+ keyed: true,
3857
+ keys: "any",
3858
+ wildcard: true,
3859
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
3860
+ }),
3861
+ server: Object.freeze({
3862
+ keyed: true,
3863
+ keys: Object.freeze([
3864
+ "REQUEST_URI",
3865
+ "REQUEST_METHOD",
3866
+ "HTTP_USER_AGENT",
3867
+ "HTTP_REFERER",
3868
+ "HTTP_HOST",
3869
+ "REMOTE_ADDR",
3870
+ "ip",
3871
+ "CONTENT_TYPE",
3872
+ "CONTENT_LENGTH"
3873
+ ]),
3874
+ key_prefixes: Object.freeze(["HTTP_"])
3875
+ }),
3876
+ response: Object.freeze({
3877
+ keyed: true,
3878
+ keys: Object.freeze(["status", "body", "headers"]),
3879
+ key_prefixes: Object.freeze(["header."])
3880
+ }),
3881
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
3882
+ });
3883
+ var MATCH_TYPES = Object.freeze([
3884
+ "array_in_array",
3885
+ "array_key_value",
3886
+ "contains",
3887
+ "cors_reflected",
3888
+ "cross_origin",
3889
+ "ctype_alnum",
3890
+ "ctype_digit",
3891
+ "ctype_special",
3892
+ "equals",
3893
+ "equals_strict",
3894
+ "hostname",
3895
+ "in_array",
3896
+ "inline_js_xss",
3897
+ "inline_xss",
3898
+ "internal_host",
3899
+ "is_numeric",
3900
+ "isset",
3901
+ "jwt_claim_equals",
3902
+ "less_than",
3903
+ "more_than",
3904
+ "not_contains",
3905
+ "not_in_array",
3906
+ "off_origin",
3907
+ "quotes",
3908
+ "regex",
3909
+ "stripos"
3910
+ ]);
3911
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
3912
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
3913
+ "isset",
3914
+ "quotes",
3915
+ "is_numeric",
3916
+ "ctype_alnum",
3917
+ "ctype_digit",
3918
+ "ctype_special",
3919
+ "inline_xss",
3920
+ "inline_js_xss",
3921
+ "internal_host",
3922
+ "cross_origin",
3923
+ "off_origin",
3924
+ "cors_reflected"
3925
+ ]);
3926
+ var OPERAND_SHAPES = Object.freeze([
3927
+ "positive-number",
3928
+ "boolean",
3929
+ "non-empty-string",
3930
+ "regex-literal",
3931
+ "string-or-string-list",
3932
+ "scalar",
3933
+ "string",
3934
+ "numeric",
3935
+ "scalar-or-non-empty-list",
3936
+ "non-empty-list",
3937
+ "object",
3938
+ "non-empty-object",
3939
+ "non-empty-string-list",
3940
+ "header-name-map",
3941
+ "header-name-list",
3942
+ "cookie-flags"
3943
+ ]);
3944
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
3945
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
3946
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
3947
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
3948
+ var MATCH_OPERANDS = Object.freeze({
3949
+ array_key_value: Object.freeze({
3950
+ required: Object.freeze(["key", "match"]),
3951
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
3952
+ }),
3953
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
3954
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
3955
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
3956
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
3957
+ jwt_claim_equals: Object.freeze({
3958
+ required: Object.freeze(["claim", "value"]),
3959
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
3960
+ }),
3961
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
3962
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3963
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3964
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3965
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
3966
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
3967
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
3968
+ // `array_in_array` inert and `not_in_array` true for every value.
3969
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3970
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3971
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3972
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
3973
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
3974
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
3975
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
3976
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
3977
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
3978
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
3979
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
3980
+ });
3981
+ function operandShapeProblem(shape, value) {
3982
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3983
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3984
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
3985
+ switch (shape) {
3986
+ case "positive-number":
3987
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
3988
+ case "boolean":
3989
+ return typeof value === "boolean" ? null : "must be true or false";
3990
+ case "non-empty-string":
3991
+ return isFilledString(value) ? null : "must be a non-empty string";
3992
+ case "regex-literal":
3993
+ if (!isFilledString(value)) return "must be a non-empty string";
3994
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
3995
+ case "string-or-string-list":
3996
+ if (isFilledString(value)) return null;
3997
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
3998
+ return "must be a non-empty string, or a non-empty list of them";
3999
+ case "scalar":
4000
+ return isScalar(value) ? null : "must be a string, number or boolean";
4001
+ case "string":
4002
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
4003
+ case "numeric":
4004
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
4005
+ case "scalar-or-non-empty-list":
4006
+ if (isScalar(value)) return null;
4007
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
4008
+ return "must be a scalar, or a non-empty list of them";
4009
+ case "non-empty-list":
4010
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
4011
+ case "header-name-map": {
4012
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
4013
+ const names = Object.keys(value);
4014
+ if (names.length === 0) return "must be an object with at least one entry";
4015
+ const bad = names.find((name) => !isHeaderName(name));
4016
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4017
+ }
4018
+ case "header-name-list": {
4019
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
4020
+ const bad = value.find((name) => !isHeaderName(name));
4021
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4022
+ }
4023
+ case "cookie-flags": {
4024
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
4025
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
4026
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
4027
+ for (const flag of ["httpOnly", "secure"]) {
4028
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
4029
+ return `flag "${flag}" must be true or false`;
4030
+ }
4031
+ }
4032
+ const sameSite = value.sameSite;
4033
+ const sameSiteOff = sameSite === false;
4034
+ if (sameSite !== void 0 && !sameSiteOff) {
4035
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
4036
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
4037
+ }
4038
+ }
4039
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
4040
+ return "turns off every flag, so it would harden nothing";
4041
+ }
4042
+ return null;
4043
+ }
4044
+ case "object":
4045
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
4046
+ case "non-empty-object":
4047
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
4048
+ case "non-empty-string-list":
4049
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
4050
+ default:
4051
+ return null;
4052
+ }
4053
+ }
4054
+ var MUTATIONS = Object.freeze([
4055
+ "base64_decode",
4056
+ "getArrayValues",
4057
+ "htmlentitydecode",
4058
+ "intval",
4059
+ "json_decode",
4060
+ "json_encode",
4061
+ "urldecode"
4062
+ ]);
4063
+ var PHASES = Object.freeze(["request", "response", "egress"]);
4064
+ var ACTIONS = Object.freeze([
4065
+ "block",
4066
+ "redact",
4067
+ "encode",
4068
+ "set-header",
4069
+ "remove-header",
4070
+ "harden-cookie"
4071
+ ]);
4072
+ var RULE_PROPERTY_SHAPES = Object.freeze({
4073
+ max_bytes: "positive-number",
4074
+ bypass_limit: "boolean",
4075
+ ensure: "boolean",
4076
+ prefilter: "non-empty-string-list"
4077
+ });
4078
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
4079
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
4080
+ var RULE_PROPERTIES = Object.freeze([
4081
+ "id",
4082
+ "rule_id",
4083
+ "title",
4084
+ "category",
4085
+ "phase",
4086
+ "action",
4087
+ "rule_v2",
4088
+ "when",
4089
+ "message",
4090
+ "enforcement",
4091
+ "source_revision",
4092
+ "prefilter",
4093
+ "max_bytes",
4094
+ "bypass_limit",
4095
+ "set_headers",
4096
+ "remove_headers",
4097
+ "cookie_flags",
4098
+ "ensure"
4099
+ ]);
4100
+ var ACTION_PROPERTIES = Object.freeze({
4101
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
4102
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
4103
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
4104
+ "set-header": Object.freeze({
4105
+ required: Object.freeze(["set_headers"]),
4106
+ defaulted: Object.freeze(["ensure"]),
4107
+ phases: Object.freeze(["response"]),
4108
+ shapes: Object.freeze({ set_headers: "header-name-map" })
4109
+ }),
4110
+ "remove-header": Object.freeze({
4111
+ required: Object.freeze(["remove_headers"]),
4112
+ defaulted: Object.freeze([]),
4113
+ phases: Object.freeze(["response"]),
4114
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
4115
+ }),
4116
+ "harden-cookie": Object.freeze({
4117
+ required: Object.freeze([]),
4118
+ defaulted: Object.freeze(["cookie_flags"]),
4119
+ phases: Object.freeze(["response"]),
4120
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
4121
+ })
4122
+ });
4123
+ var LIMITS = Object.freeze({
3431
4124
  maxRules: 5e3,
3432
4125
  maxWhitelists: 2e3,
3433
4126
  maxConditionsPerRule: 250,
3434
4127
  maxNestingDepth: 12,
3435
4128
  maxRegexLength: 1e3,
3436
4129
  maxValueLength: 8192
3437
- };
3438
- var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
3439
- var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
4130
+ });
4131
+ var GROUP_PARAMETER = "rules";
4132
+ function isGroup(condition) {
4133
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
4134
+ }
4135
+ function conditionShapeProblem(condition) {
4136
+ if (!condition || typeof condition !== "object") return "condition is not an object";
4137
+ const named = condition.parameter === GROUP_PARAMETER;
4138
+ const nested = Array.isArray(condition.rules);
4139
+ if (named && !nested) {
4140
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
4141
+ }
4142
+ if (nested && !named) {
4143
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4144
+ }
4145
+ if (named && nested) {
4146
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4147
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
4148
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
4149
+ }
4150
+ return null;
4151
+ }
4152
+ function parameterProblem(parameter) {
4153
+ if (parameter === void 0 || parameter === null) return null;
4154
+ if (Array.isArray(parameter)) {
4155
+ if (parameter.length === 0) return "parameter list is empty";
4156
+ for (const member of parameter) {
4157
+ const problem = parameterProblem(member);
4158
+ if (problem !== null) return problem;
4159
+ }
4160
+ return null;
4161
+ }
4162
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
4163
+ if (parameter === GROUP_PARAMETER) {
4164
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
4165
+ }
4166
+ const dot = parameter.indexOf(".");
4167
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
4168
+ const source = SOURCES[name];
4169
+ if (source === void 0) return `unknown parameter source "${name}"`;
4170
+ if (dot === -1) {
4171
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
4172
+ }
4173
+ if (source.keyed !== true) return `source "${name}" takes no key`;
4174
+ const key = parameter.slice(dot + 1);
4175
+ if (key === "") return `source "${name}" needs a key after the dot`;
4176
+ return keyProblem(name, source, key);
4177
+ }
4178
+ function keyProblem(name, source, key) {
4179
+ if (key.endsWith("*") && source.wildcard !== true) {
4180
+ return `"${name}" does not fan out over a wildcard key`;
4181
+ }
4182
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
4183
+ if (source.keys === "any") return null;
4184
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
4185
+ for (const prefix of source.key_prefixes ?? []) {
4186
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
4187
+ }
4188
+ const accepted = [
4189
+ ...Array.isArray(source.keys) ? source.keys : [],
4190
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
4191
+ ].join(", ");
4192
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
4193
+ }
4194
+ function matchProblem(match, parameter) {
4195
+ if (!match || typeof match !== "object") return "condition has no match object";
4196
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
4197
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
4198
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
4199
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
4200
+ return `match type "${match.type}" needs a parameter`;
4201
+ }
4202
+ if (parameterless && parameter !== void 0 && parameter !== null) {
4203
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
4204
+ }
4205
+ const operands = MATCH_OPERANDS[match.type];
4206
+ if (operands) {
4207
+ for (const required of operands.required) {
4208
+ if (match[required] === void 0 || match[required] === null) {
4209
+ return `match type "${match.type}" needs "${required}"`;
4210
+ }
4211
+ }
4212
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
4213
+ if (match[operand] === void 0) continue;
4214
+ const problem = operandShapeProblem(shape, match[operand]);
4215
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
4216
+ }
4217
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
4218
+ return `match type "${match.type}" needs a value`;
4219
+ }
4220
+ return null;
4221
+ }
4222
+ function mutationsProblem(mutations) {
4223
+ if (mutations === void 0 || mutations === null) return null;
4224
+ if (!Array.isArray(mutations)) return "mutations must be an array";
4225
+ for (const mutation of mutations) {
4226
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
4227
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
4228
+ }
4229
+ }
4230
+ return null;
4231
+ }
4232
+ function whenProblem(when) {
4233
+ if (when === void 0 || when === null) return null;
4234
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
4235
+ const keys = Object.keys(when);
4236
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
4237
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
4238
+ if (unknown.length > 0) {
4239
+ 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`;
4240
+ }
4241
+ if ("path" in when && !isNonEmptyString(when.path)) {
4242
+ return "when.path must be a non-empty string";
4243
+ }
4244
+ if ("method" in when) {
4245
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
4246
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
4247
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
4248
+ }
4249
+ return null;
4250
+ }
4251
+ function isNonEmptyString(value) {
4252
+ return typeof value === "string" && value.trim() !== "";
4253
+ }
4254
+ function nullPropertyProblem(rule) {
4255
+ if (!rule || typeof rule !== "object") return null;
4256
+ for (const property of RULE_PROPERTIES) {
4257
+ if (property in rule && rule[property] === null) {
4258
+ return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
4259
+ }
4260
+ }
4261
+ return null;
4262
+ }
4263
+ function rulePropertyProblem(rule) {
4264
+ if (!rule || typeof rule !== "object") return null;
4265
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
4266
+ if (rule[property] === void 0 || rule[property] === null) continue;
4267
+ const problem = operandShapeProblem(shape, rule[property]);
4268
+ if (problem) return `"${property}" ${problem}`;
4269
+ }
4270
+ return null;
4271
+ }
4272
+ function actionProblem(action, rule) {
4273
+ if (action === void 0 || action === null) return null;
4274
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
4275
+ const spec = ACTION_PROPERTIES[action];
4276
+ for (const required of spec?.required ?? []) {
4277
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
4278
+ return `action "${action}" needs "${required}"`;
4279
+ }
4280
+ }
4281
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
4282
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
4283
+ const problem = operandShapeProblem(shape, rule[property]);
4284
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
4285
+ }
4286
+ const phase = rule?.phase ?? "request";
4287
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
4288
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
4289
+ }
4290
+ return null;
4291
+ }
4292
+
4293
+ // src/protect/rules/validate.js
4294
+ var LIMITS2 = LIMITS;
4295
+ var PHASES2 = new Set(PHASES);
4296
+ var ACTIONS2 = new Set(ACTIONS);
3440
4297
  function validateBundle(bundle, opts = {}) {
3441
4298
  const rejected = [];
3442
4299
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
3443
4300
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
3444
4301
  const firewall = [];
3445
4302
  for (const rule of inFirewall) {
3446
- if (firewall.length >= LIMITS.maxRules) {
3447
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
4303
+ if (firewall.length >= LIMITS2.maxRules) {
4304
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
3448
4305
  continue;
3449
4306
  }
3450
4307
  const reason = ruleProblem(rule);
@@ -3453,8 +4310,8 @@ function validateBundle(bundle, opts = {}) {
3453
4310
  }
3454
4311
  const whitelists = [];
3455
4312
  for (const wl of inWhitelists) {
3456
- if (whitelists.length >= LIMITS.maxWhitelists) {
3457
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
4313
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
4314
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
3458
4315
  continue;
3459
4316
  }
3460
4317
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -3476,37 +4333,47 @@ function idOf(rule) {
3476
4333
  }
3477
4334
  function ruleProblem(rule) {
3478
4335
  if (!rule || typeof rule !== "object") return "not an object";
3479
- if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
3480
- if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
3481
- const capOverride = rule.max_bytes;
3482
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
4336
+ const nullReason = nullPropertyProblem(rule);
4337
+ if (nullReason) return nullReason;
4338
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
4339
+ const actionReason = actionProblem(rule.action, rule);
4340
+ if (actionReason) return actionReason;
4341
+ const scopeReason = whenProblem(rule.when);
4342
+ if (scopeReason) return scopeReason;
4343
+ const propertyReason = rulePropertyProblem(rule);
4344
+ if (propertyReason) return propertyReason;
3483
4345
  return conditionsProblem(rule.rule_v2);
3484
4346
  }
3485
4347
  function conditionsProblem(conditions, depth = 0) {
3486
4348
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
3487
4349
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
3488
- if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
3489
- if (conditions.length > LIMITS.maxConditionsPerRule) {
3490
- return `more than ${LIMITS.maxConditionsPerRule} conditions`;
4350
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
4351
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
4352
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
3491
4353
  }
3492
4354
  for (const c of conditions) {
3493
- if (!c || typeof c !== "object") return "condition is not an object";
3494
- if (Array.isArray(c.rules)) {
4355
+ const shapeReason = conditionShapeProblem(c);
4356
+ if (shapeReason) return shapeReason;
4357
+ if (isGroup(c)) {
3495
4358
  const nested = conditionsProblem(c.rules, depth + 1);
3496
4359
  if (nested) return nested;
3497
4360
  continue;
3498
4361
  }
4362
+ const parameterReason = parameterProblem(c.parameter);
4363
+ if (parameterReason) return parameterReason;
4364
+ const mutationReason = mutationsProblem(c.mutations);
4365
+ if (mutationReason) return mutationReason;
4366
+ const matchReason = matchProblem(c.match, c.parameter);
4367
+ if (matchReason) return matchReason;
3499
4368
  const m = c.match;
3500
- if (!m || typeof m !== "object") return "condition has no match object";
3501
- if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
3502
4369
  if (m.type === "regex") {
3503
4370
  if (typeof m.value !== "string") return "regex match.value must be a string";
3504
- if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
3505
- } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
3506
- return `match.value longer than ${LIMITS.maxValueLength} chars`;
4371
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
4372
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
4373
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
3507
4374
  }
3508
4375
  if (m.match) {
3509
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
4376
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
3510
4377
  if (nested) return nested;
3511
4378
  }
3512
4379
  }
@@ -3534,64 +4401,67 @@ function reportRejections(rejected, options, label) {
3534
4401
  `${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" : ""}`
3535
4402
  ), "onError");
3536
4403
  }
4404
+ function fromSource(bundle, reason) {
4405
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
4406
+ }
3537
4407
  async function resolveRules(options, store, ctx = {}) {
3538
4408
  const timeoutMs = ctx.timeoutMs;
3539
4409
  if (options.siteUuid) {
3540
4410
  const prior = await store.read();
3541
4411
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
3542
4412
  const res = await client.getRules();
3543
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
4413
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3544
4414
  if (res.success && !res.notModified) {
3545
4415
  const rejected = liveUpdateRejections(res, options);
3546
4416
  if (rejected.length > 0) {
3547
4417
  reportRejections(rejected, options, "rule update rejected");
3548
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
3549
- if (options.rules) return normalizeBundle(options.rules, options);
3550
- return emptyBundle();
4418
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4419
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4420
+ return fromSource(emptyBundle(), "update rejected");
3551
4421
  }
3552
4422
  const bundle = normalizeBundle(res, options);
3553
4423
  await store.write({ bundle, etag: res.etag ?? null });
3554
- return bundle;
4424
+ return fromSource(bundle);
3555
4425
  }
3556
4426
  if (prior?.bundle) {
3557
4427
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3558
- return normalizeBundle(prior.bundle, options);
4428
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3559
4429
  }
3560
4430
  if (options.rules) {
3561
4431
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
3562
- return normalizeBundle(options.rules, options);
4432
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
3563
4433
  }
3564
4434
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3565
- return emptyBundle();
4435
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
3566
4436
  }
3567
4437
  if (options.token) {
3568
4438
  const prior = await store.read();
3569
4439
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
3570
4440
  const res = await client.getRules();
3571
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
4441
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3572
4442
  if (res.success && !res.notModified) {
3573
4443
  const rejected = liveUpdateRejections(res, options);
3574
4444
  if (rejected.length > 0) {
3575
4445
  reportRejections(rejected, options, "rule update rejected");
3576
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
3577
- if (options.rules) return normalizeBundle(options.rules, options);
3578
- return emptyBundle();
4446
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4447
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4448
+ return fromSource(emptyBundle(), "update rejected");
3579
4449
  }
3580
4450
  const bundle = normalizeBundle(res, options);
3581
4451
  await store.write({ bundle, etag: res.etag ?? null });
3582
- return bundle;
4452
+ return fromSource(bundle);
3583
4453
  }
3584
4454
  if (prior?.bundle) {
3585
4455
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3586
- return normalizeBundle(prior.bundle, options);
4456
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3587
4457
  }
3588
4458
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3589
- return emptyBundle();
4459
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
3590
4460
  }
3591
4461
  if (options.rules) {
3592
- return normalizeBundle(options.rules, options);
4462
+ return fromSource(normalizeBundle(options.rules, options));
3593
4463
  }
3594
- return emptyBundle();
4464
+ return fromSource(emptyBundle());
3595
4465
  }
3596
4466
  function normalizeBundle(b, options = {}) {
3597
4467
  const enforcement = b?.enforcement ?? b?.mode;
@@ -3644,8 +4514,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3644
4514
  const run = async () => {
3645
4515
  if (stopped) return;
3646
4516
  try {
3647
- await tick();
3648
- failures = 0;
4517
+ const status = await tick();
4518
+ if (status && status.ok === false) failures++;
4519
+ else failures = 0;
3649
4520
  } catch (err) {
3650
4521
  failures++;
3651
4522
  notify(onError, err, "onError");
@@ -3672,7 +4543,8 @@ function makeRefreshHandler(tick, secret) {
3672
4543
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3673
4544
  let refreshed = true;
3674
4545
  try {
3675
- await tick();
4546
+ const status = await tick();
4547
+ if (status && status.ok === false) refreshed = false;
3676
4548
  } catch {
3677
4549
  refreshed = false;
3678
4550
  }
@@ -3701,6 +4573,12 @@ function ruleParameters(rule) {
3701
4573
  walk2(rule?.rule_v2);
3702
4574
  return [...out];
3703
4575
  }
4576
+ function revisionOf(rule) {
4577
+ const revision = rule?.source_revision;
4578
+ if (typeof revision === "string" && revision !== "") return revision;
4579
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
4580
+ return null;
4581
+ }
3704
4582
  function routeOf(path7) {
3705
4583
  if (typeof path7 !== "string" || path7 === "") return null;
3706
4584
  const cut = path7.search(/[?#]/);
@@ -3709,16 +4587,30 @@ function routeOf(path7) {
3709
4587
  function createDetectionReporter(opts) {
3710
4588
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
3711
4589
  if (!siteUuid) {
3712
- return { record() {
3713
- }, flush() {
3714
- }, stop() {
3715
- }, dropped: () => 0 };
4590
+ return {
4591
+ record() {
4592
+ },
4593
+ flush() {
4594
+ },
4595
+ stop() {
4596
+ },
4597
+ setRulesEtag() {
4598
+ },
4599
+ dropped: () => 0,
4600
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
4601
+ };
3716
4602
  }
3717
4603
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
3718
4604
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
3719
4605
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3720
4606
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
3721
4607
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
4608
+ let rulesEtag = opts.rulesEtag ?? null;
4609
+ let sent = 0;
4610
+ let delivered = 0;
4611
+ let failed = 0;
4612
+ let droppedTotal = 0;
4613
+ let lastDeliveredAt = null;
3722
4614
  let queue = [];
3723
4615
  let timer = null;
3724
4616
  let stopped = false;
@@ -3732,6 +4624,8 @@ function createDetectionReporter(opts) {
3732
4624
  const batch = queue.splice(0, MAX_BATCH);
3733
4625
  const droppedWith = dropped;
3734
4626
  dropped = 0;
4627
+ droppedTotal += droppedWith;
4628
+ sent += batch.length;
3735
4629
  void (async () => {
3736
4630
  try {
3737
4631
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -3740,15 +4634,22 @@ function createDetectionReporter(opts) {
3740
4634
  "Content-Type": "application/json",
3741
4635
  Accept: "application/json",
3742
4636
  "User-Agent": "@patchstack/connect",
3743
- // Same credential path as the rules fetch, and unauthenticated when none resolves: the
3744
- // server accepts the UUID, and reporting must never hinge on getting a token.
4637
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
4638
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
4639
+ // why the runtime does not build a reporter when no credential resolves, rather than
4640
+ // posting into a 401.
3745
4641
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
3746
4642
  },
3747
4643
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
3748
4644
  });
3749
- if (res && typeof res.then === "function") res.catch(() => {
3750
- });
4645
+ if (res && res.ok) {
4646
+ delivered += batch.length;
4647
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
4648
+ } else {
4649
+ failed += batch.length;
4650
+ }
3751
4651
  } catch {
4652
+ failed += batch.length;
3752
4653
  }
3753
4654
  })();
3754
4655
  };
@@ -3777,7 +4678,12 @@ function createDetectionReporter(opts) {
3777
4678
  // The state this detection was handled under, which is the whole point: `false` is a rule that
3778
4679
  // saw traffic it would have stopped.
3779
4680
  enforced: detection.mode === "block",
3780
- rules_etag: opts.rulesEtag ?? null,
4681
+ rules_etag: rulesEtag,
4682
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
4683
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
4684
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
4685
+ // bundle carried none.
4686
+ rule_revision: revisionOf(detection.rule),
3781
4687
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
3782
4688
  });
3783
4689
  if (queue.length >= MAX_BATCH) {
@@ -3791,7 +4697,24 @@ function createDetectionReporter(opts) {
3791
4697
  stopped = true;
3792
4698
  flush();
3793
4699
  },
3794
- dropped: () => dropped
4700
+ /**
4701
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
4702
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
4703
+ *
4704
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
4705
+ * the only moment the two are known to agree.
4706
+ *
4707
+ * @param {string | null | undefined} next
4708
+ */
4709
+ setRulesEtag(next) {
4710
+ rulesEtag = next ?? null;
4711
+ },
4712
+ dropped: () => dropped,
4713
+ /**
4714
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
4715
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
4716
+ */
4717
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
3795
4718
  };
3796
4719
  }
3797
4720
 
@@ -4056,22 +4979,32 @@ async function createProtection(options = {}) {
4056
4979
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
4057
4980
  const pulseAuth = await resolvePulseAuth(options);
4058
4981
  if (options.siteUuid && !pulseAuth) {
4059
- 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.";
4982
+ 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.";
4060
4983
  notify(onError, new Error(message), "onError");
4061
4984
  console.warn(message);
4062
4985
  }
4063
4986
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
4987
+ let detectionReporting = "off";
4064
4988
  if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
4065
- detections = createDetectionReporter({
4066
- siteUuid: options.siteUuid,
4067
- baseUrl: options.pulseRulesUrl,
4068
- pulseAuth,
4069
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
4070
- // it rather than to whatever is current when the report is read.
4071
- rulesEtag: (await store.read())?.etag ?? null,
4072
- fetchImpl: options.fetchImpl,
4073
- flushMs: options.detectionFlushMs
4074
- });
4989
+ if (!pulseAuth) {
4990
+ detectionReporting = "unavailable-no-credential";
4991
+ 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.";
4992
+ notify(onError, new Error(message), "onError");
4993
+ console.warn(message);
4994
+ } else {
4995
+ detectionReporting = "on";
4996
+ detections = createDetectionReporter({
4997
+ siteUuid: options.siteUuid,
4998
+ baseUrl: options.pulseRulesUrl,
4999
+ pulseAuth,
5000
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
5001
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
5002
+ // see the refresh tick below.
5003
+ rulesEtag: (await store.read())?.etag ?? null,
5004
+ fetchImpl: options.fetchImpl,
5005
+ flushMs: options.detectionFlushMs
5006
+ });
5007
+ }
4075
5008
  }
4076
5009
  let mode = resolveMode(options, bundle);
4077
5010
  let requestRules;
@@ -4405,11 +5338,17 @@ async function createProtection(options = {}) {
4405
5338
  );
4406
5339
  };
4407
5340
  },
4408
- // Node / Connect middleware — buffers the body itself (request phase).
5341
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
5342
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
5343
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
4409
5344
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
4410
5345
  node(nodeOptions = {}) {
4411
5346
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
4412
5347
  return (req, res, next) => {
5348
+ if (req.readableEnded || req.body !== void 0) {
5349
+ screenNodeRequest(req, res, next, "", req.body);
5350
+ return;
5351
+ }
4413
5352
  const chunks = [];
4414
5353
  let size = 0;
4415
5354
  let overflow = false;
@@ -4427,38 +5366,41 @@ async function createProtection(options = {}) {
4427
5366
  });
4428
5367
  req.on("end", () => {
4429
5368
  if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
4430
- const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
4431
- let shaped;
4432
- let result;
4433
- try {
4434
- shaped = fromNodeRequest(req, rawBody);
4435
- result = engine.evaluate(shaped);
4436
- } catch (err) {
4437
- notify(onError, err, "onError");
4438
- return next();
4439
- }
4440
- decide(
4441
- "request",
4442
- result,
4443
- () => {
4444
- res.statusCode = 403;
4445
- if (isDocumentNavigation((n) => req.headers?.[n])) {
4446
- res.setHeader("content-type", "text/html; charset=utf-8");
4447
- res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
4448
- } else {
4449
- res.setHeader("content-type", "application/json");
4450
- res.end(JSON.stringify(blockBody(result)));
4451
- }
4452
- },
4453
- () => {
4454
- if (req.body === void 0) req.body = shaped.body;
4455
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4456
- next();
4457
- },
4458
- nodeRequestMeta(req)
4459
- );
5369
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
4460
5370
  });
4461
5371
  };
5372
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
5373
+ let shaped;
5374
+ let result;
5375
+ try {
5376
+ shaped = fromNodeRequest(req, rawBody);
5377
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
5378
+ result = engine.evaluate(shaped);
5379
+ } catch (err) {
5380
+ notify(onError, err, "onError");
5381
+ return next();
5382
+ }
5383
+ decide(
5384
+ "request",
5385
+ result,
5386
+ () => {
5387
+ res.statusCode = 403;
5388
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
5389
+ res.setHeader("content-type", "text/html; charset=utf-8");
5390
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
5391
+ } else {
5392
+ res.setHeader("content-type", "application/json");
5393
+ res.end(JSON.stringify(blockBody(result)));
5394
+ }
5395
+ },
5396
+ () => {
5397
+ if (req.body === void 0) req.body = shaped.body;
5398
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
5399
+ next();
5400
+ },
5401
+ nodeRequestMeta(req)
5402
+ );
5403
+ }
4462
5404
  }
4463
5405
  };
4464
5406
  if (options.egress) {
@@ -4495,24 +5437,22 @@ async function createProtection(options = {}) {
4495
5437
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
4496
5438
  mode = resolveMode(options, next);
4497
5439
  applyBundle(next);
5440
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
5441
+ return next.source ?? { ok: true };
4498
5442
  };
4499
5443
  if (live) {
4500
5444
  protection.refresh = () => runRefreshTick();
4501
5445
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
4502
5446
  }
4503
- if (options.refreshMs > 0 && live) {
4504
- const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
4505
- protection.stopRefresh = () => {
4506
- loop.stop();
4507
- firewallLog?.stop();
4508
- detections?.stop();
4509
- };
4510
- } else if (firewallLog) {
4511
- protection.stopRefresh = () => {
4512
- firewallLog.stop();
4513
- detections?.stop();
4514
- };
4515
- }
5447
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
5448
+ protection.stop = () => {
5449
+ loop?.stop();
5450
+ firewallLog?.stop();
5451
+ detections?.stop();
5452
+ };
5453
+ protection.stopRefresh = protection.stop;
5454
+ protection.detectionReporting = detectionReporting;
5455
+ if (detections) protection.detectionHealth = () => detections.health();
4516
5456
  return protection;
4517
5457
  }
4518
5458
  function resolveMode(options, bundle) {
@@ -4523,22 +5463,35 @@ function resolveMode(options, bundle) {
4523
5463
  if (options?.mode === "dry-run") return "dry-run";
4524
5464
  return "dry-run";
4525
5465
  }
5466
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
5467
+ async function readCredentialField(options, field) {
5468
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
5469
+ let readFileSync;
5470
+ let join;
5471
+ try {
5472
+ const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
5473
+ readFileSync = fs.readFileSync;
5474
+ join = pathMod.join;
5475
+ } catch {
5476
+ return void 0;
5477
+ }
5478
+ const cwd = options?.cwd ?? process.cwd();
5479
+ for (const filename of CREDENTIAL_FILES) {
5480
+ try {
5481
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
5482
+ if (typeof value === "string" && value.length > 0) return value;
5483
+ } catch {
5484
+ }
5485
+ }
5486
+ return void 0;
5487
+ }
4526
5488
  async function resolveApiKey(options) {
4527
5489
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
4528
5490
  if (typeof process !== "undefined") {
4529
5491
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
4530
5492
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4531
5493
  }
4532
- try {
4533
- if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
4534
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
4535
- const cwd = options?.cwd ?? process.cwd();
4536
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
4537
- const key = JSON.parse(raw)?.apiKey;
4538
- if (typeof key === "string" && key.length > 0) return key;
4539
- } catch {
4540
- }
4541
- return void 0;
5494
+ return readCredentialField(options, "apiKey");
4542
5495
  }
4543
5496
  async function resolvePulseAuth(options) {
4544
5497
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -4546,15 +5499,8 @@ async function resolvePulseAuth(options) {
4546
5499
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
4547
5500
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4548
5501
  }
4549
- try {
4550
- if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
4551
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
4552
- const cwd = options?.cwd ?? process.cwd();
4553
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
4554
- const key = JSON.parse(raw)?.pulseAuth;
4555
- if (typeof key === "string" && key.length > 0) return key;
4556
- } catch {
4557
- }
5502
+ const fromFile = await readCredentialField(options, "pulseAuth");
5503
+ if (fromFile !== void 0) return fromFile;
4558
5504
  return resolveApiKey(options);
4559
5505
  }
4560
5506
  function byPhase(rules, phase) {
@@ -4687,6 +5633,8 @@ function extractRedactors(rule) {
4687
5633
  }
4688
5634
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
4689
5635
  out.push({ literal: String(m.value) });
5636
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
5637
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
4690
5638
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
4691
5639
  const keys = Array.isArray(m.key) ? m.key : [m.key];
4692
5640
  for (const key of keys) {
@@ -4705,7 +5653,11 @@ function applyRedactors(body, redactors, mask, transform) {
4705
5653
  let out = body;
4706
5654
  for (const r of redactors) {
4707
5655
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
4708
- else if (r.literal) {
5656
+ else if (r.jwtClaim) {
5657
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
5658
+ out = out.split(token).join(transform ? transform(token) : mask);
5659
+ }
5660
+ } else if (r.literal) {
4709
5661
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
4710
5662
  out = out.replace(re, (m) => transform ? transform(m) : mask);
4711
5663
  }
@@ -4839,11 +5791,13 @@ function applyHeaderMutation(headers, rule) {
4839
5791
  }
4840
5792
  }
4841
5793
  }
5794
+ var SAME_SITE = ["Strict", "Lax", "None"];
4842
5795
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
4843
5796
  let out = String(cookie);
4844
5797
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
4845
5798
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
4846
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
5799
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
5800
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
4847
5801
  return out;
4848
5802
  }
4849
5803
  function rebuildResponse(response, body, redactedHeaders) {
@@ -4857,11 +5811,9 @@ function rebuildResponse(response, body, redactedHeaders) {
4857
5811
  } catch {
4858
5812
  }
4859
5813
  } else if (typeof value === "string") {
4860
- if (headers.get(name) !== value) {
4861
- try {
4862
- headers.set(name, value);
4863
- } catch {
4864
- }
5814
+ try {
5815
+ if (headers.get(name) !== value) headers.set(name, value);
5816
+ } catch {
4865
5817
  }
4866
5818
  } else if (Array.isArray(value)) {
4867
5819
  try {