@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -7,6 +7,7 @@ import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
7
7
  import { resolve, relative, dirname, posix, sep } from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { isUtf8 } from 'node:buffer';
10
+ import { singleLineFragment } from './text-safety.mjs';
10
11
  const digest = value => createHash('sha256').update(value).digest('hex');
11
12
  const version = value => {
12
13
  if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value))
@@ -49,6 +50,9 @@ function normalizeScope(value) {
49
50
  throw new Error('Scope and exclusions must be literal repository-relative files or directories.');
50
51
  return value.replace(/^\.\//, '').replace(/\/$/, '') || '.';
51
52
  }
53
+ const PREFIX_SCAN_REASONS = new Set(['dynamic-key', 'unsupported-storage-method', 'storage-object-escape']);
54
+ const LOOPS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
55
+ const ITERATION_METHODS = new Set(['forEach', 'filter', 'map', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce']);
52
56
  const inside = (path, scope) => scope === '.' || path === scope || path.startsWith(scope + '/');
53
57
  export function readStorageManifest(path) {
54
58
  const bytes = readFileSync(path);
@@ -219,7 +223,72 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
219
223
  else
220
224
  reason = 'literal-unaffected-key';
221
225
  }
222
- emit(node, { operation, area: storage.name, key, status, reason, replacement });
226
+ const prefixScan = status === 'unknown' && !storage.shadowed && PREFIX_SCAN_REASONS.has(reason) ? sdkPrefixScan(storage.name, node) : null;
227
+ emit(node, { operation, area: storage.name, key, status, reason, replacement, ...(prefixScan && { prefixScan }) });
228
+ }
229
+ // A loop that matches storage keys by a scoped SDK key's prefix: its key()/getItem(k) lines stay unknown, but are named.
230
+ // A prefix test counts only when it tests a key the loop read from storage itself: a `k = storage.key(i)` binding,
231
+ // a for-of/for-in variable over `Object.keys(storage)` or `storage`, or the element parameter of an array callback
232
+ // chained on `Object.keys(storage)`. A line is named only when it is that key's source or reads that key.
233
+ // Names are matched within the loop, not resolved through scopes; it is a hint only.
234
+ const stack = [];
235
+ const prefixCache = new WeakMap();
236
+ 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;
237
+ 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;
238
+ function keySource(node) {
239
+ // Object.keys(storage).filter(...).forEach(...): the chain bottoms out at Object.keys.
240
+ while (node?.type === 'CallExpression' && ITERATION_METHODS.has(callName(node)))
241
+ node = node.callee.object;
242
+ return keysOf(node);
243
+ }
244
+ function prefixTests(loop) {
245
+ if (prefixCache.has(loop)) return prefixCache.get(loop);
246
+ const keys = new Map(), sources = new Map(), tests = [];
247
+ // source: the storage reference the key came from (the key() call, or the storage in Object.keys(storage) / for-in).
248
+ const bind = (id, storageNode, source = storageNode) => {
249
+ const storage = storageNode && area(storageNode, scopes.get(storageNode));
250
+ if (id?.type !== 'Identifier' || !storage) return;
251
+ keys.set(id.name, storage.name);
252
+ sources.set(source, id.name);
253
+ };
254
+ const loopVariable = left => left?.type === 'VariableDeclaration' ? left.declarations[0]?.id : left;
255
+ (function walk(n) {
256
+ if (n.type === 'VariableDeclarator' && callName(n.init) === 'key')
257
+ bind(n.id, n.init.callee.object, n.init);
258
+ if (n.type === 'AssignmentExpression' && n.operator === '=' && callName(n.right) === 'key')
259
+ bind(n.left, n.right.callee.object, n.right);
260
+ if (n.type === 'ForOfStatement')
261
+ bind(loopVariable(n.left), keysOf(n.right));
262
+ if (n.type === 'ForInStatement')
263
+ bind(loopVariable(n.left), n.right);
264
+ if (n.type === 'CallExpression' && ITERATION_METHODS.has(callName(n)) && ['ArrowFunctionExpression', 'FunctionExpression'].includes(n.arguments[0]?.type))
265
+ bind(n.arguments[0].params[callName(n) === 'reduce' ? 1 : 0], keySource(n.callee.object));
266
+ if (callName(n) === 'startsWith')
267
+ tests.push({ receiver: n.callee.object, prefix: literal(n.arguments[0], scopes.get(n)) });
268
+ if (n.type === 'BinaryExpression' && ['===', '==', '!==', '!='].includes(n.operator))
269
+ for (const [call, zero] of [[n.left, n.right], [n.right, n.left]])
270
+ if (zero.type === 'Literal' && zero.value === 0 && callName(call) === 'indexOf' && call.arguments.length === 1)
271
+ tests.push({ receiver: call.callee.object, prefix: literal(call.arguments[0], scopes.get(call)) });
272
+ for (const child of children(n)) walk(child);
273
+ })(loop);
274
+ 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) }));
275
+ const result = { prefixes, sources };
276
+ prefixCache.set(loop, result);
277
+ return result;
278
+ }
279
+ function sdkPrefixScan(storageName, node) {
280
+ // The key this line reads (getItem(k), storage[k]), if it is a plain name.
281
+ const argument = node.type === 'CallExpression' ? node.arguments[0] : node.type === 'MemberExpression' && node.computed ? node.property : null;
282
+ // Nearest matching loop wins; a loop that does not test this line's key passes to the one around it.
283
+ for (const loop of [...stack].reverse()) {
284
+ if (!LOOPS.has(loop.type) && !(loop.type === 'CallExpression' && ITERATION_METHODS.has(callName(loop)))) continue;
285
+ const { prefixes, sources } = prefixTests(loop);
286
+ const names = [sources.get(node), argument?.type === 'Identifier' ? argument.name : null];
287
+ const tested = prefixes.filter(t => t.area === storageName && names.includes(t.name)).map(t => t.prefix);
288
+ const entry = manifest.keys.find(e => e.scoped && e.areas.includes(storageName) && e.key.split('{')[0] && tested.includes(e.key.split('{')[0]));
289
+ if (entry) return { prefix: entry.key.split('{')[0], key: entry.key, replacement: entry.replacement ?? null };
290
+ }
291
+ return null;
223
292
  }
