@patchstack/connect 0.3.28 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +37 -21
  2. package/README.md +24 -10
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1267 -224
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +419 -104
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +28 -9
  10. package/dist/index.d.ts +28 -9
  11. package/dist/index.js +412 -97
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1039 -235
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +663 -132
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +665 -134
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
  37. package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/protect.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,131 @@ 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));
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) {
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);
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);
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);
725
+ });
726
+ return perEntry.flat();
727
+ }
728
+ async function packageAndNested(pkgDir, resolved, depth, seen) {
729
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
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
+ async function readPackage(pkgDir, depth) {
546
745
  let raw;
547
746
  try {
548
- raw = await (0, import_promises4.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
747
+ raw = await (0, import_promises5.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
549
748
  } catch {
550
- return;
749
+ return null;
551
750
  }
552
751
  let parsed;
553
752
  try {
554
753
  parsed = JSON.parse(raw);
555
754
  } catch {
556
- return;
755
+ return null;
557
756
  }
558
757
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
559
- return;
758
+ return null;
560
759
  }
561
760
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
562
- return;
761
+ return null;
563
762
  }
564
- acc.push({
565
- name: parsed.name,
566
- version: parsed.version,
567
- direct: depth === 0
568
- });
763
+ return { name: parsed.name, version: parsed.version, direct: depth === 0 };
569
764
  }
570
- async function walkNested(pkgDir, acc, depth) {
765
+ async function walkNested(pkgDir, depth, seen) {
571
766
  const nested = import_node_path4.default.join(pkgDir, "node_modules");
572
- if (!await isPlainDirectory(nested)) {
573
- return;
767
+ if (await claim(nested, seen, depth + 1) === null) {
768
+ return [];
574
769
  }
575
- await walk(nested, acc, depth + 1);
770
+ return walk(nested, depth + 1, seen);
576
771
  }
577
- async function isPlainDirectory(dir) {
772
+ async function claim(dir, seen, depth) {
773
+ let resolved;
578
774
  try {
579
- const info = await (0, import_promises4.lstat)(dir);
580
- return info.isDirectory() && !info.isSymbolicLink();
775
+ const info = await (0, import_promises5.lstat)(dir);
776
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
777
+ resolved = await (0, import_promises5.realpath)(dir);
778
+ const target = await (0, import_promises5.stat)(resolved);
779
+ if (!target.isDirectory()) return null;
581
780
  } catch {
582
- return false;
781
+ return null;
583
782
  }
783
+ const claimed = seen.get(resolved);
784
+ if (claimed !== void 0 && claimed <= depth) return null;
785
+ seen.set(resolved, depth);
786
+ return resolved;
584
787
  }
585
- var import_promises4, import_node_path4;
788
+ var import_promises5, import_node_path4, PACKAGE_STORE_DIRS, CONCURRENCY;
586
789
  var init_node_modules = __esm({
587
790
  "src/parsers/node_modules.ts"() {
588
791
  "use strict";
589
- import_promises4 = require("fs/promises");
792
+ import_promises5 = require("fs/promises");
590
793
  import_node_path4 = __toESM(require("path"), 1);
591
794
  init_types();
795
+ PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
796
+ CONCURRENCY = 32;
592
797
  }
593
798
  });
594
799
 
595
800
  // src/parsers/pnpm.ts
596
- async function parsePnpmLockfile(lockfilePath) {
801
+ async function parsePnpmLockfile(lockfilePath, report) {
597
802
  let raw;
598
803
  try {
599
- raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
804
+ raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
600
805
  } catch (cause) {
601
806
  throw new PatchstackError(
602
807
  `Could not read lockfile at ${lockfilePath}`,
@@ -617,6 +822,7 @@ async function parsePnpmLockfile(lockfilePath) {
617
822
  for (const key of packageKeys) {
618
823
  const parsed = parsePackageKey(key);
619
824
  if (parsed === null) {
825
+ recordUnreadable(report, key);
620
826
  continue;
621
827
  }
622
828
  entries.push({
@@ -837,12 +1043,13 @@ function extractLeafName(trimmed) {
837
1043
  }
838
1044
  return name.length > 0 ? name : null;
839
1045
  }
840
- var import_promises5, DEP_SECTIONS;
1046
+ var import_promises6, DEP_SECTIONS;
841
1047
  var init_pnpm = __esm({
842
1048
  "src/parsers/pnpm.ts"() {
843
1049
  "use strict";
844
- import_promises5 = require("fs/promises");
1050
+ import_promises6 = require("fs/promises");
845
1051
  init_types();
1052
+ init_report();
846
1053
  DEP_SECTIONS = /* @__PURE__ */ new Set([
847
1054
  "dependencies",
848
1055
  "devDependencies",
@@ -852,10 +1059,10 @@ var init_pnpm = __esm({
852
1059
  });
853
1060
 
854
1061
  // src/parsers/yarn.ts
855
- async function parseYarnLockfile(lockfilePath) {
1062
+ async function parseYarnLockfile(lockfilePath, report) {
856
1063
  let raw;
857
1064
  try {
858
- raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
1065
+ raw = await (0, import_promises7.readFile)(lockfilePath, "utf8");
859
1066
  } catch (cause) {
860
1067
  throw new PatchstackError(
861
1068
  `Could not read lockfile at ${lockfilePath}`,
@@ -863,7 +1070,7 @@ async function parseYarnLockfile(lockfilePath) {
863
1070
  cause
864
1071
  );
865
1072
  }
866
- const blocks = parseBlocks(raw);
1073
+ const blocks = parseBlocks(raw, report);
867
1074
  if (blocks.length === 0) {
868
1075
  throw new PatchstackError(
869
1076
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -892,13 +1099,18 @@ async function parseYarnLockfile(lockfilePath) {
892
1099
  }
893
1100
  return entries;
894
1101
  }
895
- function parseBlocks(raw) {
1102
+ function parseBlocks(raw, report) {
896
1103
  const lines = raw.split(/\r?\n/);
897
1104
  const blocks = [];
898
1105
  let current = null;
899
1106
  const finalize = () => {
900
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
1107
+ if (current === null) {
1108
+ return;
1109
+ }
1110
+ if (current.version.length > 0 && current.names.size > 0) {
901
1111
  blocks.push(current);
1112
+ } else if (current.unreadable) {
1113
+ recordUnreadable(report, current.key);
902
1114
  }
903
1115
  current = null;
904
1116
  };
@@ -915,13 +1127,13 @@ function parseBlocks(raw) {
915
1127
  }
916
1128
  const keyLine = trimmed.slice(0, -1);
917
1129
  const names = /* @__PURE__ */ new Set();
1130
+ let unreadable = false;
918
1131
  for (const spec of splitDescriptors(keyLine)) {
919
- const name = extractName(spec);
920
- if (name !== null) {
921
- names.add(name);
922
- }
1132
+ const classified = classifyDescriptor(spec);
1133
+ if (classified.name !== void 0) names.add(classified.name);
1134
+ else if (!classified.excluded) unreadable = true;
923
1135
  }
924
- current = { names, version: "" };
1136
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
925
1137
  continue;
926
1138
  }
927
1139
  if (current === null) {
@@ -976,20 +1188,36 @@ function splitDescriptors(keyLine) {
976
1188
  }
977
1189
  return parts;
978
1190
  }
979
- function extractName(rawSpec) {
1191
+ function classifyDescriptor(rawSpec) {
980
1192
  let s = rawSpec.trim();
981
1193
  if (s.length === 0) {
982
- return null;
1194
+ return { excluded: true };
983
1195
  }
984
1196
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
985
1197
  s = s.slice(1, -1);
986
1198
  }
987
- const atIdx = s.lastIndexOf("@");
1199
+ if (NON_PACKAGE_KEYS.has(s)) {
1200
+ return { excluded: true };
1201
+ }
1202
+ const atIdx = s.indexOf("@", 1);
988
1203
  if (atIdx <= 0) {
989
- return null;
1204
+ return {};
990
1205
  }
991
1206
  const name = s.slice(0, atIdx);
992
- return name.length > 0 ? name : null;
1207
+ const range = s.slice(atIdx + 1);
1208
+ if (name.length === 0) {
1209
+ return {};
1210
+ }
1211
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
1212
+ return { excluded: true };
1213
+ }
1214
+ const alias = /^npm:(.+)$/i.exec(range);
1215
+ if (alias !== null) {
1216
+ const target = alias[1] ?? "";
1217
+ const targetAt = target.indexOf("@", 1);
1218
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
1219
+ }
1220
+ return { name };
993
1221
  }
994
1222
  function parseVersionField(content) {
995
1223
  if (!content.startsWith("version")) {
@@ -1014,7 +1242,7 @@ async function readDirectDepNames(cwd) {
1014
1242
  const names = /* @__PURE__ */ new Set();
1015
1243
  let raw;
1016
1244
  try {
1017
- raw = await (0, import_promises6.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
1245
+ raw = await (0, import_promises7.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
1018
1246
  } catch {
1019
1247
  return names;
1020
1248
  }
@@ -1039,13 +1267,16 @@ async function readDirectDepNames(cwd) {
1039
1267
  }
1040
1268
  return names;
1041
1269
  }
1042
- var import_promises6, import_node_path5;
1270
+ var import_promises7, import_node_path5, NON_PACKAGE_KEYS, NON_REGISTRY_PROTOCOLS;
1043
1271
  var init_yarn = __esm({
1044
1272
  "src/parsers/yarn.ts"() {
1045
1273
  "use strict";
1046
- import_promises6 = require("fs/promises");
1274
+ import_promises7 = require("fs/promises");
1047
1275
  import_node_path5 = __toESM(require("path"), 1);
1048
1276
  init_types();
1277
+ init_report();
1278
+ NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
1279
+ NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
1049
1280
  }
1050
1281
  });
1051
1282
 
@@ -1080,34 +1311,58 @@ async function scanLockfile(cwd) {
1080
1311
  const declared = await readDeclaredDependencyNames(cwd);
1081
1312
  const warnings = [];
1082
1313
  let firstParsed = null;
1083
- let walkTried = false;
1314
+ let walked = null;
1315
+ let walkWasCandidate = false;
1316
+ const parsedSources = [];
1084
1317
  for (const candidate of candidates) {
1085
1318
  let packages;
1319
+ const report = newReport();
1086
1320
  try {
1087
- packages = await runStrategy(candidate, cwd);
1321
+ packages = await runStrategy(candidate, cwd, report);
1088
1322
  } catch {
1089
1323
  continue;
1090
1324
  }
1091
- walkTried ||= candidate.strategy === "node-modules-walk";
1325
+ const unreadable = unreadableWarning(candidate.filename, report);
1326
+ if (unreadable !== null) warnings.push(unreadable);
1327
+ if (candidate.strategy === "node-modules-walk") {
1328
+ walked = packages;
1329
+ walkWasCandidate = true;
1330
+ }
1092
1331
  firstParsed ??= { packages, filename: candidate.filename };
1093
- const missing = missingDependencies(declared, packages);
1094
- if (missing.length === 0) {
1095
- return manifestWith(packages, warnings);
1332
+ parsedSources.push({ packages, filename: candidate.filename });
1333
+ }
1334
+ for (const source of parsedSources) {
1335
+ const missing = missingDependencies(declared, source.packages);
1336
+ if (missing.length > 0) {
1337
+ warnings.push(staleWarning(source.filename, missing));
1338
+ continue;
1339
+ }
1340
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
1341
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
1342
+ );
1343
+ if (conflicts.length === 0) {
1344
+ return manifestWith(source.packages, warnings);
1096
1345
  }
1097
- warnings.push(staleWarning(candidate.filename, missing));
1346
+ warnings.push(conflictWarning(source.filename, conflicts));
1347
+ break;
1098
1348
  }
1099
- if (!walkTried) {
1349
+ if (walked === null) {
1100
1350
  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);
1351
+ walked = await walkNodeModules(cwd);
1108
1352
  } catch {
1353
+ walked = null;
1109
1354
  }
1110
1355
  }
1356
+ if (walked !== null) {
1357
+ const missing = missingDependencies(declared, walked);
1358
+ if (missing.length > 0) {
1359
+ warnings.push(staleWarning("node_modules/", missing));
1360
+ }
1361
+ warnings.push(
1362
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
1363
+ );
1364
+ return manifestWith(walked, warnings);
1365
+ }
1111
1366
  if (firstParsed === null) {
1112
1367
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
1113
1368
  throw new PatchstackError(
@@ -1123,6 +1378,15 @@ async function scanLockfile(cwd) {
1123
1378
  function manifestWith(packages, warnings) {
1124
1379
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
1125
1380
  }
1381
+ function otherCompleteSources(all, current, declared) {
1382
+ return all.filter(
1383
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1384
+ );
1385
+ }
1386
+ function conflictWarning(filename, conflicts) {
1387
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1388
+ 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.`;
1389
+ }
1126
1390
  function staleWarning(source, missing) {
1127
1391
  const sample = missing.slice(0, 3).join(", ");
1128
1392
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -1137,41 +1401,50 @@ async function presentLockfiles(cwd) {
1137
1401
  );
1138
1402
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
1139
1403
  }
1140
- async function runStrategy(detected, cwd) {
1404
+ async function runStrategy(detected, cwd, report) {
1141
1405
  switch (detected.strategy) {
1142
1406
  case "npm-lockfile":
1143
1407
  return parseNpmLockfile(detected.filePath);
1408
+ case "bun-lockfile":
1409
+ return parseBunLockfile(detected.filePath, report);
1410
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1411
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
1144
1412
  case "pnpm-lockfile":
1145
- return parsePnpmLockfile(detected.filePath);
1413
+ return parsePnpmLockfile(detected.filePath, report);
1146
1414
  case "yarn-lockfile":
1147
- return parseYarnLockfile(detected.filePath);
1415
+ return parseYarnLockfile(detected.filePath, report);
1148
1416
  case "node-modules-walk":
1149
1417
  return walkNodeModules(cwd);
1150
1418
  }
1151
1419
  }
1152
1420
  async function exists(filePath) {
1153
1421
  try {
1154
- await (0, import_promises7.access)(filePath);
1422
+ await (0, import_promises8.access)(filePath);
1155
1423
  return true;
1156
1424
  } catch {
1157
1425
  return false;
1158
1426
  }
1159
1427
  }
1160
- var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
1428
+ var import_promises8, import_node_path6, LOCKFILE_CANDIDATES;
1161
1429
  var init_parsers = __esm({
1162
1430
  "src/parsers/index.ts"() {
1163
1431
  "use strict";
1164
- import_promises7 = require("fs/promises");
1432
+ import_promises8 = require("fs/promises");
1165
1433
  import_node_path6 = __toESM(require("path"), 1);
1166
1434
  init_types();
1167
1435
  init_consistency();
1436
+ init_bun();
1168
1437
  init_npm();
1169
1438
  init_node_modules();
1170
1439
  init_pnpm();
1171
1440
  init_yarn();
1441
+ init_report();
1172
1442
  LOCKFILE_CANDIDATES = [
1173
1443
  { filename: "package-lock.json", strategy: "npm-lockfile" },
1174
- { filename: "bun.lock", strategy: "node-modules-walk" },
1444
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
1445
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
1446
+ // for new workspaces.
1447
+ { filename: "bun.lock", strategy: "bun-lockfile" },
1175
1448
  { filename: "bun.lockb", strategy: "node-modules-walk" },
1176
1449
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
1177
1450
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -1450,6 +1723,45 @@ function base64DecodeUtf8(value) {
1450
1723
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
1451
1724
  return new TextDecoder().decode(bytes);
1452
1725
  }
1726
+ var NAMED_ENTITIES = {
1727
+ lt: "<",
1728
+ gt: ">",
1729
+ amp: "&",
1730
+ quot: '"',
1731
+ apos: "'",
1732
+ "#39": "'",
1733
+ nbsp: "\xA0",
1734
+ sol: "/",
1735
+ bsol: "\\",
1736
+ colon: ":",
1737
+ lpar: "(",
1738
+ rpar: ")",
1739
+ equals: "=",
1740
+ grave: "`",
1741
+ Tab: " ",
1742
+ NewLine: "\n",
1743
+ semi: ";",
1744
+ excl: "!",
1745
+ num: "#",
1746
+ dollar: "$",
1747
+ percnt: "%",
1748
+ ast: "*"
1749
+ };
1750
+ function decodeHtmlEntities(input) {
1751
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
1752
+ if (body[0] === "#") {
1753
+ const hex = body[1] === "x" || body[1] === "X";
1754
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
1755
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
1756
+ try {
1757
+ return String.fromCodePoint(code);
1758
+ } catch {
1759
+ return whole;
1760
+ }
1761
+ }
1762
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
1763
+ });
1764
+ }
1453
1765
  var RequestResolver = class {
1454
1766
  #req;
1455
1767
  #cookies;
@@ -1572,6 +1884,8 @@ var RequestResolver = class {
1572
1884
  } catch {
1573
1885
  return value;
1574
1886
  }
1887
+ case "htmlentitydecode":
1888
+ return decodeHtmlEntities(String(value));
1575
1889
  case "intval":
1576
1890
  return parseInt(String(value), 10) || 0;
1577
1891
  case "getArrayValues":
@@ -3426,25 +3740,428 @@ function toEnvelope(value) {
3426
3740
  return null;
3427
3741
  }
3428
3742
 
3429
- // src/protect/rules/validate.js
3430
- var LIMITS = {
3743
+ // src/protect/rules/contract.js
3744
+ var SOURCES = Object.freeze({
3745
+ raw: Object.freeze({ keyed: false }),
3746
+ all: Object.freeze({ keyed: false }),
3747
+ false: Object.freeze({ keyed: false }),
3748
+ rules: Object.freeze({ keyed: false, group: true }),
3749
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3750
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3751
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3752
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3753
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
3754
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
3755
+ files: Object.freeze({
3756
+ keyed: true,
3757
+ keys: "any",
3758
+ wildcard: true,
3759
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
3760
+ }),
3761
+ server: Object.freeze({
3762
+ keyed: true,
3763
+ keys: Object.freeze([
3764
+ "REQUEST_URI",
3765
+ "REQUEST_METHOD",
3766
+ "HTTP_USER_AGENT",
3767
+ "HTTP_REFERER",
3768
+ "HTTP_HOST",
3769
+ "REMOTE_ADDR",
3770
+ "ip",
3771
+ "CONTENT_TYPE",
3772
+ "CONTENT_LENGTH"
3773
+ ]),
3774
+ key_prefixes: Object.freeze(["HTTP_"])
3775
+ }),
3776
+ response: Object.freeze({
3777
+ keyed: true,
3778
+ keys: Object.freeze(["status", "body", "headers"]),
3779
+ key_prefixes: Object.freeze(["header."])
3780
+ }),
3781
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
3782
+ });
3783
+ var MATCH_TYPES = Object.freeze([
3784
+ "array_in_array",
3785
+ "array_key_value",
3786
+ "contains",
3787
+ "cors_reflected",
3788
+ "cross_origin",
3789
+ "ctype_alnum",
3790
+ "ctype_digit",
3791
+ "ctype_special",
3792
+ "equals",
3793
+ "equals_strict",
3794
+ "hostname",
3795
+ "in_array",
3796
+ "inline_js_xss",
3797
+ "inline_xss",
3798
+ "internal_host",
3799
+ "is_numeric",
3800
+ "isset",
3801
+ "less_than",
3802
+ "more_than",
3803
+ "not_contains",
3804
+ "not_in_array",
3805
+ "off_origin",
3806
+ "quotes",
3807
+ "regex",
3808
+ "stripos"
3809
+ ]);
3810
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
3811
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
3812
+ "isset",
3813
+ "quotes",
3814
+ "is_numeric",
3815
+ "ctype_alnum",
3816
+ "ctype_digit",
3817
+ "ctype_special",
3818
+ "inline_xss",
3819
+ "inline_js_xss",
3820
+ "internal_host",
3821
+ "cross_origin",
3822
+ "off_origin",
3823
+ "cors_reflected"
3824
+ ]);
3825
+ var OPERAND_SHAPES = Object.freeze([
3826
+ "non-empty-string",
3827
+ "regex-literal",
3828
+ "string-or-string-list",
3829
+ "scalar",
3830
+ "string",
3831
+ "numeric",
3832
+ "scalar-or-non-empty-list",
3833
+ "non-empty-list",
3834
+ "object",
3835
+ "non-empty-object",
3836
+ "non-empty-string-list",
3837
+ "header-name-map",
3838
+ "header-name-list",
3839
+ "cookie-flags"
3840
+ ]);
3841
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
3842
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
3843
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
3844
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
3845
+ var MATCH_OPERANDS = Object.freeze({
3846
+ array_key_value: Object.freeze({
3847
+ required: Object.freeze(["key", "match"]),
3848
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
3849
+ }),
3850
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
3851
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3852
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3853
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3854
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
3855
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
3856
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
3857
+ // `array_in_array` inert and `not_in_array` true for every value.
3858
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3859
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3860
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3861
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
3862
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
3863
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
3864
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
3865
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
3866
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
3867
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
3868
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
3869
+ });
3870
+ function operandShapeProblem(shape, value) {
3871
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3872
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3873
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
3874
+ switch (shape) {
3875
+ case "non-empty-string":
3876
+ return isFilledString(value) ? null : "must be a non-empty string";
3877
+ case "regex-literal":
3878
+ if (!isFilledString(value)) return "must be a non-empty string";
3879
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
3880
+ case "string-or-string-list":
3881
+ if (isFilledString(value)) return null;
3882
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
3883
+ return "must be a non-empty string, or a non-empty list of them";
3884
+ case "scalar":
3885
+ return isScalar(value) ? null : "must be a string, number or boolean";
3886
+ case "string":
3887
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
3888
+ case "numeric":
3889
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
3890
+ case "scalar-or-non-empty-list":
3891
+ if (isScalar(value)) return null;
3892
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
3893
+ return "must be a scalar, or a non-empty list of them";
3894
+ case "non-empty-list":
3895
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
3896
+ case "header-name-map": {
3897
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3898
+ const names = Object.keys(value);
3899
+ if (names.length === 0) return "must be an object with at least one entry";
3900
+ const bad = names.find((name) => !isHeaderName(name));
3901
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
3902
+ }
3903
+ case "header-name-list": {
3904
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
3905
+ const bad = value.find((name) => !isHeaderName(name));
3906
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
3907
+ }
3908
+ case "cookie-flags": {
3909
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3910
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
3911
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
3912
+ for (const flag of ["httpOnly", "secure"]) {
3913
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
3914
+ return `flag "${flag}" must be true or false`;
3915
+ }
3916
+ }
3917
+ const sameSite = value.sameSite;
3918
+ const sameSiteOff = sameSite === false;
3919
+ if (sameSite !== void 0 && !sameSiteOff) {
3920
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
3921
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
3922
+ }
3923
+ }
3924
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
3925
+ return "turns off every flag, so it would harden nothing";
3926
+ }
3927
+ return null;
3928
+ }
3929
+ case "object":
3930
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
3931
+ case "non-empty-object":
3932
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
3933
+ case "non-empty-string-list":
3934
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
3935
+ default:
3936
+ return null;
3937
+ }
3938
+ }
3939
+ var MUTATIONS = Object.freeze([
3940
+ "base64_decode",
3941
+ "getArrayValues",
3942
+ "htmlentitydecode",
3943
+ "intval",
3944
+ "json_decode",
3945
+ "json_encode",
3946
+ "urldecode"
3947
+ ]);
3948
+ var PHASES = Object.freeze(["request", "response", "egress"]);
3949
+ var ACTIONS = Object.freeze([
3950
+ "block",
3951
+ "redact",
3952
+ "encode",
3953
+ "set-header",
3954
+ "remove-header",
3955
+ "harden-cookie"
3956
+ ]);
3957
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
3958
+ var RULE_PROPERTIES = Object.freeze([
3959
+ "id",
3960
+ "rule_id",
3961
+ "title",
3962
+ "category",
3963
+ "phase",
3964
+ "action",
3965
+ "rule_v2",
3966
+ "when",
3967
+ "message",
3968
+ "enforcement",
3969
+ "source_revision",
3970
+ "prefilter",
3971
+ "max_bytes",
3972
+ "bypass_limit",
3973
+ "set_headers",
3974
+ "remove_headers",
3975
+ "cookie_flags",
3976
+ "ensure"
3977
+ ]);
3978
+ var ACTION_PROPERTIES = Object.freeze({
3979
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
3980
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
3981
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
3982
+ "set-header": Object.freeze({
3983
+ required: Object.freeze(["set_headers"]),
3984
+ defaulted: Object.freeze(["ensure"]),
3985
+ phases: Object.freeze(["response"]),
3986
+ shapes: Object.freeze({ set_headers: "header-name-map" })
3987
+ }),
3988
+ "remove-header": Object.freeze({
3989
+ required: Object.freeze(["remove_headers"]),
3990
+ defaulted: Object.freeze([]),
3991
+ phases: Object.freeze(["response"]),
3992
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
3993
+ }),
3994
+ "harden-cookie": Object.freeze({
3995
+ required: Object.freeze([]),
3996
+ defaulted: Object.freeze(["cookie_flags"]),
3997
+ phases: Object.freeze(["response"]),
3998
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
3999
+ })
4000
+ });
4001
+ var LIMITS = Object.freeze({
3431
4002
  maxRules: 5e3,
3432
4003
  maxWhitelists: 2e3,
3433
4004
  maxConditionsPerRule: 250,
3434
4005
  maxNestingDepth: 12,
3435
4006
  maxRegexLength: 1e3,
3436
4007
  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"]);
4008
+ });
4009
+ var GROUP_PARAMETER = "rules";
4010
+ function isGroup(condition) {
4011
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
4012
+ }
4013
+ function conditionShapeProblem(condition) {
4014
+ if (!condition || typeof condition !== "object") return "condition is not an object";
4015
+ const named = condition.parameter === GROUP_PARAMETER;
4016
+ const nested = Array.isArray(condition.rules);
4017
+ if (named && !nested) {
4018
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
4019
+ }
4020
+ if (nested && !named) {
4021
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4022
+ }
4023
+ if (named && nested) {
4024
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4025
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
4026
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
4027
+ }
4028
+ return null;
4029
+ }
4030
+ function parameterProblem(parameter) {
4031
+ if (parameter === void 0 || parameter === null) return null;
4032
+ if (Array.isArray(parameter)) {
4033
+ if (parameter.length === 0) return "parameter list is empty";
4034
+ for (const member of parameter) {
4035
+ const problem = parameterProblem(member);
4036
+ if (problem !== null) return problem;
4037
+ }
4038
+ return null;
4039
+ }
4040
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
4041
+ if (parameter === GROUP_PARAMETER) {
4042
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
4043
+ }
4044
+ const dot = parameter.indexOf(".");
4045
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
4046
+ const source = SOURCES[name];
4047
+ if (source === void 0) return `unknown parameter source "${name}"`;
4048
+ if (dot === -1) {
4049
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
4050
+ }
4051
+ if (source.keyed !== true) return `source "${name}" takes no key`;
4052
+ const key = parameter.slice(dot + 1);
4053
+ if (key === "") return `source "${name}" needs a key after the dot`;
4054
+ return keyProblem(name, source, key);
4055
+ }
4056
+ function keyProblem(name, source, key) {
4057
+ if (key.endsWith("*") && source.wildcard !== true) {
4058
+ return `"${name}" does not fan out over a wildcard key`;
4059
+ }
4060
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
4061
+ if (source.keys === "any") return null;
4062
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
4063
+ for (const prefix of source.key_prefixes ?? []) {
4064
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
4065
+ }
4066
+ const accepted = [
4067
+ ...Array.isArray(source.keys) ? source.keys : [],
4068
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
4069
+ ].join(", ");
4070
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
4071
+ }
4072
+ function matchProblem(match, parameter) {
4073
+ if (!match || typeof match !== "object") return "condition has no match object";
4074
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
4075
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
4076
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
4077
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
4078
+ return `match type "${match.type}" needs a parameter`;
4079
+ }
4080
+ if (parameterless && parameter !== void 0 && parameter !== null) {
4081
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
4082
+ }
4083
+ const operands = MATCH_OPERANDS[match.type];
4084
+ if (operands) {
4085
+ for (const required of operands.required) {
4086
+ if (match[required] === void 0 || match[required] === null) {
4087
+ return `match type "${match.type}" needs "${required}"`;
4088
+ }
4089
+ }
4090
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
4091
+ if (match[operand] === void 0) continue;
4092
+ const problem = operandShapeProblem(shape, match[operand]);
4093
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
4094
+ }
4095
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
4096
+ return `match type "${match.type}" needs a value`;
4097
+ }
4098
+ return null;
4099
+ }
4100
+ function mutationsProblem(mutations) {
4101
+ if (mutations === void 0 || mutations === null) return null;
4102
+ if (!Array.isArray(mutations)) return "mutations must be an array";
4103
+ for (const mutation of mutations) {
4104
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
4105
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
4106
+ }
4107
+ }
4108
+ return null;
4109
+ }
4110
+ function whenProblem(when) {
4111
+ if (when === void 0 || when === null) return null;
4112
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
4113
+ const keys = Object.keys(when);
4114
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
4115
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
4116
+ if (unknown.length > 0) {
4117
+ 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`;
4118
+ }
4119
+ if ("path" in when && !isNonEmptyString(when.path)) {
4120
+ return "when.path must be a non-empty string";
4121
+ }
4122
+ if ("method" in when) {
4123
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
4124
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
4125
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
4126
+ }
4127
+ return null;
4128
+ }
4129
+ function isNonEmptyString(value) {
4130
+ return typeof value === "string" && value.trim() !== "";
4131
+ }
4132
+ function actionProblem(action, rule) {
4133
+ if (action === void 0 || action === null) return null;
4134
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
4135
+ const spec = ACTION_PROPERTIES[action];
4136
+ for (const required of spec?.required ?? []) {
4137
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
4138
+ return `action "${action}" needs "${required}"`;
4139
+ }
4140
+ }
4141
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
4142
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
4143
+ const problem = operandShapeProblem(shape, rule[property]);
4144
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
4145
+ }
4146
+ const phase = rule?.phase ?? "request";
4147
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
4148
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
4149
+ }
4150
+ return null;
4151
+ }
4152
+
4153
+ // src/protect/rules/validate.js
4154
+ var LIMITS2 = LIMITS;
4155
+ var PHASES2 = new Set(PHASES);
4156
+ var ACTIONS2 = new Set(ACTIONS);
3440
4157
  function validateBundle(bundle, opts = {}) {
3441
4158
  const rejected = [];
3442
4159
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
3443
4160
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
3444
4161
  const firewall = [];
3445
4162
  for (const rule of inFirewall) {
3446
- if (firewall.length >= LIMITS.maxRules) {
3447
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
4163
+ if (firewall.length >= LIMITS2.maxRules) {
4164
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
3448
4165
  continue;
3449
4166
  }
3450
4167
  const reason = ruleProblem(rule);
@@ -3453,8 +4170,8 @@ function validateBundle(bundle, opts = {}) {
3453
4170
  }
3454
4171
  const whitelists = [];
3455
4172
  for (const wl of inWhitelists) {
3456
- if (whitelists.length >= LIMITS.maxWhitelists) {
3457
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
4173
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
4174
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
3458
4175
  continue;
3459
4176
  }
3460
4177
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -3476,8 +4193,11 @@ function idOf(rule) {
3476
4193
  }
3477
4194
  function ruleProblem(rule) {
3478
4195
  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}"`;
4196
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
4197
+ const actionReason = actionProblem(rule.action, rule);
4198
+ if (actionReason) return actionReason;
4199
+ const scopeReason = whenProblem(rule.when);
4200
+ if (scopeReason) return scopeReason;
3481
4201
  const capOverride = rule.max_bytes;
3482
4202
  if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
3483
4203
  return conditionsProblem(rule.rule_v2);
@@ -3485,28 +4205,33 @@ function ruleProblem(rule) {
3485
4205
  function conditionsProblem(conditions, depth = 0) {
3486
4206
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
3487
4207
  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`;
4208
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
4209
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
4210
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
3491
4211
  }
3492
4212
  for (const c of conditions) {
3493
- if (!c || typeof c !== "object") return "condition is not an object";
3494
- if (Array.isArray(c.rules)) {
4213
+ const shapeReason = conditionShapeProblem(c);
4214
+ if (shapeReason) return shapeReason;
4215
+ if (isGroup(c)) {
3495
4216
  const nested = conditionsProblem(c.rules, depth + 1);
3496
4217
  if (nested) return nested;
3497
4218
  continue;
3498
4219
  }
4220
+ const parameterReason = parameterProblem(c.parameter);
4221
+ if (parameterReason) return parameterReason;
4222
+ const mutationReason = mutationsProblem(c.mutations);
4223
+ if (mutationReason) return mutationReason;
4224
+ const matchReason = matchProblem(c.match, c.parameter);
4225
+ if (matchReason) return matchReason;
3499
4226
  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
4227
  if (m.type === "regex") {
3503
4228
  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`;
4229
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
4230
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
4231
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
3507
4232
  }
3508
4233
  if (m.match) {
3509
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
4234
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
3510
4235
  if (nested) return nested;
3511
4236
  }
3512
4237
  }
@@ -3534,64 +4259,67 @@ function reportRejections(rejected, options, label) {
3534
4259
  `${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
4260
  ), "onError");
3536
4261
  }
4262
+ function fromSource(bundle, reason) {
4263
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
4264
+ }
3537
4265
  async function resolveRules(options, store, ctx = {}) {
3538
4266
  const timeoutMs = ctx.timeoutMs;
3539
4267
  if (options.siteUuid) {
3540
4268
  const prior = await store.read();
3541
4269
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
3542
4270
  const res = await client.getRules();
3543
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
4271
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3544
4272
  if (res.success && !res.notModified) {
3545
4273
  const rejected = liveUpdateRejections(res, options);
3546
4274
  if (rejected.length > 0) {
3547
4275
  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();
4276
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4277
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4278
+ return fromSource(emptyBundle(), "update rejected");
3551
4279
  }
3552
4280
  const bundle = normalizeBundle(res, options);
3553
4281
  await store.write({ bundle, etag: res.etag ?? null });
3554
- return bundle;
4282
+ return fromSource(bundle);
3555
4283
  }
3556
4284
  if (prior?.bundle) {
3557
4285
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3558
- return normalizeBundle(prior.bundle, options);
4286
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3559
4287
  }
3560
4288
  if (options.rules) {
3561
4289
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
3562
- return normalizeBundle(options.rules, options);
4290
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
3563
4291
  }
3564
4292
  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();
4293
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
3566
4294
  }
3567
4295
  if (options.token) {
3568
4296
  const prior = await store.read();
3569
4297
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
3570
4298
  const res = await client.getRules();
3571
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
4299
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3572
4300
  if (res.success && !res.notModified) {
3573
4301
  const rejected = liveUpdateRejections(res, options);
3574
4302
  if (rejected.length > 0) {
3575
4303
  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();
4304
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4305
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4306
+ return fromSource(emptyBundle(), "update rejected");
3579
4307
  }
3580
4308
  const bundle = normalizeBundle(res, options);
3581
4309
  await store.write({ bundle, etag: res.etag ?? null });
3582
- return bundle;
4310
+ return fromSource(bundle);
3583
4311
  }
3584
4312
  if (prior?.bundle) {
3585
4313
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3586
- return normalizeBundle(prior.bundle, options);
4314
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3587
4315
  }
3588
4316
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3589
- return emptyBundle();
4317
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
3590
4318
  }
3591
4319
  if (options.rules) {
3592
- return normalizeBundle(options.rules, options);
4320
+ return fromSource(normalizeBundle(options.rules, options));
3593
4321
  }
3594
- return emptyBundle();
4322
+ return fromSource(emptyBundle());
3595
4323
  }
3596
4324
  function normalizeBundle(b, options = {}) {
3597
4325
  const enforcement = b?.enforcement ?? b?.mode;
@@ -3644,8 +4372,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3644
4372
  const run = async () => {
3645
4373
  if (stopped) return;
3646
4374
  try {
3647
- await tick();
3648
- failures = 0;
4375
+ const status = await tick();
4376
+ if (status && status.ok === false) failures++;
4377
+ else failures = 0;
3649
4378
  } catch (err) {
3650
4379
  failures++;
3651
4380
  notify(onError, err, "onError");
@@ -3672,7 +4401,8 @@ function makeRefreshHandler(tick, secret) {
3672
4401
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3673
4402
  let refreshed = true;
3674
4403
  try {
3675
- await tick();
4404
+ const status = await tick();
4405
+ if (status && status.ok === false) refreshed = false;
3676
4406
  } catch {
3677
4407
  refreshed = false;
3678
4408
  }
@@ -3701,6 +4431,12 @@ function ruleParameters(rule) {
3701
4431
  walk2(rule?.rule_v2);
3702
4432
  return [...out];
3703
4433
  }
4434
+ function revisionOf(rule) {
4435
+ const revision = rule?.source_revision;
4436
+ if (typeof revision === "string" && revision !== "") return revision;
4437
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
4438
+ return null;
4439
+ }
3704
4440
  function routeOf(path7) {
3705
4441
  if (typeof path7 !== "string" || path7 === "") return null;
3706
4442
  const cut = path7.search(/[?#]/);
@@ -3709,16 +4445,30 @@ function routeOf(path7) {
3709
4445
  function createDetectionReporter(opts) {
3710
4446
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
3711
4447
  if (!siteUuid) {
3712
- return { record() {
3713
- }, flush() {
3714
- }, stop() {
3715
- }, dropped: () => 0 };
4448
+ return {
4449
+ record() {
4450
+ },
4451
+ flush() {
4452
+ },
4453
+ stop() {
4454
+ },
4455
+ setRulesEtag() {
4456
+ },
4457
+ dropped: () => 0,
4458
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
4459
+ };
3716
4460
  }
3717
4461
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
3718
4462
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
3719
4463
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3720
4464
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
3721
4465
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
4466
+ let rulesEtag = opts.rulesEtag ?? null;
4467
+ let sent = 0;
4468
+ let delivered = 0;
4469
+ let failed = 0;
4470
+ let droppedTotal = 0;
4471
+ let lastDeliveredAt = null;
3722
4472
  let queue = [];
3723
4473
  let timer = null;
3724
4474
  let stopped = false;
@@ -3732,6 +4482,8 @@ function createDetectionReporter(opts) {
3732
4482
  const batch = queue.splice(0, MAX_BATCH);
3733
4483
  const droppedWith = dropped;
3734
4484
  dropped = 0;
4485
+ droppedTotal += droppedWith;
4486
+ sent += batch.length;
3735
4487
  void (async () => {
3736
4488
  try {
3737
4489
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -3740,15 +4492,22 @@ function createDetectionReporter(opts) {
3740
4492
  "Content-Type": "application/json",
3741
4493
  Accept: "application/json",
3742
4494
  "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.
4495
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
4496
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
4497
+ // why the runtime does not build a reporter when no credential resolves, rather than
4498
+ // posting into a 401.
3745
4499
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
3746
4500
  },
3747
4501
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
3748
4502
  });
3749
- if (res && typeof res.then === "function") res.catch(() => {
3750
- });
4503
+ if (res && res.ok) {
4504
+ delivered += batch.length;
4505
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
4506
+ } else {
4507
+ failed += batch.length;
4508
+ }
3751
4509
  } catch {
4510
+ failed += batch.length;
3752
4511
  }
3753
4512
  })();
3754
4513
  };
@@ -3777,7 +4536,12 @@ function createDetectionReporter(opts) {
3777
4536
  // The state this detection was handled under, which is the whole point: `false` is a rule that
3778
4537
  // saw traffic it would have stopped.
3779
4538
  enforced: detection.mode === "block",
3780
- rules_etag: opts.rulesEtag ?? null,
4539
+ rules_etag: rulesEtag,
4540
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
4541
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
4542
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
4543
+ // bundle carried none.
4544
+ rule_revision: revisionOf(detection.rule),
3781
4545
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
3782
4546
  });
3783
4547
  if (queue.length >= MAX_BATCH) {
@@ -3791,7 +4555,24 @@ function createDetectionReporter(opts) {
3791
4555
  stopped = true;
3792
4556
  flush();
3793
4557
  },
3794
- dropped: () => dropped
4558
+ /**
4559
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
4560
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
4561
+ *
4562
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
4563
+ * the only moment the two are known to agree.
4564
+ *
4565
+ * @param {string | null | undefined} next
4566
+ */
4567
+ setRulesEtag(next) {
4568
+ rulesEtag = next ?? null;
4569
+ },
4570
+ dropped: () => dropped,
4571
+ /**
4572
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
4573
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
4574
+ */
4575
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
3795
4576
  };
3796
4577
  }
3797
4578
 
@@ -4056,22 +4837,32 @@ async function createProtection(options = {}) {
4056
4837
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
4057
4838
  const pulseAuth = await resolvePulseAuth(options);
4058
4839
  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.";
4840
+ 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
4841
  notify(onError, new Error(message), "onError");
4061
4842
  console.warn(message);
4062
4843
  }
4063
4844
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
4845
+ let detectionReporting = "off";
4064
4846
  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
- });
4847
+ if (!pulseAuth) {
4848
+ detectionReporting = "unavailable-no-credential";
4849
+ 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.";
4850
+ notify(onError, new Error(message), "onError");
4851
+ console.warn(message);
4852
+ } else {
4853
+ detectionReporting = "on";
4854
+ detections = createDetectionReporter({
4855
+ siteUuid: options.siteUuid,
4856
+ baseUrl: options.pulseRulesUrl,
4857
+ pulseAuth,
4858
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
4859
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
4860
+ // see the refresh tick below.
4861
+ rulesEtag: (await store.read())?.etag ?? null,
4862
+ fetchImpl: options.fetchImpl,
4863
+ flushMs: options.detectionFlushMs
4864
+ });
4865
+ }
4075
4866
  }
4076
4867
  let mode = resolveMode(options, bundle);
4077
4868
  let requestRules;
@@ -4405,11 +5196,17 @@ async function createProtection(options = {}) {
4405
5196
  );
4406
5197
  };
4407
5198
  },
4408
- // Node / Connect middleware — buffers the body itself (request phase).
5199
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
5200
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
5201
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
4409
5202
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
4410
5203
  node(nodeOptions = {}) {
4411
5204
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
4412
5205
  return (req, res, next) => {
5206
+ if (req.readableEnded || req.body !== void 0) {
5207
+ screenNodeRequest(req, res, next, "", req.body);
5208
+ return;
5209
+ }
4413
5210
  const chunks = [];
4414
5211
  let size = 0;
4415
5212
  let overflow = false;
@@ -4427,38 +5224,41 @@ async function createProtection(options = {}) {
4427
5224
  });
4428
5225
  req.on("end", () => {
4429
5226
  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
- );
5227
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
4460
5228
  });
4461
5229
  };
5230
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
5231
+ let shaped;
5232
+ let result;
5233
+ try {
5234
+ shaped = fromNodeRequest(req, rawBody);
5235
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
5236
+ result = engine.evaluate(shaped);
5237
+ } catch (err) {
5238
+ notify(onError, err, "onError");
5239
+ return next();
5240
+ }
5241
+ decide(
5242
+ "request",
5243
+ result,
5244
+ () => {
5245
+ res.statusCode = 403;
5246
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
5247
+ res.setHeader("content-type", "text/html; charset=utf-8");
5248
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
5249
+ } else {
5250
+ res.setHeader("content-type", "application/json");
5251
+ res.end(JSON.stringify(blockBody(result)));
5252
+ }
5253
+ },
5254
+ () => {
5255
+ if (req.body === void 0) req.body = shaped.body;
5256
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
5257
+ next();
5258
+ },
5259
+ nodeRequestMeta(req)
5260
+ );
5261
+ }
4462
5262
  }
4463
5263
  };
4464
5264
  if (options.egress) {
@@ -4495,24 +5295,22 @@ async function createProtection(options = {}) {
4495
5295
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
4496
5296
  mode = resolveMode(options, next);
4497
5297
  applyBundle(next);
5298
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
5299
+ return next.source ?? { ok: true };
4498
5300
  };
4499
5301
  if (live) {
4500
5302
  protection.refresh = () => runRefreshTick();
4501
5303
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
4502
5304
  }
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
- }
5305
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
5306
+ protection.stop = () => {
5307
+ loop?.stop();
5308
+ firewallLog?.stop();
5309
+ detections?.stop();
5310
+ };
5311
+ protection.stopRefresh = protection.stop;
5312
+ protection.detectionReporting = detectionReporting;
5313
+ if (detections) protection.detectionHealth = () => detections.health();
4516
5314
  return protection;
4517
5315
  }
4518
5316
  function resolveMode(options, bundle) {
@@ -4523,22 +5321,35 @@ function resolveMode(options, bundle) {
4523
5321
  if (options?.mode === "dry-run") return "dry-run";
4524
5322
  return "dry-run";
4525
5323
  }
5324
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
5325
+ async function readCredentialField(options, field) {
5326
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
5327
+ let readFileSync;
5328
+ let join;
5329
+ try {
5330
+ const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
5331
+ readFileSync = fs.readFileSync;
5332
+ join = pathMod.join;
5333
+ } catch {
5334
+ return void 0;
5335
+ }
5336
+ const cwd = options?.cwd ?? process.cwd();
5337
+ for (const filename of CREDENTIAL_FILES) {
5338
+ try {
5339
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
5340
+ if (typeof value === "string" && value.length > 0) return value;
5341
+ } catch {
5342
+ }
5343
+ }
5344
+ return void 0;
5345
+ }
4526
5346
  async function resolveApiKey(options) {
4527
5347
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
4528
5348
  if (typeof process !== "undefined") {
4529
5349
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
4530
5350
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4531
5351
  }
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;
5352
+ return readCredentialField(options, "apiKey");
4542
5353
  }
4543
5354
  async function resolvePulseAuth(options) {
4544
5355
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -4546,15 +5357,8 @@ async function resolvePulseAuth(options) {
4546
5357
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
4547
5358
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
4548
5359
  }
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
- }
5360
+ const fromFile = await readCredentialField(options, "pulseAuth");
5361
+ if (fromFile !== void 0) return fromFile;
4558
5362
  return resolveApiKey(options);
4559
5363
  }
4560
5364
  function byPhase(rules, phase) {
@@ -4839,11 +5643,13 @@ function applyHeaderMutation(headers, rule) {
4839
5643
  }
4840
5644
  }
4841
5645
  }
5646
+ var SAME_SITE = ["Strict", "Lax", "None"];
4842
5647
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
4843
5648
  let out = String(cookie);
4844
5649
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
4845
5650
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
4846
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
5651
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
5652
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
4847
5653
  return out;
4848
5654
  }
4849
5655
  function rebuildResponse(response, body, redactedHeaders) {
@@ -4857,11 +5663,9 @@ function rebuildResponse(response, body, redactedHeaders) {
4857
5663
  } catch {
4858
5664
  }
4859
5665
  } else if (typeof value === "string") {
4860
- if (headers.get(name) !== value) {
4861
- try {
4862
- headers.set(name, value);
4863
- } catch {
4864
- }
5666
+ try {
5667
+ if (headers.get(name) !== value) headers.set(name, value);
5668
+ } catch {
4865
5669
  }
4866
5670
  } else if (Array.isArray(value)) {
4867
5671
  try {