@nextcommerce/campaigns-os 1.59.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.
- package/CHANGELOG.md +242 -0
- package/README.md +11 -10
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +1311 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +41 -3
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -5
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- 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
|
|
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("
|
|
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
|
|
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
|
-
|
|
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.
|
|
6
|
-
// data-next-* name the SDK's non-test TypeScript names as a
|
|
7
|
-
// `dataset.next*` property it reads or writes, turned back
|
|
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.
|
|
19
|
-
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.
|
|
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.
|
|
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
|
|
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"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|