224
293
  const handled = new WeakSet();
225
294
  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 +298,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
229
298
  (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(parent?.type) && parent.label === node)
230
299
  );
231
300
  function visit(node, parent) {
301
+ stack.push(node);
232
302
  const scope = scopes.get(node);
233
303
  if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
234
304
  const member = node.callee, storage = area(member.object, scope), method = member.computed ? literal(member.property, scope) : member.property.name;
@@ -258,6 +328,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
258
328
  }
259
329
  for (const child of children(node))
260
330
  visit(child, node);
331
+ stack.pop();
261
332
  }
262
333
  visit(ast, null);
263
334
  return findings;
@@ -291,6 +362,19 @@ function frontmatterScripts(text) {
291
362
  // linePos is 1-based within the YAML block, which starts on the file's line 2.
292
363
  return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
293
364
  }
365
+ const INVENTORY_MAX_BYTES = 256 * 1024 * 1024;
366
+ const INVENTORY_DETAIL_MAX = 200;
367
+
368
+ // A missing git already fails at rev-parse, above the inventory.
369
+ export function inventoryFailure(error) {
370
+ if (error.code === 'ENOBUFS') return `the tracked-file list under --scope is larger than ${INVENTORY_MAX_BYTES / 1024 / 1024} MiB; narrow --scope.`;
371
+ const stderr = typeof error.stderr === 'string' ? error.stderr : error.stderr?.toString() ?? '';
372
+ const detail = singleLineFragment(stderr.trim().split('\n')[0], `git exited ${error.status ?? error.signal ?? error.code ?? 'abnormally'} with no message`);
373
+ const chars = Array.from(detail);
374
+ const bounded = chars.length > INVENTORY_DETAIL_MAX ? `${chars.slice(0, INVENTORY_DETAIL_MAX).join('')}…` : detail;
375
+ return `${bounded.replace(/\.$/, '')}.`;
376
+ }
377
+
294
378
  export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
295
379
  version(targetSdkVersion);
296
380
  if (!Array.isArray(scope) || !scope.length)
@@ -305,16 +389,22 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
305
389
  if (rootIdentity.dev !== targetIdentity.dev || rootIdentity.ino !== targetIdentity.ino)
