@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,401 @@
1
+ // Local proof mode: a campaign under `deploy.target: local-serve` is built
2
+ // with the page-kit DEVELOPMENT environment, served on localhost, and proven
3
+ // there (polish capture, browser QA, typed-card orders) before anything is
4
+ // committed. The production build is never served locally: the starter
5
+ // templates gate every vendor loader on `{% unless environment ==
6
+ // "development" %}`, several of those loaders are protocol-relative
7
+ // (`//host/...`), and over a plain-HTTP local serve they resolve to http://
8
+ // and fail, which voids the capture unwaivably. Editing the generated include
9
+ // to force https: is not a repair; serving the right environment is.
10
+ //
11
+ // What this module owns:
12
+ // - the development build command the build stage runs under local-serve;
13
+ // - the production-parity check: the proven development output must be what
14
+ // the current source renders in development, and the production render of
15
+ // the same source may differ from it ONLY in what the environment gate
16
+ // contributes. "Gated" is derived, not declared: it is the diff between a
17
+ // development render and a production render of the same source, so no
18
+ // vendor list lives here.
19
+ // - the never-edit rule as one string every renderer quotes.
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join, posix, relative, sep } from "node:path";
25
+
26
+ import { PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND } from "./page-kit-build-summary.mjs";
27
+
28
+ export const LOCAL_PROOF_BUILD_ENVIRONMENT = "development";
29
+ export const LOCAL_PROOF_PRODUCTION_ENVIRONMENT = "production";
30
+ export const LOCAL_PROOF_BUILD_COMMAND = `CPK_ENV=${LOCAL_PROOF_BUILD_ENVIRONMENT} ${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}`;
31
+ export const LOCAL_PROOF_PARITY_SCOPE = "local_proof.production_parity";
32
+ export const LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE = "local_proof.build_environment";
33
+ export const LOCAL_PROOF_PARITY_COMMAND = "campaigns-os page-kit parity --packet <packet>";
34
+ // Where the build stage records which environment it rendered, and where the
35
+ // parity command records its result. Both live under the assembly stage's
36
+ // free-form `evidence` object, which the hashed Assembly Report schema already
37
+ // allows, so neither needs a schema change.
38
+ export const LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD = "stages.assembly.evidence.build_environment";
39
+ export const LOCAL_PROOF_PARITY_FIELD = "stages.assembly.evidence.local_proof.production_parity";
40
+ export const LOCAL_PROOF_NEVER_EDIT_RULE = "Never edit a generated include (analytics-head.html, analytics-body.html, or any _includes/ file marked GENERATED) to make a local capture pass: a vendor loader that fails over plain HTTP is the production build served in the wrong environment, not a template defect.";
41
+
42
+ // The one line every renderer prints when a capture over plain HTTP failed on
43
+ // a cross-origin http: dependency — the signature of a protocol-relative
44
+ // production loader served locally.
45
+ export function localProofRebuildText() {
46
+ return `The served build is a production build over plain HTTP: a cross-origin http: dependency failed to load, which is what a protocol-relative vendor loader (//host/...) does off an http://localhost origin. Rebuild in local proof mode — \`${LOCAL_PROOF_BUILD_COMMAND}\` — record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} as "${LOCAL_PROOF_BUILD_ENVIRONMENT}", serve the development output, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
47
+ }
48
+
49
+ export function isLocalServePacket(packet) {
50
+ return packet?.deploy?.target === "local-serve";
51
+ }
52
+
53
+ function nonEmptyString(value) {
54
+ return typeof value === "string" && value.trim() ? value : null;
55
+ }
56
+
57
+ export function recordedBuildEnvironment(report) {
58
+ return nonEmptyString(report?.stages?.assembly?.evidence?.build_environment);
59
+ }
60
+
61
+ export function recordedProductionParity(report) {
62
+ const value = report?.stages?.assembly?.evidence?.local_proof?.production_parity;
63
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Rendered-output readers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const SDK_LOADER_SRC = /campaign-cart(?:@v?([^"'/]*))?\/dist\/loader\.js/i;
71
+
72
+ // The pin as the rendered page carries it: the Campaign Cart loader src (with
73
+ // the version it pins) and the next-api-key meta when the page declares one.
74
+ export function renderedPagePin(html) {
75
+ const text = typeof html === "string" ? html : "";
76
+ let loaderSrc = null;
77
+ let sdkVersion = null;
78
+ for (const tag of text.matchAll(/<script\b[^>]*>/gi)) {
79
+ const srcMatch = tag[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i);
80
+ if (!srcMatch) continue;
81
+ const loader = srcMatch[1].match(SDK_LOADER_SRC);
82
+ if (!loader) continue;
83
+ loaderSrc = srcMatch[1];
84
+ sdkVersion = loader[1] || null;
85
+ break;
86
+ }
87
+ let apiKeyMeta = null;
88
+ for (const tag of text.matchAll(/<meta\b[^>]*>/gi)) {
89
+ const name = tag[0].match(/\bname\s*=\s*["']([^"']+)["']/i);
90
+ if (!name || name[1] !== "next-api-key") continue;
91
+ const content = tag[0].match(/\bcontent\s*=\s*["']([^"']*)["']/i);
92
+ apiKeyMeta = content ? content[1] : "";
93
+ break;
94
+ }
95
+ return { sdk_loader_src: loaderSrc, sdk_version: sdkVersion, api_key_meta: apiKeyMeta };
96
+ }
97
+
98
+ // Every rendered page under <root>/<slug>/, as slug-relative POSIX paths
99
+ // (`index.html`, `checkout/index.html`), sorted. Assets are not pages: page-kit
100
+ // copies them byte for byte in every environment, and the build fingerprint
101
+ // already covers them.
102
+ export function listRenderedPages(root, slug) {
103
+ const base = join(root, slug);
104
+ if (!existsSync(base) || !statSync(base).isDirectory()) return [];
105
+ const pages = [];
106
+ const walk = (dir) => {
107
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
108
+ const full = join(dir, entry.name);
109
+ if (entry.isDirectory()) walk(full);
110
+ else if (entry.isFile() && entry.name === "index.html") {
111
+ pages.push(relative(base, full).split(sep).join(posix.sep));
112
+ }
113
+ }
114
+ };
115
+ walk(base);
116
+ return pages.sort();
117
+ }
118
+
119
+ export function renderedPageRoute(slug, pagePath) {
120
+ const dir = posix.dirname(pagePath);
121
+ return dir === "." ? `/${slug}/` : `/${slug}/${dir}/`;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Line diff (common prefix/suffix, then LCS on the middle)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function lineDiff(aLines, bLines) {
129
+ let start = 0;
130
+ while (start < aLines.length && start < bLines.length && aLines[start] === bLines[start]) start += 1;
131
+ let aEnd = aLines.length;
132
+ let bEnd = bLines.length;
133
+ while (aEnd > start && bEnd > start && aLines[aEnd - 1] === bLines[bEnd - 1]) {
134
+ aEnd -= 1;
135
+ bEnd -= 1;
136
+ }
137
+ const a = aLines.slice(start, aEnd);
138
+ const b = bLines.slice(start, bEnd);
139
+ const n = a.length;
140
+ const m = b.length;
141
+ // LCS table on the trimmed middle only; the gated block is small next to the page.
142
+ const table = new Array(n + 1);
143
+ for (let i = 0; i <= n; i += 1) table[i] = new Uint32Array(m + 1);
144
+ for (let i = n - 1; i >= 0; i -= 1) {
145
+ for (let j = m - 1; j >= 0; j -= 1) {
146
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
147
+ }
148
+ }
149
+ const removed = [];
150
+ const inserted = [];
151
+ let i = 0;
152
+ let j = 0;
153
+ while (i < n && j < m) {
154
+ if (a[i] === b[j]) {
155
+ i += 1;
156
+ j += 1;
157
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
158
+ removed.push({ line: start + i + 1, text: a[i] });
159
+ i += 1;
160
+ } else {
161
+ inserted.push({ line: start + j + 1, text: b[j] });
162
+ j += 1;
163
+ }
164
+ }
165
+ for (; i < n; i += 1) removed.push({ line: start + i + 1, text: a[i] });
166
+ for (; j < m; j += 1) inserted.push({ line: start + j + 1, text: b[j] });
167
+ return { removed, inserted };
168
+ }
169
+
170
+ // Rendered lines, with either line ending: a CRLF render must diff and number
171
+ // exactly like an LF one, so the terminator is never part of the compared text.
172
+ function renderedLines(text) {
173
+ return text.split(/\r?\n/);
174
+ }
175
+
176
+ function firstDifferingLine(aText, bText) {
177
+ const a = renderedLines(aText);
178
+ const b = renderedLines(bText);
179
+ const limit = Math.min(a.length, b.length);
180
+ for (let index = 0; index < limit; index += 1) {
181
+ if (a[index] !== b[index]) return index + 1;
182
+ }
183
+ return a.length === b.length ? null : limit + 1;
184
+ }
185
+
186
+ // Hosts named by the lines the environment gate contributes, so the summary
187
+ // says which loaders production adds without naming any vendor here.
188
+ function gatedHosts(lines) {
189
+ const hosts = new Set();
190
+ for (const { text } of lines) {
191
+ for (const match of text.matchAll(/(?<![\w.-])((?:https?:)?\/\/[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?)\//gi)) {
192
+ hosts.add(match[1]);
193
+ }
194
+ }
195
+ return [...hosts].sort();
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // The parity comparison
200
+ // ---------------------------------------------------------------------------
201
+
202
+ function readPage(root, slug, pagePath) {
203
+ return readFileSync(join(root, slug, pagePath), "utf8");
204
+ }
205
+
206
+ /**
207
+ * Compare three rendered outputs of one campaign:
208
+ * provenRoot — the served development build the campaign was proven on
209
+ * (normally the target's _site/);
210
+ * developmentRoot — a fresh development render of the current source;
211
+ * productionRoot — a fresh production render of the current source.
212
+ *
213
+ * Pass: every page in the proven output is byte-identical to the fresh
214
+ * development render (nothing changed since the proof), the page set and
215
+ * route slugs agree across all three, and the pin (Campaign Cart loader
216
+ * version and next-api-key meta) is the same in the proven and production
217
+ * pages and matches `expectedSdkVersion` when one is given. The production
218
+ * render's remaining difference from the development render is, by
219
+ * construction, the environment gate's output; it is summarised per page,
220
+ * never judged.
221
+ *
222
+ * Fail: the first non-gated difference, named by route, path, kind and line.
223
+ */
224
+ export function compareRenderedOutputs({ provenRoot, developmentRoot, productionRoot, slug, expectedSdkVersion = null }) {
225
+ const proven = listRenderedPages(provenRoot, slug);
226
+ const development = listRenderedPages(developmentRoot, slug);
227
+ const production = listRenderedPages(productionRoot, slug);
228
+ const pages = [];
229
+ const fail = (difference) => ({
230
+ status: "fail",
231
+ campaign_slug: slug,
232
+ page_count: proven.length,
233
+ pages,
234
+ first_difference: difference,
235
+ summary: `${difference.kind} at ${difference.route}${difference.line ? ` line ${difference.line}` : ""}: ${difference.detail}`,
236
+ });
237
+
238
+ if (proven.length === 0) {
239
+ return fail({ kind: "proven_output_missing", route: `/${slug}/`, path: null, line: null, detail: `no rendered pages under ${posix.join(relativeLabel(provenRoot), slug)}/; run the development build first.` });
240
+ }
241
+ for (const pagePath of proven) {
242
+ const route = renderedPageRoute(slug, pagePath);
243
+ if (!development.includes(pagePath)) {
244
+ return fail({ kind: "page_not_in_source", route, path: pagePath, line: null, detail: "the proven output carries a page the current source no longer renders (a stale file left in the served output); rebuild before proving." });
245
+ }
246
+ if (!production.includes(pagePath)) {
247
+ return fail({ kind: "page_missing_in_production", route, path: pagePath, line: null, detail: "the production render does not produce this page." });
248
+ }
249
+ }
250
+ for (const pagePath of development) {
251
+ if (!proven.includes(pagePath)) {
252
+ return fail({ kind: "page_not_proven", route: renderedPageRoute(slug, pagePath), path: pagePath, line: null, detail: "the current source renders a page the proven output lacks; rebuild and re-prove." });
253
+ }
254
+ }
255
+ for (const pagePath of production) {
256
+ if (!proven.includes(pagePath)) {
257
+ return fail({ kind: "page_only_in_production", route: renderedPageRoute(slug, pagePath), path: pagePath, line: null, detail: "the production render produces a page the development render does not; page routing must not depend on the environment." });
258
+ }
259
+ }
260
+
261
+ let sdkVersion;
262
+ for (const pagePath of proven) {
263
+ const route = renderedPageRoute(slug, pagePath);
264
+ const provenHtml = readPage(provenRoot, slug, pagePath);
265
+ const developmentHtml = readPage(developmentRoot, slug, pagePath);
266
+ const productionHtml = readPage(productionRoot, slug, pagePath);
267
+ const provenPin = renderedPagePin(provenHtml);
268
+ const productionPin = renderedPagePin(productionHtml);
269
+ if (provenHtml !== developmentHtml) {
270
+ const line = firstDifferingLine(provenHtml, developmentHtml);
271
+ const developmentPin = renderedPagePin(developmentHtml);
272
+ // The two ways this goes wrong that have a name: the pin moved after the
273
+ // proof, or the served output is the production render itself.
274
+ if (provenPin.sdk_loader_src !== developmentPin.sdk_loader_src) {
275
+ return fail({ kind: "sdk_pin_drift", route, path: pagePath, line, detail: `the proven output pins Campaign Cart ${provenPin.sdk_version ?? "(none)"} but the current source renders ${developmentPin.sdk_version ?? "(none)"}; the pin changed after the proof. Rebuild in development, re-prove, then check parity again.` });
276
+ }
277
+ if (provenHtml === productionHtml) {
278
+ return fail({ kind: "proven_output_is_production", route, path: pagePath, line, detail: `the served output is the production render, not a development one: the environment gate's output is present. Rebuild with ${LOCAL_PROOF_BUILD_COMMAND} and re-prove; do not serve a production build locally.` });
279
+ }
280
+ return fail({ kind: "proven_output_stale", route, path: pagePath, line, detail: "the proven development output differs from what the current source renders in development; the source changed after the proof (rebuild, re-prove, then check parity again)." });
281
+ }
282
+ if (provenPin.sdk_loader_src !== productionPin.sdk_loader_src) {
283
+ return fail({ kind: "sdk_pin_mismatch", route, path: pagePath, line: null, detail: `Campaign Cart loader differs between the proven output (${provenPin.sdk_loader_src ?? "none"}) and the production render (${productionPin.sdk_loader_src ?? "none"}); the SDK pin must not be environment-gated.` });
284
+ }
285
+ if (provenPin.api_key_meta !== productionPin.api_key_meta) {
286
+ return fail({ kind: "api_key_meta_mismatch", route, path: pagePath, line: null, detail: "the next-api-key meta differs between the proven output and the production render." });
287
+ }
288
+ if (expectedSdkVersion && provenPin.sdk_version && provenPin.sdk_version !== expectedSdkVersion) {
289
+ return fail({ kind: "sdk_version_mismatch", route, path: pagePath, line: null, detail: `the rendered Campaign Cart loader pins ${provenPin.sdk_version} but _data/campaigns.json[${slug}].sdk_version is ${expectedSdkVersion}.` });
290
+ }
291
+ // A missing loader is a value like any other: every page must agree with
292
+ // the first page, and a page that lost its loader fails on its own code.
293
+ if (sdkVersion === undefined) sdkVersion = provenPin.sdk_version;
294
+ else if (provenPin.sdk_version !== sdkVersion) {
295
+ return fail({
296
+ kind: provenPin.sdk_version === null || sdkVersion === null ? "sdk_loader_missing" : "sdk_version_mismatch",
297
+ route,
298
+ path: pagePath,
299
+ line: null,
300
+ detail: provenPin.sdk_version === null
301
+ ? `this page renders no Campaign Cart loader while ${pages[0]?.route ?? "the first page"} pins ${sdkVersion}.`
302
+ : sdkVersion === null
303
+ ? `this page pins Campaign Cart ${provenPin.sdk_version} while ${pages[0]?.route ?? "the first page"} renders no loader.`
304
+ : `pages pin different Campaign Cart versions (${sdkVersion} and ${provenPin.sdk_version}).`,
305
+ });
306
+ }
307
+ const diff = lineDiff(renderedLines(developmentHtml), renderedLines(productionHtml));
308
+ pages.push({
309
+ route,
310
+ path: pagePath,
311
+ sdk_version: provenPin.sdk_version,
312
+ gated_inserted_lines: diff.inserted.length,
313
+ gated_removed_lines: diff.removed.length,
314
+ gated_hosts: gatedHosts(diff.inserted),
315
+ });
316
+ }
317
+ const gatedTotal = pages.reduce((sum, page) => sum + page.gated_inserted_lines + page.gated_removed_lines, 0);
318
+ const hosts = [...new Set(pages.flatMap((page) => page.gated_hosts))].sort();
319
+ return {
320
+ status: "pass",
321
+ campaign_slug: slug,
322
+ page_count: pages.length,
323
+ sdk_version: sdkVersion ?? null,
324
+ pages,
325
+ first_difference: null,
326
+ summary: `${pages.length} page(s) identical to the current development render; production differs only in environment-gated output (${gatedTotal} line(s)${hosts.length ? `; loaders: ${hosts.join(", ")}` : ""}); Campaign Cart pin ${sdkVersion ?? "not rendered"}.`,
327
+ };
328
+ }
329
+
330
+ function relativeLabel(root) {
331
+ const rel = relative(process.cwd(), root);
332
+ return rel && !rel.startsWith("..") ? rel : root;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Rendering through the target's own page-kit
337
+ // ---------------------------------------------------------------------------
338
+
339
+ // Renders the target's source with the page-kit the target installed, into
340
+ // `outputPath`, in the given environment. `campaign-build` has no output-dir
341
+ // flag, so this goes through the package's exported build() — the same
342
+ // function the bin calls — with cwd at the target so page-kit resolves
343
+ // _data/campaigns.json and src/ exactly as `npx campaign-build` would.
344
+ export function renderPageKitOutput({ targetRepo, environment, outputPath }) {
345
+ const script = [
346
+ 'const { build } = require("next-campaign-page-kit");',
347
+ "build({ outputPath: process.argv[1], mode: process.argv[2] }).then((summary) => {",
348
+ " process.stdout.write(JSON.stringify({ built: summary.built, errors: summary.errors, skipped: summary.skipped }));",
349
+ " if (summary.errors > 0) process.exitCode = 1;",
350
+ "}).catch((error) => { process.stderr.write(String((error && error.message) || error)); process.exitCode = 2; });",
351
+ ].join("\n");
352
+ try {
353
+ const stdout = execFileSync(process.execPath, ["-e", script, outputPath, environment], {
354
+ cwd: targetRepo,
355
+ encoding: "utf8",
356
+ stdio: ["ignore", "pipe", "pipe"],
357
+ env: { ...process.env, CPK_ENV: environment },
358
+ });
359
+ return { ok: true, summary: JSON.parse(stdout || "{}"), error: null };
360
+ } catch (error) {
361
+ const stderr = String(error?.stderr || error?.message || error).trim().split("\n")[0];
362
+ const missing = /Cannot find module ['"]next-campaign-page-kit['"]/.test(String(error?.stderr || ""));
363
+ return { ok: false, summary: null, error: missing ? "next-campaign-page-kit is not installed in the target repo (npm install there first)." : stderr };
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Run the production-parity check for one packet against the proven output in
369
+ * `provenRoot` (the served development build). Renders development and
370
+ * production output into temp directories through the target's page-kit and
371
+ * compares. Never writes into the target repo.
372
+ */
373
+ export function runProductionParityCheck({ targetRepo, slug, provenRoot, expectedSdkVersion = null, now = new Date().toISOString() }) {
374
+ const scratch = mkdtempSync(join(tmpdir(), "campaigns-os-local-proof-"));
375
+ try {
376
+ const developmentRoot = join(scratch, "development");
377
+ const productionRoot = join(scratch, "production");
378
+ for (const [environment, outputPath] of [[LOCAL_PROOF_BUILD_ENVIRONMENT, developmentRoot], [LOCAL_PROOF_PRODUCTION_ENVIRONMENT, productionRoot]]) {
379
+ const render = renderPageKitOutput({ targetRepo, environment, outputPath });
380
+ if (!render.ok) {
381
+ return {
382
+ status: "unavailable",
383
+ checked_at: now,
384
+ campaign_slug: slug,
385
+ environment: { proven: LOCAL_PROOF_BUILD_ENVIRONMENT, compared: LOCAL_PROOF_PRODUCTION_ENVIRONMENT },
386
+ pages: [],
387
+ first_difference: null,
388
+ summary: `could not render the ${environment} output through the target's page-kit: ${render.error}`,
389
+ };
390
+ }
391
+ }
392
+ const comparison = compareRenderedOutputs({ provenRoot, developmentRoot, productionRoot, slug, expectedSdkVersion });
393
+ return {
394
+ checked_at: now,
395
+ environment: { proven: LOCAL_PROOF_BUILD_ENVIRONMENT, compared: LOCAL_PROOF_PRODUCTION_ENVIRONMENT },
396
+ ...comparison,
397
+ };
398
+ } finally {
399
+ rmSync(scratch, { recursive: true, force: true });
400
+ }
401
+ }
@@ -0,0 +1,210 @@
1
+ // The one write the toolkit makes to a saved Map: the repo SDK pin into the
2
+ // Map's Build hints field (`global_config.sdk_version`, plus the
3
+ // `runtime.sdk_version` alias when the Map declares it), #415. The repo pin is
4
+ // what ships and the Map field is a build hint (#413), so after a bump the Map
5
+ // reads stale until someone re-saves it by hand; `spec derive --write-map`
6
+ // closes that from the toolkit with the pin the plan already derived. Nothing
7
+ // else on the Map is touched: the record read back from the proxy is sent back
8
+ // with exactly the pin fields changed, so an authored field is never rewritten
9
+ // from a local copy that may be behind the Map.
10
+ //
11
+ // Transport: GET /api/maps/<id> (the /api/spec alias the other Map read uses)
12
+ // then PUT /api/maps/<id> on the same proxy Worker. The PUT is guarded twice by
13
+ // the receiver — `X-Campaign-Key` must match the key stored on the Map, and
14
+ // `X-Spec-Hash` (the hash the GET returned) turns a save that landed in
15
+ // between into a 409 instead of an overwrite. The campaign key is the
16
+ // public-by-design Campaigns API key the packet already carries (the remit
17
+ // rail sends the same header); it is a request credential all the same, so the
18
+ // proxy base goes through the same TLS gate as every other credential-bearing
19
+ // request. `fetchImpl` is parameterized for tests, as in spec-fetch.mjs.
20
+
21
+ import { compareReleasedSdkVersions, resolveSpecSdkPin } from "./page-kit-sdk-version.mjs";
22
+ import { assertSecureProxyBase, DEFAULT_REMIT_TIMEOUT_MS } from "./remit.mjs";
23
+ import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
24
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
25
+
26
+ export const MAP_PIN_FIELD = "global_config.sdk_version";
27
+ export const MAP_PIN_ALIAS_FIELD = "runtime.sdk_version";
28
+
29
+ // Whether the repo pin may be written over the Map's. The rule mirrors the
30
+ // spec side of `spec derive` (a spec pin ahead of the repo is never moved
31
+ // backwards) and the repo side of `page-kit sync` (a configured pin is never
32
+ // lowered): the write goes forward or not at all.
33
+ //
34
+ // write — the Map declares no pin, or one behind the repo pin
35
+ // unchanged — the Map already carries the repo pin
36
+ // ahead — the Map pin is newer than the repo pin: a bump the
37
+ // repo never received, doctor's blocked state and
38
+ // page-kit sync's repair; nothing is written
39
+ // pin_unreadable — the Map declares a pin that is not a released
40
+ // version, or two declarations that disagree; a value
41
+ // this rule cannot order is not overwritten silently
42
+ //
43
+ // Reasons never start with `map_`: the CLI files them under the
44
+ // `spec.derive.map_<reason>` issue codes, where the prefix already says
45
+ // which side refused.
46
+ //
47
+ // `repoPin` must already be a released MAJOR.MINOR.PATCH (the derive plan
48
+ // checked it before it became a derived value); anything else is refused
49
+ // here too so a caller cannot push an unreleased pin into the Map.
50
+ export function mapPinWritebackDecision({ repoPin, mapSpec }) {
51
+ if (!isReleasedSdkVersion(repoPin)) {
52
+ return { decision: "repo_pin_invalid", map_pin: null, detail: `the repo pin ${JSON.stringify(repoPin)} is not a released MAJOR.MINOR.PATCH version; nothing is written to the Map.` };
53
+ }
54
+ const pin = resolveSpecSdkPin(mapSpec);
55
+ if (pin.status === "spec_missing") {
56
+ return { decision: "write", map_pin: null, detail: "the Map declares no Campaign Cart SDK version; the repo pin is recorded as its Build hint." };
57
+ }
58
+ if (pin.status !== "ok") {
59
+ const declaredValue = (field) => JSON.stringify(field === MAP_PIN_FIELD ? mapSpec?.global_config?.sdk_version : mapSpec?.runtime?.sdk_version);
60
+ const declared = pin.status === "spec_conflict"
61
+ ? `${MAP_PIN_FIELD} ${declaredValue(MAP_PIN_FIELD)} and ${MAP_PIN_ALIAS_FIELD} ${declaredValue(MAP_PIN_ALIAS_FIELD)} disagree`
62
+ : `${pin.invalid_declarations.map((field) => `${field} ${declaredValue(field)}`).join(" and ")} ${pin.invalid_declarations.length === 1 ? "is" : "are"} not a released MAJOR.MINOR.PATCH version`;
63
+ return { decision: "pin_unreadable", map_pin: null, detail: `the Map's pin cannot be ordered against the repo pin (${declared}); re-save the Map's Build hints (Campaign Cart SDK version) by hand to ${repoPin}.` };
64
+ }
65
+ const order = compareReleasedSdkVersions(pin.value, repoPin);
66
+ if (order === 0) return { decision: "unchanged", map_pin: pin.value, detail: `the Map already records ${repoPin}.` };
67
+ if (order > 0) {
68
+ return { decision: "ahead", map_pin: pin.value, detail: `the Map records ${pin.value}, ahead of the repo pin ${repoPin}; the write goes forward or not at all. If ${pin.value} should ship, bump the repo (page-kit sync moves the pin forward from the spec); if ${repoPin} is right, lower the Map's Build hints by hand.` };
69
+ }
70
+ return { decision: "write", map_pin: pin.value, detail: `the Map records ${pin.value}, behind the repo pin ${repoPin}.` };
71
+ }
72
+
73
+ // The Map record with exactly the pin fields moved: the canonical field, and
74
+ // the alias only when the Map already declares it (never created). Every
75
+ // other byte of the record is the receiver's own read-back, so the PUT
76
+ // re-states what the Map holds rather than what a local copy remembers.
77
+ export function applyMapPin(record, repoPin) {
78
+ const next = { ...record, global_config: { ...(record?.global_config ?? {}), sdk_version: repoPin } };
79
+ if (record?.runtime != null && typeof record.runtime === "object" && Object.hasOwn(record.runtime, "sdk_version")) {
80
+ next.runtime = { ...record.runtime, sdk_version: repoPin };
81
+ }
82
+ return next;
83
+ }
84
+
85
+ function identityOf(record) {
86
+ const identity = record?.spec_identity;
87
+ return {
88
+ spec_hash: typeof identity?.spec_hash === "string" ? identity.spec_hash : null,
89
+ saved_at: typeof identity?.saved_at === "string" ? identity.saved_at : (typeof record?.saved_at === "string" ? record.saved_at : null),
90
+ };
91
+ }
92
+
93
+ function failure(result, reason, detail) {
94
+ return { ...result, status: "failed", reason, detail };
95
+ }
96
+
97
+ /**
98
+ * Read the Map, decide, and (unless `dryRun`) PUT the repo pin back.
99
+ *
100
+ * Returns a result document, never throws past a programming error:
101
+ * { status: written | would_write | unchanged | refused | failed,
102
+ * reason, detail, map_id, proxy_base, field, before, after,
103
+ * spec_identity: { before: {spec_hash, saved_at}, after: {…} | null },
104
+ * warnings: string[] }
105
+ *
106
+ * `refused` reasons are the decision's (ahead, pin_unreadable,
107
+ * repo_pin_invalid). `failed` reasons: proxy_base_insecure, id_missing,
108
+ * key_missing, not_found, unreadable, key_mismatch, changed_underneath,
109
+ * rejected, http_error, network_error, response_invalid.
110
+ */
111
+ export async function writeMapSdkPin({
112
+ mapId,
113
+ repoPin,
114
+ campaignKey,
115
+ proxyBase = DEFAULT_PROXY_BASE,
116
+ dryRun = false,
117
+ fetchImpl = globalThis.fetch,
118
+ timeoutMs = DEFAULT_REMIT_TIMEOUT_MS,
119
+ warn = undefined,
120
+ } = {}) {
121
+ const result = {
122
+ status: "failed",
123
+ reason: null,
124
+ detail: null,
125
+ map_id: typeof mapId === "string" && mapId.trim() ? mapId.trim() : null,
126
+ proxy_base: null,
127
+ field: MAP_PIN_FIELD,
128
+ before: null,
129
+ after: repoPin ?? null,
130
+ spec_identity: { before: null, after: null },
131
+ warnings: [],
132
+ recorded: null,
133
+ };
134
+ if (!result.map_id) return failure(result, "id_missing", "the packet names no Map ID (spec.map_id), so there is no Map to write the pin to.");
135
+ if (typeof campaignKey !== "string" || !campaignKey.trim()) {
136
+ return failure(result, "key_missing", "no Campaigns API key was found in the packet, its local CampaignSpec or the declared env source; the Map write needs it as X-Campaign-Key.");
137
+ }
138
+ let base;
139
+ try {
140
+ ({ base } = assertSecureProxyBase(proxyBase, { label: "spec derive --write-map", credential: "the Campaigns API key", ...(warn ? { warn } : {}) }));
141
+ } catch (error) {
142
+ return failure(result, "proxy_base_insecure", error.message);
143
+ }
144
+ result.proxy_base = base;
145
+ if (typeof fetchImpl !== "function") return failure(result, "network_error", "Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
146
+
147
+ let record;
148
+ try {
149
+ record = await fetchSpecByMapId(result.map_id, { proxyBase: base, fetchImpl });
150
+ } catch (error) {
151
+ // Routed on the fields the fetch attaches (kind, status), never its
152
+ // prose, and a receiver status means the same thing on the read as on
153
+ // the write below.
154
+ if (error?.status === 404) return failure(result, "not_found", `Map ${result.map_id} was not found on ${base}; nothing was written.`);
155
+ if (error?.status === 403) return failure(result, "key_mismatch", `the Map's stored campaign key does not match the packet's (${String(error?.message || error)}); nothing was written. Point the packet at the Map's campaign, or re-save the Map under this key.`);
156
+ return failure(result, error?.kind === "network" ? "network_error" : "unreadable", `${String(error?.message || error)}; nothing was written to the Map.`);
157
+ }
158
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
159
+ return failure(result, "unreadable", `Map ${result.map_id} did not read back as a CampaignSpec object; nothing was written.`);
160
+ }
161
+ result.spec_identity.before = identityOf(record);
162
+ const decision = mapPinWritebackDecision({ repoPin, mapSpec: record });
163
+ result.before = decision.map_pin;
164
+ result.detail = decision.detail;
165
+ if (decision.decision === "unchanged") return { ...result, status: "unchanged", reason: "unchanged" };
166
+ if (decision.decision !== "write") return { ...result, status: "refused", reason: decision.decision };
167
+ if (dryRun) return { ...result, status: "would_write", reason: "dry_run" };
168
+
169
+ const body = applyMapPin(record, repoPin);
170
+ const headers = {
171
+ "Content-Type": "application/json",
172
+ Accept: "application/json",
173
+ "X-Campaign-Key": campaignKey.trim(),
174
+ ...(result.spec_identity.before.spec_hash ? { "X-Spec-Hash": result.spec_identity.before.spec_hash } : {}),
175
+ };
176
+ const url = `${base}/api/maps/${encodeURIComponent(result.map_id)}`;
177
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
178
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
179
+ if (timer && typeof timer.unref === "function") timer.unref();
180
+ let response;
181
+ try {
182
+ response = await fetchImpl(url, { method: "PUT", headers, body: JSON.stringify(body), ...(controller ? { signal: controller.signal } : {}) });
183
+ } catch (error) {
184
+ return failure(result, "network_error", `Map write network error: ${String(error?.message || error)} (${url}); the Map may be unchanged.`);
185
+ } finally {
186
+ if (timer) clearTimeout(timer);
187
+ }
188
+ let payload = null;
189
+ try {
190
+ payload = await response.json();
191
+ } catch {
192
+ payload = null;
193
+ }
194
+ const receiverError = typeof payload?.error === "string" ? payload.error : null;
195
+ if (response.status === 403) return failure(result, "key_mismatch", `the Map's stored campaign key does not match the packet's (${receiverError || "403"}); nothing was written. Point the packet at the Map's campaign, or re-save the Map under this key.`);
196
+ if (response.status === 404) return failure(result, "not_found", `Map ${result.map_id} was not found for writing (${receiverError || "404"}); nothing was written.`);
197
+ if (response.status === 409) return failure(result, "changed_underneath", `the Map was saved by someone else between the read and the write (${receiverError || "409"}); nothing was written. Derive again to write against the current save.`);
198
+ if (response.status === 400 || response.status === 422) {
199
+ const count = Array.isArray(payload?.violations) ? payload.violations.filter((row) => row?.severity === "error").length : 0;
200
+ return failure(result, "rejected", `the proxy refused the Map as re-stated with the pin (${receiverError || response.status}${count ? `; ${count} error-severity violation${count === 1 ? "" : "s"}` : ""}); nothing was written. The Map needs a re-save in the builder first.`);
201
+ }
202
+ if (!response.ok) return failure(result, "http_error", `Map write failed: ${response.status} ${response.statusText || ""}`.trim() + ` (${url}); the Map may be unchanged.`);
203
+ if (!payload || payload.ok === false) return failure(result, "response_invalid", `the proxy answered ${response.status} without an ok body (${receiverError || "unreadable JSON"}); the Map may or may not have been written. Read the Map back before deriving again.`);
204
+ result.spec_identity.after = identityOf(payload);
205
+ for (const warning of Array.isArray(payload.warnings) ? payload.warnings : []) {
206
+ const text = typeof warning === "string" ? warning : (typeof warning?.message === "string" ? warning.message : null);
207
+ if (text) result.warnings.push(text);
208
+ }
209
+ return { ...result, status: "written", reason: "written" };
210
+ }