@nextcommerce/campaigns-os 1.60.0 → 1.61.0

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 (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
package/src/readback.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  * usually subdirectories of their enclosing campaign repository — to locate the
11
11
  * Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
12
12
  * and normalized Campaign Build Brief the Build Packet names, whose current
13
- * content decides each stage's effective status. It writes nothing,
13
+ * content decides each stage's effective status. It also hashes built output
14
+ * and maps file drift against the recorded assembly manifest to evidence debt.
15
+ * It writes nothing,
14
16
  * starts no process, and touches no network. That contract is why the CLI
15
17
  * exempts `readback` from lifecycle-journal capture the way it exempts doctor
16
18
  * inspection: a command declared read-only must not append a journal entry.
@@ -53,6 +55,7 @@ import { fileURLToPath } from "node:url";
53
55
 
54
56
  import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
55
57
  import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
58
+ import { assessEvidenceDebt } from "./evidence-debt.mjs";
56
59
 
57
60
  export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
58
61
  export const MAX_GIT_METADATA_BYTES = 64 * 1024;
@@ -942,7 +945,7 @@ function renderStaleness(staleness, lines) {
942
945
  for (const key of staleness.stale_keys) {
943
946
  lines.push(` ${ARTIFACT_TITLES[key]} (generated ${formatUtc(staleness.artifacts[key].generated_at)})`);
944
947
  }
945
- lines.push(" Every section below describes the repository as it was when the artifacts");
948
+ lines.push(" Recorded artifact sections describe the repository as it was when the artifacts");
946
949
  lines.push(" were generated, not necessarily as it is now; a new run must regenerate");
947
950
  lines.push(" them before this view is current.");
948
951
  } else {
@@ -1284,6 +1287,36 @@ export function readbackInputs(views) {
1284
1287
  return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
1285
1288
  }
1286
1289
 
1290
+ function readbackEvidenceDebt(views) {
1291
+ return assessEvidenceDebt({
1292
+ packet: artifactData(views, "packet"),
1293
+ packetPath: views.packet?.path,
1294
+ report: artifactData(views, "report"),
1295
+ });
1296
+ }
1297
+
1298
+ function renderEvidenceDebt(debt, lines) {
1299
+ lines.push("EVIDENCE DEBT [the readback's own projection layer: recorded build bindings versus built output]");
1300
+ for (const name of ["polish", "qa"]) {
1301
+ const stage = debt[name];
1302
+ const explanation = stage.status === "current" ? "recorded build matches the current build"
1303
+ : stage.status === "owed" ? "evidence is owed for the current build"
1304
+ : `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
1305
+ lines.push(` ${name}: ${stage.status} — ${explanation}`);
1306
+ lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
1307
+ }
1308
+ const changes = debt.changes;
1309
+ lines.push(` changes against assembly manifest: ${changes.status}${changes.reason ? ` (${changes.reason})` : ""}`);
1310
+ lines.push(` recorded: ${changes.recorded_build_fingerprint ?? "unknown"}; current: ${changes.current_build_fingerprint ?? "unknown"}`);
1311
+ for (const page of changes.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
1312
+ for (const path of changes.unmapped_files) lines.push(` unmapped file: ${oneLine(path)}`);
1313
+ for (const kind of ["extra", "missing", "changed"]) {
1314
+ if (changes[kind].length) lines.push(` ${kind} files: ${changes[kind].map(oneLine).join(", ")}`);
1315
+ }
1316
+ if (changes.status === "known" && !changes.changed_pages.length && !changes.unmapped_files.length) lines.push(" no files differ from the recorded assembly manifest");
1317
+ lines.push(" Page changes use the assembly baseline; older stage manifests are not reconstructed.", "");
1318
+ }
1319
+
1287
1320
  // Superseded records shown per stage, newest last (the report keeps five).
1288
1321
  const HISTORY_RENDER_CAP = 5;
1289
1322
 
@@ -1544,7 +1577,7 @@ function renderFindings(views, lines) {
1544
1577
  * `inputs` is readbackInputs(views); when omitted, every input reads as not
1545
1578
  * readable, so a build, Polish or QA record reads unknown.
1546
1579
  */
1547
- export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
1580
+ export function projectReadback(views, staleness = null, packetSelection = null, inputs = null, evidenceDebt = assessEvidenceDebt()) {
1548
1581
  const lines = [
1549
1582
  "CAMPAIGNS OS RUN-ARTIFACT READBACK",
1550
1583
  "A read-only projection of this run's emitted artifacts. Campaigns OS",
@@ -1557,6 +1590,7 @@ export function projectReadback(views, staleness = null, packetSelection = null,
1557
1590
  renderArtifactTable(views, lines, packetSelection);
1558
1591
  renderIdentity(views, lines);
1559
1592
  renderStages(views, lines, inputs);
1593
+ renderEvidenceDebt(evidenceDebt, lines);
1560
1594
  renderContext(views, lines);
1561
1595
  renderDoctor(views, lines);
1562
1596
  renderVerdict(views, lines);
@@ -1602,13 +1636,15 @@ export function serializeStaleness(staleness) {
1602
1636
  /**
1603
1637
  * Whether this projection shows nothing the readback can call wrong.
1604
1638
  *
1605
- * True only when all five conditions hold: every artifact the readback found is
1639
+ * True only when all six conditions hold: every artifact the readback found is
1606
1640
  * loaded and recognized (absent artifacts are not counted against it — a run
1607
1641
  * that emitted no findings export is not thereby unclean, while an unreadable
1608
1642
  * or unrecognized one always is); the staleness comparison is computable and NO
1609
1643
  * loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
1610
1644
  * readback could not parse; no cross-artifact divergence was found; and the
1611
- * doctor output records zero errors.
1645
+ * doctor output records zero errors; and no evidence-debt stage is owed.
1646
+ * Unknown build binding does not independently change this integrity flag;
1647
+ * callers must read evidence_debt rather than treating clean as no debt.
1612
1648
  *
1613
1649
  * The fourth of those is the unknown-age rule: an artifact whose recorded age
1614
1650
  * did not parse was never shown to be current, and `clean` states that the
@@ -1626,7 +1662,9 @@ export function serializeStaleness(staleness) {
1626
1662
  * OS recorded. docs/readback.md states the rule and its limits for callers
1627
1663
  * gating on it.
1628
1664
  */
1629
- export function computeClean(views, staleness) {
1665
+ export function computeClean(views, staleness, evidenceDebt) {
1666
+ if (evidenceDebt == null) throw new TypeError("computeClean requires an evidence-debt assessment");
1667
+ if ([evidenceDebt?.polish, evidenceDebt?.qa].some((stage) => stage?.status === "owed")) return false;
1630
1668
  if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
1631
1669
  if (!staleness || !staleness.computable || staleness.stale) return false;
1632
1670
  if (staleness.unparseable_keys.length) return false;
@@ -1661,8 +1699,12 @@ function artifactRowDetail(view, staleness) {
1661
1699
  *
1662
1700
  * `packet_selection` and `staleness` are `null` when a programmatic caller
1663
1701
  * builds a payload without them; the CLI always supplies both.
1702
+ * `evidenceDebt` is projectTarget's or projectExample's assessment. The default
1703
+ * calls assessEvidenceDebt with no packet inputs, which returns unknown with
1704
+ * packet_unavailable before any filesystem access. Callers with loaded packet
1705
+ * data must supply their computed assessment; this serializer never hashes files.
1664
1706
  */
1665
- export function buildJsonPayload(views, staleness = null, packetSelection = null) {
1707
+ export function buildJsonPayload(views, staleness = null, packetSelection = null, evidenceDebt = assessEvidenceDebt()) {
1666
1708
  return {
1667
1709
  schema_version: JSON_SCHEMA_VERSION,
1668
1710
  artifacts: Object.values(views).map((view) => ({
@@ -1676,7 +1718,8 @@ export function buildJsonPayload(views, staleness = null, packetSelection = null
1676
1718
  doctor: computeDoctorSummary(views),
1677
1719
  skip_cascades: computeSkipCascades(views),
1678
1720
  divergences: computeDivergences(views),
1679
- clean: computeClean(views, staleness),
1721
+ evidence_debt: evidenceDebt,
1722
+ clean: computeClean(views, staleness, evidenceDebt),
1680
1723
  };
1681
1724
  }
1682
1725
 
@@ -1853,7 +1896,7 @@ export function resolvePaths(root, overrides = {}) {
1853
1896
  export function projectTarget(root, overrides = {}) {
1854
1897
  const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
1855
1898
  const views = loadArtifacts(paths, { packet: packetView });
1856
- return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
1899
+ return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views), evidenceDebt: readbackEvidenceDebt(views) };
1857
1900
  }
1858
1901
 
1859
1902
  // The bundled synthetic sample `--example` projects. It is already on the
@@ -1885,6 +1928,7 @@ export function projectExample() {
1885
1928
  const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
1886
1929
  const views = loadArtifacts(paths, { packet: packetView });
1887
1930
  const inputs = readbackInputs(views);
1931
+ const evidenceDebt = readbackEvidenceDebt(views);
1888
1932
  for (const view of Object.values(views)) {
1889
1933
  const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
1890
1934
  view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
@@ -1892,7 +1936,7 @@ export function projectExample() {
1892
1936
  const staleness = assessStaleness(EXAMPLE_ROOT, views, {
1893
1937
  headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
1894
1938
  });
1895
- return { views, staleness, packetSelection, inputs };
1939
+ return { views, staleness, packetSelection, inputs, evidenceDebt };
1896
1940
  }
1897
1941
 
1898
1942
  const OVERRIDE_FLAGS = {
@@ -1986,9 +2030,9 @@ export function runReadbackCommand(args) {
1986
2030
  if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
1987
2031
  throw error;
1988
2032
  }
1989
- const { views, staleness, packetSelection, inputs } = projection;
2033
+ const { views, staleness, packetSelection, inputs, evidenceDebt } = projection;
1990
2034
  const text = request.json
1991
- ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
1992
- : projectReadback(views, staleness, packetSelection, inputs);
2035
+ ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection, evidenceDebt), null, 2)}\n`
2036
+ : projectReadback(views, staleness, packetSelection, inputs, evidenceDebt);
1993
2037
  return { exitCode: 0, text };
1994
2038
  }
@@ -2,10 +2,10 @@
2
2
  // checks (#303).
3
3
  //
4
4
  // Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
5
- // v0.4.42 (the SDK pin every certified starter family ships today): every
6
- // data-next-* name the SDK's non-test TypeScript names as a string, plus every
7
- // `dataset.next*` property it reads or writes, turned back into its
8
- // attribute. Only the data-next-* names are kept: the checks that read this
5
+ // v0.4.43; the certified starter families and the SDK support policy still
6
+ // name 0.4.42, whose names are the same: every data-next-* name the SDK's non-test TypeScript names as a
7
+ // string, plus every `dataset.next*` property it reads or writes, turned back
8
+ // into its attribute. Only the data-next-* names are kept: the checks that read this
9
9
  // list ask "does the SDK read this data-next-* attribute at all", and the
10
10
  // SDK's other attribute families (data-item-*, data-package-*, plain data-*)
11
11
  // are not what an agent kit misspells.
@@ -15,8 +15,8 @@
15
15
  // whole from the source at a named tag, never edited by hand.
16
16
  //
17
17
  // paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
18
- // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.42 -- "${paths[@]}"
19
- // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.42 -- "${paths[@]}" \
18
+ // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.43 -- "${paths[@]}"
19
+ // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.43 -- "${paths[@]}" \
20
20
  // | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
21
21
  // } | sort -u
22
22
  //
@@ -31,14 +31,14 @@
31
31
  // data-next-bump-section, data-next-button-text, the variant option state).
32
32
  // At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
33
33
  // those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
34
- // returns the same 149 names as at v0.4.41.
34
+ // returns the same 149 names as at v0.4.41, and at v0.4.43 the same again.
35
35
  //
36
36
  // A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
37
37
  // suffix.
38
38
 
39
39
  import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
40
40
 
41
- export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.42";
41
+ export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.43";
42
42
 
43
43
  export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
44
44
  "data-next-accordion",
@@ -365,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
365
365
  });
366
366
 
367
367
  // The SDK versions whose renderer files above were verified unchanged from
368
- // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41 and v0.4.42, for
369
- // the eight renderer files cited above plus cart-item-list.enhancer.ts and
368
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41, v0.4.42 and
369
+ // v0.4.43, for the eight renderer files cited above plus cart-item-list.enhancer.ts and
370
370
  // order-item-list.enhancer.ts). A page whose loader pins another version
371
371
  // cannot pass the placeholder check.
372
- export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
372
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42", "0.4.43"]);
@@ -45,6 +45,18 @@
45
45
  // TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
46
46
  // are single-brace and conditions are no-brace;
47
47
  // a double brace renders literally.
48
+ // PAGE_TYPE_ATTRIBUTE data-next-page-type on a page with no
49
+ // <meta name="next-page-type"> (campaigns-os#683).
50
+ // The SDK never reads the attribute. The meta tag
51
+ // is the source every reader uses; a
52
+ // window.nextConfig.pageType reaches the config
53
+ // store only, not the upsell page view, so it
54
+ // does not stand in for the meta here.
55
+ // UPSELL_PAGE_TYPE_MISSING a data-next-upsell container on a page whose
56
+ // next-page-type meta is not "upsell". The SDK
57
+ // records the upsell page view only from that meta
58
+ // tag, so reports show the purchase with no upsell
59
+ // view before it.
48
60
  //
49
61
  // Not a finding: data-next-is-upsell="true" on a checkout order bump. The
50
62
  // selected bump is a line item on the checkout order, tagged as an upsell so
@@ -91,10 +103,34 @@ export const SDK_MARKUP_CODES = Object.freeze({
91
103
  RECEIPT_ORDER_ITEMS_CART_VISIBILITY: { code: `${SDK_MARKUP}.receipt_order_items_cart_visibility`, severity: "error" },
92
104
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
93
105
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
106
+ PAGE_TYPE_ATTRIBUTE: { code: `${SDK_MARKUP}.page_type_attribute`, severity: "warning" },
107
+ UPSELL_PAGE_TYPE_MISSING: { code: `${SDK_MARKUP}.upsell_page_type_missing`, severity: "warning" },
94
108
  // Unknown data-next-* names are not a finding and carry no code: they are
95
109
  // information on the gate (unknown_attributes[]) and one advisory ready line.
96
110
  });
97
111
 
112
+ // The page types the SDK has (Campaign Cart v0.4.42 `PageType`), so a remedy
113
+ // never proposes a value the SDK does not have.
114
+ export const SDK_PAGE_TYPES = Object.freeze(["product", "cart", "checkout", "upsell", "receipt"]);
115
+ // Campaign page-type names this repository already reads as one of those, and
116
+ // only those; anything else is left for the operator to name. Sources:
117
+ // - downsell: a post-purchase page (POST_PURCHASE_PAGE_TYPES in
118
+ // upsell-selector-scope.mjs); the SDK records its page view only as upsell.
119
+ // - thank-you, thankyou, confirmation: the receipt role (normalizedPageRole in
120
+ // design-source-package.mjs).
121
+ // - presell, landing, advertorial: pre-checkout pages whose SDK page type is
122
+ // product (PRE_CHECKOUT_PAGE_TYPES in doctor/checks.mjs; normalizedPageRole).
123
+ export const SDK_PAGE_TYPE_ALIASES = Object.freeze({
124
+ downsell: "upsell",
125
+ "thank-you": "receipt", thankyou: "receipt", confirmation: "receipt",
126
+ presell: "product", landing: "product", advertorial: "product",
127
+ });
128
+
129
+ function sdkPageType(value) {
130
+ const key = String(value || "").trim().toLowerCase();
131
+ return SDK_PAGE_TYPES.includes(key) ? key : SDK_PAGE_TYPE_ALIASES[key] || null;
132
+ }
133
+
98
134
  // Attributes whose value names a <template> by id. The SDK reads the template
99
135
  // they point at, so that template is SDK-owned wherever it sits.
100
136
  export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
@@ -210,9 +246,48 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
210
246
  const templates = []; // { entry, sdkOwned }
211
247
  const referencedTemplateIds = new Set();
212
248
  let receiptPage = String(page_type || "").trim().toLowerCase() === "receipt";
213
- walkElements(document, ({ tag, attrs: a }) => {
214
- if (tag === "meta" && a.get("name")?.toLowerCase() === "next-page-type" && a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
249
+ // The page's own next-page-type meta (first one, as querySelector reads it),
250
+ // the first data-next-page-type element and the first upsell container.
251
+ let pageTypeMeta = null;
252
+ let pageTypeAttribute = null;
253
+ let upsellContainer = null;
254
+ walkElements(document, (entry) => {
255
+ const { tag, attrs: a } = entry;
256
+ if (tag === "meta" && a.get("name")?.trim().toLowerCase() === "next-page-type") {
257
+ if (a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
258
+ if (pageTypeMeta === null) pageTypeMeta = (a.get("content") || "").trim();
259
+ }
260
+ if (!pageTypeAttribute && a.has("data-next-page-type")) pageTypeAttribute = entry;
261
+ if (!upsellContainer && a.has("data-next-upsell")) upsellContainer = entry;
215
262
  });
263
+ // The meta value the remedy names: a receipt is "receipt" and an upsell
264
+ // offer page "upsell", whatever the attribute says; otherwise the
265
+ // attribute's SDK page type, or none when it has no SDK name.
266
+ let attributeNamesUpsell = false;
267
+ if (pageTypeAttribute && pageTypeMeta === null) {
268
+ const value = (pageTypeAttribute.attrs.get("data-next-page-type") || "").trim();
269
+ const fromValue = sdkPageType(value);
270
+ const mapped = receiptPage ? "receipt" : upsellContainer ? "upsell" : fromValue;
271
+ attributeNamesUpsell = mapped === "upsell";
272
+ const note = !mapped || !value || value.toLowerCase() === mapped
273
+ ? ""
274
+ : fromValue === mapped
275
+ ? ` (the SDK's name for "${value}" is "${mapped}")`
276
+ : ` (this page is ${receiptPage ? "a receipt" : "an upsell offer, with a data-next-upsell container"}, so its SDK page type is "${mapped}")`;
277
+ const remedy = mapped
278
+ ? `Add <meta name="next-page-type" content="${mapped}"> to the page head${note}.`
279
+ : `Add <meta name="next-page-type"> to the page head with the SDK page type this page is: one of ${SDK_PAGE_TYPES.join(", ")}.`;
280
+ findings.push(finding("PAGE_TYPE_ATTRIBUTE", page_id, where,
281
+ `${describe(pageTypeAttribute)} on ${where} carries data-next-page-type="${value}", which the SDK does not read, and the page has no <meta name="next-page-type">. The SDK takes the page type from that meta tag, so this page runs as the default "product" step${attributeNamesUpsell ? " and the upsell page view is never recorded" : ""}. ${remedy}`,
282
+ { value, suggested: mapped, element: pageTypeAttribute.tag }));
283
+ }
284
+ // One finding per root cause: skipped only when the attribute finding
285
+ // above already names the upsell meta.
286
+ if (upsellContainer && !attributeNamesUpsell && (pageTypeMeta || "").toLowerCase() !== "upsell" && !receiptPage) {
287
+ findings.push(finding("UPSELL_PAGE_TYPE_MISSING", page_id, where,
288
+ `${describe(upsellContainer)} on ${where} is an upsell offer (data-next-upsell), but the page's next-page-type meta is ${pageTypeMeta === null ? "missing" : `"${pageTypeMeta}"`}. The SDK records the upsell page view only from <meta name="next-page-type" content="upsell">, so reports show the purchase with no upsell view before it. Add that meta tag to the page head.`,
289
+ { meta_content: pageTypeMeta }));
290
+ }
216
291
 
217
292
  walkElements(document, (entry) => {
218
293
  const { tag, attrs: a, ancestors } = entry;
@@ -49,6 +49,9 @@ function normalizeScope(value) {
49
49
  throw new Error('Scope and exclusions must be literal repository-relative files or directories.');
50
50
  return value.replace(/^\.\//, '').replace(/\/$/, '') || '.';
51
51
  }
52
+ const PREFIX_SCAN_REASONS = new Set(['dynamic-key', 'unsupported-storage-method', 'storage-object-escape']);
53
+ const LOOPS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
54
+ const ITERATION_METHODS = new Set(['forEach', 'filter', 'map', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce']);
52
55
  const inside = (path, scope) => scope === '.' || path === scope || path.startsWith(scope + '/');
53
56
  export function readStorageManifest(path) {
54
57
  const bytes = readFileSync(path);
@@ -219,7 +222,72 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
219
222
  else
220
223
  reason = 'literal-unaffected-key';
221
224
  }
222
- emit(node, { operation, area: storage.name, key, status, reason, replacement });
225
+ const prefixScan = status === 'unknown' && !storage.shadowed && PREFIX_SCAN_REASONS.has(reason) ? sdkPrefixScan(storage.name, node) : null;
226
+ emit(node, { operation, area: storage.name, key, status, reason, replacement, ...(prefixScan && { prefixScan }) });
227
+ }
228
+ // A loop that matches storage keys by a scoped SDK key's prefix: its key()/getItem(k) lines stay unknown, but are named.
229
+ // A prefix test counts only when it tests a key the loop read from storage itself: a `k = storage.key(i)` binding,
230
+ // a for-of/for-in variable over `Object.keys(storage)` or `storage`, or the element parameter of an array callback
231
+ // chained on `Object.keys(storage)`. A line is named only when it is that key's source or reads that key.
232
+ // Names are matched within the loop, not resolved through scopes; it is a hint only.
233
+ const stack = [];
234
+ const prefixCache = new WeakMap();
235
+ const callName = call => call?.type === 'CallExpression' && call.callee.type === 'MemberExpression' ? (call.callee.computed ? literal(call.callee.property, scopes.get(call)) : call.callee.property.name) : null;
236
+ const keysOf = node => node?.type === 'CallExpression' && node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object' && callName(node) === 'keys' ? node.arguments[0] : null;
237
+ function keySource(node) {
238
+ // Object.keys(storage).filter(...).forEach(...): the chain bottoms out at Object.keys.
239
+ while (node?.type === 'CallExpression' && ITERATION_METHODS.has(callName(node)))
240
+ node = node.callee.object;
241
+ return keysOf(node);
242
+ }
243
+ function prefixTests(loop) {
244
+ if (prefixCache.has(loop)) return prefixCache.get(loop);
245
+ const keys = new Map(), sources = new Map(), tests = [];
246
+ // source: the storage reference the key came from (the key() call, or the storage in Object.keys(storage) / for-in).
247
+ const bind = (id, storageNode, source = storageNode) => {
248
+ const storage = storageNode && area(storageNode, scopes.get(storageNode));
249
+ if (id?.type !== 'Identifier' || !storage) return;
250
+ keys.set(id.name, storage.name);
251
+ sources.set(source, id.name);
252
+ };
253
+ const loopVariable = left => left?.type === 'VariableDeclaration' ? left.declarations[0]?.id : left;
254
+ (function walk(n) {
255
+ if (n.type === 'VariableDeclarator' && callName(n.init) === 'key')
256
+ bind(n.id, n.init.callee.object, n.init);
257
+ if (n.type === 'AssignmentExpression' && n.operator === '=' && callName(n.right) === 'key')
258
+ bind(n.left, n.right.callee.object, n.right);
259
+ if (n.type === 'ForOfStatement')
260
+ bind(loopVariable(n.left), keysOf(n.right));
261
+ if (n.type === 'ForInStatement')
262
+ bind(loopVariable(n.left), n.right);
263
+ if (n.type === 'CallExpression' && ITERATION_METHODS.has(callName(n)) && ['ArrowFunctionExpression', 'FunctionExpression'].includes(n.arguments[0]?.type))
264
+ bind(n.arguments[0].params[callName(n) === 'reduce' ? 1 : 0], keySource(n.callee.object));
265
+ if (callName(n) === 'startsWith')
266
+ tests.push({ receiver: n.callee.object, prefix: literal(n.arguments[0], scopes.get(n)) });
267
+ if (n.type === 'BinaryExpression' && ['===', '==', '!==', '!='].includes(n.operator))
268
+ for (const [call, zero] of [[n.left, n.right], [n.right, n.left]])
269
+ if (zero.type === 'Literal' && zero.value === 0 && callName(call) === 'indexOf' && call.arguments.length === 1)
270
+ tests.push({ receiver: call.callee.object, prefix: literal(call.arguments[0], scopes.get(call)) });
271
+ for (const child of children(n)) walk(child);
272
+ })(loop);
273
+ const prefixes = tests.filter(t => t.prefix && t.receiver.type === 'Identifier' && keys.has(t.receiver.name)).map(t => ({ name: t.receiver.name, prefix: t.prefix, area: keys.get(t.receiver.name) }));
274
+ const result = { prefixes, sources };
275
+ prefixCache.set(loop, result);
276
+ return result;
277
+ }
278
+ function sdkPrefixScan(storageName, node) {
279
+ // The key this line reads (getItem(k), storage[k]), if it is a plain name.
280
+ const argument = node.type === 'CallExpression' ? node.arguments[0] : node.type === 'MemberExpression' && node.computed ? node.property : null;
281
+ // Nearest matching loop wins; a loop that does not test this line's key passes to the one around it.
282
+ for (const loop of [...stack].reverse()) {
283
+ if (!LOOPS.has(loop.type) && !(loop.type === 'CallExpression' && ITERATION_METHODS.has(callName(loop)))) continue;
284
+ const { prefixes, sources } = prefixTests(loop);
285
+ const names = [sources.get(node), argument?.type === 'Identifier' ? argument.name : null];
286
+ const tested = prefixes.filter(t => t.area === storageName && names.includes(t.name)).map(t => t.prefix);
287
+ const entry = manifest.keys.find(e => e.scoped && e.areas.includes(storageName) && e.key.split('{')[0] && tested.includes(e.key.split('{')[0]));
288
+ if (entry) return { prefix: entry.key.split('{')[0], key: entry.key, replacement: entry.replacement ?? null };
289
+ }
290
+ return null;
223
291
  }
224
292
  const handled = new WeakSet();
225
293
  const aliasInitialization = (node, parent) => parent?.type === 'VariableDeclarator' && parent.init === node && parent.id.type === 'Identifier' && binding(parent.id.name, scopes.get(parent))?.init === node;
@@ -229,6 +297,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
229
297
  (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(parent?.type) && parent.label === node)
230
298
  );
231
299
  function visit(node, parent) {
300
+ stack.push(node);
232
301
  const scope = scopes.get(node);
233
302
  if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
234
303
  const member = node.callee, storage = area(member.object, scope), method = member.computed ? literal(member.property, scope) : member.property.name;
@@ -258,6 +327,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
258
327
  }
259
328
  for (const child of children(node))
260
329
  visit(child, node);
330
+ stack.pop();
261
331
  }
262
332
  visit(ast, null);
263
333
  return findings;
@@ -314,7 +384,8 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
314
384
  const selected = tracked.filter(p => /\.(?:html?|[cm]?js)$/i.test(p) && scope.some(s => inside(p, s)) && !exclude.some(s => inside(p, s))).sort();
315
385
  const files = [], findings = [];
316
386
  const unknown = (path, reason, detail, line = 1) => findings.push({ path, line, column: 1, operation: 'unknown', area: null, key: null, status: 'unknown', reason, detail, replacement: null });
317
- if (compare(targetSdkVersion, manifest.supportedSdkVersions.min) < 0 || compare(targetSdkVersion, manifest.supportedSdkVersions.max) > 0)
387
+ const targetCovered = compare(targetSdkVersion, manifest.supportedSdkVersions.min) >= 0 && compare(targetSdkVersion, manifest.supportedSdkVersions.max) <= 0;
388
+ if (!targetCovered)
318
389
  unknown(evidence.path, 'target-outside-manifest-range', targetSdkVersion);
319
390
  if (!selected.length)
320
391
  unknown('.', 'empty-source-scope', 'No tracked HTML or JavaScript files selected.');
@@ -412,9 +483,28 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
412
483
  html(document);
413
484
  }
414
485
  const status = findings.some(f => f.status === 'incompatible') ? 'incompatible' : findings.some(f => f.status === 'unknown') ? 'unknown' : 'source-compatible';
415
- return { schemaVersion: 1, targetSdkVersion, manifest: evidence, scope, exclude, files, findings, status, evidenceBoundary: 'Static tracked source compatibility only; no browser, order, or analytics proof.' };
486
+ return { schemaVersion: 1, targetSdkVersion, targetCovered, manifest: evidence, scope, exclude, files, findings, status, evidenceBoundary: 'Static tracked source compatibility only; no browser, order, or analytics proof.' };
416
487
  }
488
+ const sdkRange = ({ min, max }) => min === max ? min : `${min} to ${max}`;
417
489
  export function formatStorageCompatibilityReport(result) {
418
490
  const counts = status => result.findings.filter(f => f.status === status).length;
419
- return [`SDK storage compatibility: ${result.status} (target ${result.targetSdkVersion})`, `${result.files.length} tracked source files; ${counts('incompatible')} incompatible, ${counts('unknown')} unknown, ${counts('unaffected')} unaffected storage accesses.`, `Manifest ${result.manifest.sha256} (${result.manifest.source.verification})`, ...result.findings.filter(f => f.status !== 'unaffected').map(f => `${f.path}:${f.line}:${f.column} ${f.status}: ${f.reason}${f.key ? ` (${f.key})` : ''}${f.detail ? ` — ${f.detail}` : ''}`), result.evidenceBoundary].join('\n');
491
+ const target = result.targetSdkVersion, covered = sdkRange(result.manifest.supportedSdkVersions);
492
+ const outside = result.targetCovered === false;
493
+ const header = outside
494
+ ? [`SDK storage compatibility: ${result.status} (checked against SDK ${covered}, not the requested target ${target})`, `The manifest (Campaign Cart ${result.manifest.sdkVersion}) declares support for SDK ${covered} only, so this report does not speak for ${target}.`]
495
+ : [`SDK storage compatibility: ${result.status} (target ${target})`];
496
+ const summary = `${result.files.length} tracked source files; ${counts('incompatible')} incompatible, ${counts('unknown')} unknown, ${counts('unaffected')} unaffected storage accesses${outside ? ` (against SDK ${covered}, not ${target})` : ''}.`;
497
+ const line = f => `${f.path}:${f.line}:${f.column} ${f.status}: ${f.reason}${f.key ? ` (${f.key})` : ''}${f.detail ? ` — ${f.detail}` : ''}${f.prefixScan ? ` [prefix loop: ${f.prefixScan.prefix}]` : ''}`;
498
+ return [...header, summary, `Manifest ${result.manifest.sha256} (${result.manifest.source.verification})`, ...result.findings.filter(f => f.status !== 'unaffected').map(line), ...prefixScanNote(result.findings), result.evidenceBoundary].join('\n');
499
+ }
500
+ // One note for every [prefix loop] line, so a reader can tell them from unknowns with no known shape.
501
+ function prefixScanNote(findings) {
502
+ const scans = findings.filter(f => f.prefixScan);
503
+ if (!scans.length) return [];
504
+ const prefixes = [...new Set(scans.map(f => f.prefixScan.prefix))];
505
+ const replacements = [...new Map(scans.filter(f => f.prefixScan.replacement).map(f => [f.prefixScan.prefix, f.prefixScan.replacement])).entries()];
506
+ return [
507
+ `${scans.length} unknown ${scans.length === 1 ? 'line is' : 'lines are'} [prefix loop]: a loop that matches storage keys starting with ${prefixes.join(', ')}. Such a loop finds the SDK key with or without its scope suffix, so the scoping rename alone does not break it. It stays unknown because on an origin shared with another campaign, or another top folder of this one, the same prefix also matches that copy; check which key the loop takes.`,
508
+ ...replacements.map(([prefix, r]) => `For ${prefix}, the manifest names the public ${r.export} export instead (${r.guide}).`),
509
+ ];
420
510
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { normalizeDesignFileReference } from "./design-file-reference.mjs";
2
3
  import { existsSync, readFileSync, statSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
 
@@ -165,6 +166,12 @@ function validateManifestPage(entry, index, add, addWarning = () => {}) {
165
166
  }
166
167
  }
167
168
  warnOnUnusableScreenshotRecords(entry, location, addWarning);
169
+ if (Object.hasOwn(entry, "design_file")) {
170
+ const { declaration, warnings } = normalizeDesignFileReference(entry.design_file);
171
+ for (const detail of warnings) {
172
+ addWarning(`${location}.design_file`, `${location}.design_file (page_id ${JSON.stringify(entry.page_id)}) ${declaration ? "has an ignored frame" : "is ignored as a design-file declaration"}: ${detail}. The rest of the manifest is used as written.`);
173
+ }
174
+ }
168
175
  }
169
176
 
170
177
  // Screenshot records are optional proof, so a malformed one is not a reason to
@@ -0,0 +1,129 @@
1
+ import { createServer } from "node:http";
2
+ import { constants, openSync, fstatSync, readSync, closeSync, realpathSync, lstatSync } from "node:fs";
3
+ import { realpath, stat, lstat, open } from "node:fs/promises";
4
+ import { resolve, relative, isAbsolute, extname } from "node:path";
5
+ import { VISUAL_MAX_REFERENCE_BYTES } from "./png-header.mjs";
6
+
7
+ // Declared inputs (packet, manifest and campaign files) are opened without
8
+ // blocking and read only when the opened descriptor is a regular file within
9
+ // `maxBytes`: a FIFO, socket, device or directory is refused before any read,
10
+ // so no declared path can hold the event loop ahead of a deadline.
11
+ // With a `root`, the path is opened first and only then resolved: the
12
+ // descriptor must be the very file (dev and ino) the contained realpath names,
13
+ // so a path replaced between the open and the check is refused, never read.
14
+ // `noFollow` also refuses a final component that is a link.
15
+ export const DECLARED_INPUT_MAX_BYTES = VISUAL_MAX_REFERENCE_BYTES;
16
+ const READ_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
17
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
18
+ const refusal = (code) => Object.assign(new Error(code), { code });
19
+ const inside = (root, path) => { const rel = relative(root, path); return !isAbsolute(rel) && rel !== ".." && !rel.startsWith("../"); };
20
+ // The resolved target is read with lstat: a final component swapped for a link
21
+ // after resolution is that link's own inode, which no opened file matches.
22
+ const sameFile = (info, target) => !target.isSymbolicLink() && info.dev === target.dev && info.ino === target.ino;
23
+ export function readRegularFileSync(path, { maxBytes = DECLARED_INPUT_MAX_BYTES, statOnly = false, root = null, noFollow = false } = {}) {
24
+ const fd = openSync(path, READ_FLAGS | (noFollow ? NO_FOLLOW : 0));
25
+ try {
26
+ const info = fstatSync(fd);
27
+ if (!info.isFile()) throw refusal("not_regular_file");
28
+ if (root !== null) {
29
+ const resolved = realpathSync(path);
30
+ if (!inside(realpathSync(root), resolved) || !sameFile(info, lstatSync(resolved))) throw refusal("outside_root");
31
+ }
32
+ if (info.size > maxBytes) throw refusal("too_large");
33
+ if (statOnly) return null;
34
+ const chunks = [];
35
+ let length = 0;
36
+ while (length <= maxBytes) {
37
+ const chunk = Buffer.alloc(Math.min(65536, maxBytes + 1 - length));
38
+ const n = readSync(fd, chunk, 0, chunk.length, null);
39
+ if (!n) break;
40
+ chunks.push(chunk.subarray(0, n)); length += n;
41
+ }
42
+ if (length > maxBytes) throw refusal("too_large");
43
+ return Buffer.concat(chunks, length);
44
+ } finally { closeSync(fd); }
45
+ }
46
+ // Whether a read of `path` would reach its bytes; the size is not judged here.
47
+ export const isRegularFileSync = (path, { root = null } = {}) => {
48
+ try { readRegularFileSync(path, { statOnly: true, maxBytes: Infinity, root }); return true; } catch { return false; }
49
+ };
50
+ // The server's read: open, fstat, then resolve and match the descriptor against
51
+ // the contained target. Returns the resolved path (for the type) and the bytes.
52
+ async function readContainedFile(base, path, { maxBytes, statOnly }) {
53
+ const handle = await open(path, READ_FLAGS);
54
+ try {
55
+ const info = await handle.stat();
56
+ if (!info.isFile() || info.size > maxBytes) throw refusal("not_served");
57
+ const resolved = await realpath(path);
58
+ if (!inside(base, resolved) || !sameFile(info, await lstat(resolved))) throw refusal("not_served");
59
+ if (statOnly) return { resolved, bytes: null };
60
+ const chunks = [];
61
+ let length = 0;
62
+ while (length <= maxBytes) {
63
+ const { bytesRead, buffer } = await handle.read(Buffer.alloc(Math.min(65536, maxBytes + 1 - length)));
64
+ if (!bytesRead) break;
65
+ chunks.push(buffer.subarray(0, bytesRead)); length += bytesRead;
66
+ }
67
+ if (length > maxBytes) throw refusal("not_served");
68
+ return { resolved, bytes: Buffer.concat(chunks, length) };
69
+ } finally { await handle.close(); }
70
+ }
71
+
72
+ const TYPES = Object.freeze({ ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript",
73
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
74
+ ".webp": "image/webp", ".avif": "image/avif", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
75
+ ".ico": "image/x-icon", ".json": "application/json" });
76
+ // This listener is also the sealed browser's refusing proxy. There is no
77
+ // outbound client: even absolute-form requests to our own origin are refused.
78
+ export async function startSourceHtmlServer({ root, signal, maxBytes = DECLARED_INPUT_MAX_BYTES } = {}) {
79
+ const base = await realpath(root);
80
+ if (!(await stat(base)).isDirectory()) throw new Error("source_root_unavailable");
81
+ if (signal?.aborted) throw signal.reason;
82
+ const refusals = new Map();
83
+ let totalRefusals = 0;
84
+ const record = (target, connect = false) => {
85
+ totalRefusals++;
86
+ try {
87
+ const origin = new URL(connect ? `https://${target}` : target).origin;
88
+ if (refusals.has(origin) || refusals.size < 32) refusals.set(origin, (refusals.get(origin) || 0) + 1);
89
+ } catch { /* never persist a malformed target */ }
90
+ };
91
+ const server = createServer(async (req, res) => {
92
+ res.setHeader("cache-control", "no-store");
93
+ const end = (code) => { res.statusCode = code; res.end(); };
94
+ if (!req.url.startsWith("/") || req.url.startsWith("//")) { record(req.url); return end(403); }
95
+ if (!["GET", "HEAD"].includes(req.method)) return end(405);
96
+ try {
97
+ const pathname = decodeURIComponent(req.url.split("?")[0]);
98
+ if (pathname.includes("\0") || pathname.includes("\\")) return end(404);
99
+ const { resolved, bytes } = await readContainedFile(base, resolve(base, `.${pathname}`), { maxBytes, statOnly: req.method === "HEAD" });
100
+ res.setHeader("content-type", TYPES[extname(resolved).toLowerCase()] || "application/octet-stream");
101
+ res.statusCode = 200;
102
+ res.end(bytes);
103
+ } catch { end(404); }
104
+ });
105
+ server.on("connect", (req, socket) => {
106
+ record(req.url, true);
107
+ socket.end("HTTP/1.1 403 Forbidden\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
108
+ });
109
+ server.on("upgrade", (req, socket) => {
110
+ record(req.url);
111
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
112
+ });
113
+ server.requestTimeout = 5_000;
114
+ server.headersTimeout = 5_000;
115
+ const sockets = new Set();
116
+ server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
117
+ await new Promise((done, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", done); });
118
+ let closing;
119
+ const close = () => closing ||= new Promise((done, reject) => {
120
+ server.close((error) => error ? reject(error) : done());
121
+ for (const socket of sockets) socket.destroy();
122
+ });
123
+ signal?.addEventListener("abort", () => { void close().catch(() => {}); }, { once: true });
124
+ if (signal?.aborted) { await close(); throw signal.reason; }
125
+ const port = server.address().port;
126
+ return { origin: `http://127.0.0.1:${port}`, port,
127
+ refusedTotal: () => totalRefusals,
128
+ refused: () => [...refusals].sort(([a], [b]) => a.localeCompare(b)).map(([origin, count]) => ({ origin, count })), close };
129
+ }