306
390
  throw new Error('--target must be the Git repository root.');
307
391
  const { value: manifest, evidence } = readStorageManifest(manifestPath);
392
+ // Inventory only the scope: a whole-repository list can pass Node's 1 MiB
393
+ // default buffer on a large repository (#717). Scopes are literal paths.
308
394
  let tracked;
309
- try { tracked = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0').filter(Boolean); }
310
- catch { throw new Error('Cannot inventory tracked Git source files.'); }
395
+ try {
396
+ tracked = execFileSync('git', ['--literal-pathspecs', '-C', root, 'ls-files', '-z', '--', ...scope], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: INVENTORY_MAX_BYTES }).split('\0').filter(Boolean);
397
+ } catch (error) {
398
+ throw new Error(`Cannot inventory tracked Git source files: ${inventoryFailure(error)}`);
399
+ }
311
400
  for (const item of scope)
312
401
  if (!tracked.some(p => inside(p, item)))
313
402
  throw new Error(`Scope has no tracked files: ${item}`);
314
403
  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
404
  const files = [], findings = [];
316
405
  const unknown = (path, reason, detail, line = 1) => findings.push({ path, line, column: 1, operation: 'unknown', area: null, key: null, status: 'unknown', reason, detail, replacement: null });
317
- if (compare(targetSdkVersion, manifest.supportedSdkVersions.min) < 0 || compare(targetSdkVersion, manifest.supportedSdkVersions.max) > 0)
406
+ const targetCovered = compare(targetSdkVersion, manifest.supportedSdkVersions.min) >= 0 && compare(targetSdkVersion, manifest.supportedSdkVersions.max) <= 0;
407
+ if (!targetCovered)
318
408
  unknown(evidence.path, 'target-outside-manifest-range', targetSdkVersion);
319
409
  if (!selected.length)
320
410
  unknown('.', 'empty-source-scope', 'No tracked HTML or JavaScript files selected.');
@@ -412,9 +502,28 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
412
502
  html(document);
413
503
  }
414
504
  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.' };
505
+ 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
506
  }
507
+ const sdkRange = ({ min, max }) => min === max ? min : `${min} to ${max}`;
417
508
  export function formatStorageCompatibilityReport(result) {
418
509
  const counts = status => result.findings.filter(f => f.status === status).length;
419
- return [`SDK storage compatibility: ${result.status} (target ${result.targetSdkVersion})`, `${result.files.length} tracked source files; ${counts('incompatible')} incompatible, ${counts('unknown')} unknown, ${counts('unaffected')} unaffected storage accesses.`, `Manifest ${result.manifest.sha256} (${result.manifest.source.verification})`, ...result.findings.filter(f => f.status !== 'unaffected').map(f => `${f.path}:${f.line}:${f.column} ${f.status}: ${f.reason}${f.key ? ` (${f.key})` : ''}${f.detail ? ` — ${f.detail}` : ''}`), result.evidenceBoundary].join('\n');
510
+ const target = result.targetSdkVersion, covered = sdkRange(result.manifest.supportedSdkVersions);
511
+ const outside = result.targetCovered === false;
512
+ const header = outside
513
+ ? [`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}.`]
514
+ : [`SDK storage compatibility: ${result.status} (target ${target})`];
515
+ 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})` : ''}.`;
516
+ 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}]` : ''}`;
517
+ 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');
518
+ }
519
+ // One note for every [prefix loop] line, so a reader can tell them from unknowns with no known shape.
520
+ function prefixScanNote(findings) {
521
+ const scans = findings.filter(f => f.prefixScan);
522
+ if (!scans.length) return [];
523
+ const prefixes = [...new Set(scans.map(f => f.prefixScan.prefix))];
524
+ const replacements = [...new Map(scans.filter(f => f.prefixScan.replacement).map(f => [f.prefixScan.prefix, f.prefixScan.replacement])).entries()];
525
+ return [
526
+ `${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.`,
527
+ ...replacements.map(([prefix, r]) => `For ${prefix}, the manifest names the public ${r.export} export instead (${r.guide}).`),
528
+ ];
420
529
  }
@@ -559,6 +559,20 @@ function addSpecHints(mapping, page) {
559
559
  if (variantLabels) mapping.variant_labels = variantLabels;
560
560
  }
561
561
 
562
+ /**
563
+ * The page-kit projection of every page in `specPages` (a Map of page id to
564
+ * projection), as intake projects a mapped page: target path, public route,
565
+ * page_type and the frontmatter links to the other pages. `record spec`
566
+ * re-projects with it when routes move or, after handoff, when pages are
567
+ * added or removed (#531, #721); every page is re-projected because a page's
568
+ * next_url and decline_url read the routes of the pages it links to.
569
+ */
570
+ export function projectPageKitTargets({ specPages = [], publicRouteSlug, outputDir }) {
571
+ const pages = Array.isArray(specPages) ? specPages : [];
572
+ const pageById = new Map(pages.map((page) => [page.id, page]));
573
+ return new Map(pages.map((page) => [page.id, pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages: pages })]));
574
+ }
575
+
562
576
  function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
563
577
  const specRoute = publicRouteForPage(page);
564
578
  const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
@@ -707,7 +721,7 @@ function normalizeRootedRoute(value) {
707
721
  return normalized ? `/${normalized}` : "/";
708
722
  }
709
723
 
710
- function cpkPageTypeForSpecType(type) {
724
+ export function cpkPageTypeForSpecType(type) {
711
725
  if (type === "presell" || type === "landing") return "product";
712
726
  if (type === "thankyou" || type === "receipt") return "receipt";
713
727
  if (type === "upsell" || type === "downsell") return "upsell";
@@ -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
+ }
@@ -0,0 +1,216 @@
1
+ // `campaigns-os spec derive`, live campaign half (#721): refresh the offer and
2
+ // package fields the CampaignSpec mirrors from the Campaigns App.
3
+ //
4
+ // Field classes (#432): a page package's ref, name, price (recurring price
5
+ // included), product, variant and availability are mirrored from the Campaigns App's packages; the offer
6
+ // records (`offers[]`, and the copy a page carries in `pages[].offers[]`) are
7
+ // mirrored from its offers. Which packages and offers a page carries, and a
8
+ // package's qty and role flags (is_upsell, is_order_bump, default_selected,
9
+ // configurable), are authored and never written here. A price is owned by the
10
+ // Campaigns App: it reaches the spec only from the live campaign, never from
11
+ // the repo.
12
+ //
13
+ // The live campaign is the body of the one read-only GET of
14
+ // `{proxy-base}/api/campaign` that doctor and `qa run` already make under the
15
+ // public campaign key (live-campaign-refs.mjs). This module is pure: it
16
+ // plans rows in spec-derive.mjs's shape from the spec and that body, and
17
+ // spec-derive.mjs applies them under its write guard.
18
+
19
+ // The page package fields the live campaign is the authority for, as Map
20
+ // Builder exports them: the recurring price fields a subscription package
21
+ // carries, and `variant_attributes`, which the campaign states as
22
+ // `product_variant_attribute_values`.
23
+ export const MIRRORED_PACKAGE_FIELDS = Object.freeze([
24
+ "name",
25
+ "price",
26
+ "price_retail",
27
+ "product_name",
28
+ "product_sku",
29
+ "product_variant_name",
30
+ "product_purchase_availability",
31
+ "product_inventory_availability",
32
+ "image",
33
+ "is_recurring",
34
+ "price_recurring",
35
+ "interval",
36
+ "interval_count",
37
+ "variant_attributes",
38
+ ]);
39
+
40
+ // A spec field whose live value sits under another key.
41
+ const LIVE_FIELD = Object.freeze({ variant_attributes: "product_variant_attribute_values" });
42
+
43
+ // The offer record fields (root `offers[]`), and the subset a page's copy
44
+ // (`pages[].offers[]`) carries.
45
+ export const MIRRORED_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit", "packages"]);
46
+ export const MIRRORED_PAGE_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit"]);
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ // A ref as the campaign states it, compared as its string form (5 and "5"
53
+ // are one ref), or null when the entry carries none.
54
+ function refOf(value) {
55
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
56
+ if (typeof value === "string" && value.trim()) return value.trim();
57
+ return null;
58
+ }
59
+
60
+ function canonicalJson(value) {
61
+ if (value === undefined) return "undefined";
62
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
63
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
64
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
65
+ }
66
+
67
+ function pathLabel(path) {
68
+ return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
69
+ }
70
+
71
+ function clone(value) {
72
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
73
+ }
74
+
75
+ // Every active page with the JSON path it lives at, as spec-derive.mjs reads
76
+ // them (funnels[] first; the legacy funnel_pages[] only without it).
77
+ function activePagesWithPaths(spec) {
78
+ const pages = [];
79
+ const visit = (page, path) => {
80
+ if (isPlainObject(page) && page.enabled !== false && typeof page.id === "string" && page.id.trim()) pages.push({ page, path });
81
+ };
82
+ if (Array.isArray(spec?.funnels)) {
83
+ spec.funnels.forEach((funnel, funnelIndex) => {
84
+ (Array.isArray(funnel?.pages) ? funnel.pages : []).forEach((page, pageIndex) => visit(page, ["funnels", funnelIndex, "pages", pageIndex]));
85
+ });
86
+ } else if (Array.isArray(spec?.funnel_pages)) {
87
+ spec.funnel_pages.forEach((page, index) => visit(page, ["funnel_pages", index]));
88
+ }
89
+ return pages;
90
+ }
91
+
92
+ function indexByRef(list) {
93
+ const map = new Map();
94
+ for (const entry of Array.isArray(list) ? list : []) {
95
+ const ref = isPlainObject(entry) ? refOf(entry.ref_id) : null;
96
+ if (ref && !map.has(ref)) map.set(ref, entry);
97
+ }
98
+ return map;
99
+ }
100
+
101
+ // The rows that bring one mirrored record in line with the live one: a field
102
+ // the live record states is written when the spec's value differs, and only
103
+ // where the spec already carries the key or the live value says something
104
+ // (not null, false or an empty list, which is how Map Builder leaves a
105
+ // one-time package's recurring fields out), so a refresh does not pad the
106
+ // spec with empty keys.
107
+ function mirrorRows({ record, live, fields, path, source, pageId = null }) {
108
+ const changes = [];
109
+ const unchanged = [];
110
+ for (const field of fields) {
111
+ const key = LIVE_FIELD[field] || field;
112
+ if (!Object.hasOwn(live, key) || live[key] === undefined) continue;
113
+ const value = live[key];
114
+ const before = Object.hasOwn(record, field) ? record[field] : undefined;
115
+ if (before === undefined && (value === null || value === false || (Array.isArray(value) && !value.length))) continue;
116
+ const row = { field: pathLabel([...path, field]), path: [...path, field], before: clone(before), after: clone(value), source, ...(pageId ? { page_id: pageId } : {}) };
117
+ if (canonicalJson(before) === canonicalJson(value)) unchanged.push(row);
118
+ else changes.push(row);
119
+ }
120
+ return { changes, unchanged };
121
+ }
122
+
123
+ /**
124
+ * The plan for the mirrored offer and package fields: `changes` and
125
+ * `unchanged` rows in spec-derive.mjs's shape, `not_derived` for a record the
126
+ * spec lists that the live campaign does not serve (reported, never removed:
127
+ * which records a page carries is authored), and `summary` counts. `campaign`
128
+ * is the live campaign body; `campaignRef` names it in each row's source.
129
+ */
130
+ export function planCampaignMirrorDerive({ spec, campaign, campaignRef = null } = {}) {
131
+ const changes = [];
132
+ const unchanged = [];
133
+ const notDerived = [];
134
+ const ref = campaignRef || refOf(campaign?.ref_id ?? campaign?.id);
135
+ const source = `the Campaigns App campaign${ref ? ` ${ref}` : ""} (GET /api/campaign)`;
136
+ const livePackages = indexByRef(campaign?.packages);
137
+ const liveOffers = Array.isArray(campaign?.offers) ? indexByRef(campaign.offers) : null;
138
+ const summary = { packages_checked: 0, offers_checked: 0, offers_added: 0 };
139
+
140
+ for (const { page, path } of activePagesWithPaths(spec)) {
141
+ (Array.isArray(page.packages) ? page.packages : []).forEach((pkg, index) => {
142
+ if (!isPlainObject(pkg)) return;
143
+ const pkgRef = refOf(pkg.ref_id);
144
+ const pkgPath = [...path, "packages", index];
145
+ if (!pkgRef) return;
146
+ const live = livePackages.get(pkgRef);
147
+ if (!live) {
148
+ notDerived.push({ field: pathLabel(pkgPath), page_id: page.id, reason: "package_not_live", detail: `page "${page.id}" carries package ${pkgRef}, which the live campaign does not serve; nothing was changed. Point the page at a package the campaign serves, or restore it in the Campaigns App.` });
149
+ return;
150
+ }
151
+ summary.packages_checked += 1;
152
+ const rows = mirrorRows({ record: pkg, live, fields: MIRRORED_PACKAGE_FIELDS, path: pkgPath, source, pageId: page.id });
153
+ changes.push(...rows.changes);
154
+ unchanged.push(...rows.unchanged);
155
+ });
156
+ (Array.isArray(page.offers) ? page.offers : []).forEach((offer, index) => {
157
+ if (!isPlainObject(offer) || !liveOffers) return;
158
+ const offerRef = refOf(offer.ref_id);
159
+ const offerPath = [...path, "offers", index];
160
+ if (!offerRef) return;
161
+ const live = liveOffers.get(offerRef);
162
+ if (!live) {
163
+ notDerived.push({ field: pathLabel(offerPath), page_id: page.id, reason: "offer_not_live", detail: `page "${page.id}" carries offer ${offerRef}, which the live campaign does not serve; nothing was changed. Remove it from the page, or restore it in the Campaigns App.` });
164
+ return;
165
+ }
166
+ const rows = mirrorRows({ record: offer, live, fields: MIRRORED_PAGE_OFFER_FIELDS, path: offerPath, source, pageId: page.id });
167
+ changes.push(...rows.changes);
168
+ unchanged.push(...rows.unchanged);
169
+ });
170
+ }
171
+
172
+ if (!liveOffers) {
173
+ if (Array.isArray(spec?.offers) && spec.offers.length) {
174
+ notDerived.push({ field: "offers", reason: "offers_not_in_live_campaign", detail: "the live campaign read carried no offers list, so the spec's offer records were not refreshed." });
175
+ }
176
+ } else if (!Array.isArray(spec?.offers)) {
177
+ const records = [...liveOffers.values()].map((live) => recordFromLive(live));
178
+ if (records.length) {
179
+ changes.push({ field: "offers", path: ["offers"], before: spec?.offers === undefined ? undefined : clone(spec.offers), after: records, source });
180
+ summary.offers_added = records.length;
181
+ }
182
+ } else {
183
+ const seen = new Set();
184
+ spec.offers.forEach((offer, index) => {
185
+ if (!isPlainObject(offer)) return;
186
+ const offerRef = refOf(offer.ref_id);
187
+ if (!offerRef) return;
188
+ seen.add(offerRef);
189
+ const live = liveOffers.get(offerRef);
190
+ if (!live) {
191
+ notDerived.push({ field: `offers[${index}]`, reason: "offer_not_live", detail: `offer ${offerRef} is in the spec's offers but not in the live campaign; it was kept, since pages may still name it. Remove it, or restore it in the Campaigns App.` });
192
+ return;
193
+ }
194
+ summary.offers_checked += 1;
195
+ const rows = mirrorRows({ record: offer, live, fields: MIRRORED_OFFER_FIELDS, path: ["offers", index], source });
196
+ changes.push(...rows.changes);
197
+ unchanged.push(...rows.unchanged);
198
+ });
199
+ let next = spec.offers.length;
200
+ for (const [offerRef, live] of liveOffers) {
201
+ if (seen.has(offerRef)) continue;
202
+ changes.push({ field: `offers[${next}]`, path: ["offers", next], op: "insert", before: undefined, after: recordFromLive(live), source });
203
+ next += 1;
204
+ summary.offers_added += 1;
205
+ }
206
+ }
207
+ return { changes, unchanged, not_derived: notDerived, summary };
208
+ }
209
+
210
+ // An offer record as the spec stores one: its ref and the mirrored fields the
211
+ // live offer states.
212
+ function recordFromLive(live) {
213
+ const record = { ref_id: live.ref_id };
214
+ for (const field of MIRRORED_OFFER_FIELDS) if (Object.hasOwn(live, field) && live[field] !== undefined) record[field] = clone(live[field]);
215
+ return record;
216
+ }