@nextcommerce/campaigns-os 1.43.2 → 1.46.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 (72) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +648 -5103
  3. package/README.md +32 -11
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  14. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  15. package/contracts/effects.v1.json +1176 -113
  16. package/contracts/orientation-reason-codes.v1.json +7 -0
  17. package/contracts/release-ledger.json +2345 -6087
  18. package/contracts/supported-surface.json +7 -4
  19. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  20. package/docs/brand-theme-bridge.md +81 -0
  21. package/docs/build-packet.md +158 -21
  22. package/docs/campaigns-os-build-flow.md +3 -3
  23. package/docs/design-source-package.md +73 -0
  24. package/docs/effects.md +50 -8
  25. package/docs/gateway-login.md +3 -0
  26. package/docs/local-setup.md +1 -1
  27. package/docs/orientation-contract-reference.md +42 -2
  28. package/docs/polish-evidence.md +74 -0
  29. package/docs/qa-and-test-orders.md +99 -13
  30. package/docs/release-ledger-authoring-guide.md +64 -4
  31. package/docs/runtime-readiness.md +1 -1
  32. package/docs/sdk-storage-compatibility.md +1 -1
  33. package/docs/skills-revision.md +10 -10
  34. package/docs/supported-surface.md +2 -2
  35. package/docs/versioning.md +4 -1
  36. package/package.json +1 -1
  37. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  38. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  39. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  40. package/skills/campaign-readback-classification/SKILL.md +3 -3
  41. package/skills/campaign-run-evidence/SKILL.md +7 -6
  42. package/skills/contribution-intake/SKILL.md +3 -3
  43. package/skills/next-campaigns-build/SKILL.md +7 -6
  44. package/skills/next-campaigns-os/SKILL.md +7 -7
  45. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  46. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  47. package/skills/next-campaigns-polish/SKILL.md +28 -9
  48. package/skills/next-campaigns-qa/SKILL.md +7 -4
  49. package/skills.json +10 -10
  50. package/src/brand-theme.mjs +320 -20
  51. package/src/built-site-scope.mjs +16 -4
  52. package/src/cli.mjs +280 -46
  53. package/src/commercial-parity.mjs +48 -2
  54. package/src/deviation.mjs +13 -1
  55. package/src/diagnostic.mjs +5 -2
  56. package/src/doctor/checks.mjs +320 -81
  57. package/src/doctor/inspect.mjs +55 -13
  58. package/src/doctor/source-provenance.mjs +184 -0
  59. package/src/invocation.mjs +4 -0
  60. package/src/live-campaign-refs.mjs +466 -0
  61. package/src/login.mjs +2 -2
  62. package/src/page-kit-store-profile.mjs +69 -12
  63. package/src/page-kit-sync.mjs +31 -12
  64. package/src/progress-node.mjs +3 -1
  65. package/src/qa-browser.mjs +538 -28
  66. package/src/qa-commercial-parity.mjs +48 -5
  67. package/src/qa-node.mjs +122 -7
  68. package/src/qa-test-order-topology.mjs +148 -0
  69. package/src/sdk-markup.mjs +72 -8
  70. package/src/source-html-intake.mjs +116 -0
  71. package/src/stage-record.mjs +551 -0
  72. package/src/upsell-selector-scope.mjs +112 -2
@@ -0,0 +1,466 @@
1
+ // Page refs against the live campaign (#533): the one comparison doctor and QA
2
+ // share. A page's `data-next-shipping-id` / package refs were only ever checked
3
+ // against the CampaignSpec, so a campaign whose shipping methods or packages
4
+ // changed after the Map was saved passed both gates while the SDK silently fell
5
+ // back to another method at checkout. This module reads what the live campaign
6
+ // serves and says, separately:
7
+ //
8
+ // - page vs live: a ref a page renders that the live campaign does not serve
9
+ // (`built_output.shipping_ref_live_missing` / `built_output.package_ref_live_missing`,
10
+ // blockers, whatever the Map says);
11
+ // - Map vs live: refs the CampaignSpec lists that the live campaign does not
12
+ // serve, or the reverse (`spec.campaign_drift`, a warning that never
13
+ // downgrades a page-level blocker).
14
+ //
15
+ // The read is one GET of `{proxy-base}/api/campaign` under `X-Campaign-Key`
16
+ // (with `?ref_id=<id>` when the CampaignSpec names the campaign): NEXT's proxy
17
+ // forwards it to the campaign retrieve the Campaign Cart SDK makes in the
18
+ // browser and answers with an envelope, `{ ok, status, endpoint,
19
+ // requested_ref_id, retrieved_at, data }`, whose `data` is that retrieve's
20
+ // body — one campaign, or an array of them. The only credential is the
21
+ // public Campaigns API key; no store or Admin credential is reachable from
22
+ // here. It takes its fetch and the proxy base as arguments, so a caller that
23
+ // passes neither reads nothing. Anything short of one parsed campaign —
24
+ // no key, no fetch, a network error, a non-2xx, a timeout, `ok: false`, a body
25
+ // that is not the envelope, several campaigns and no ref to pick one, a
26
+ // campaign other than the one the CampaignSpec names — is
27
+ // `not_run` with its reason, never a pass and never a fall back to the Map's
28
+ // own list.
29
+ import { parse as parseHtml } from "parse5";
30
+
31
+ import { runWithDeadline } from "./deadline.mjs";
32
+ import { assertSecureProxyBase } from "./remit.mjs";
33
+ import { resolveCampaignsApiKeySource, describeCampaignKeyRejection } from "./campaigns-api-key.mjs";
34
+ import { readJsonIfExists, resolveFromFile } from "./cli-helpers.mjs";
35
+
36
+ export const LIVE_CAMPAIGN_PATH = "/api/campaign";
37
+
38
+ export const LIVE_CAMPAIGN_TIMEOUT_MS = 10_000;
39
+ export const LIVE_CAMPAIGN_MAX_BYTES = 2 * 1024 * 1024;
40
+ const LIVE_CAMPAIGN_ERROR_MAX_BYTES = 64 * 1024;
41
+
42
+ export const LIVE_REF_CODES = Object.freeze({
43
+ shipping: "built_output.shipping_ref_live_missing",
44
+ package: "built_output.package_ref_live_missing",
45
+ drift: "spec.campaign_drift",
46
+ notRun: "built_output.live_refs_not_run",
47
+ });
48
+
49
+ // The refs a page renders, read the way a browser reads the markup: parsed
50
+ // with parse5, so any valid attribute syntax is seen (spaces around `=`,
51
+ // unquoted or single-quoted values, upper-case names, entity-encoded values)
52
+ // and markup the browser never builds into elements is not. One extractor
53
+ // feeds the CampaignSpec ref check, the live check and commercial parity.
54
+ //
55
+ // - attributes: `data-next-package-id` / `data-package-id` (packages) and
56
+ // `data-next-shipping-id` (shipping), on any element;
57
+ // - inline config: `packageId:` / `shippingId:` keys in `<script>` text and
58
+ // in attribute values (an inline handler or a JSON config attribute);
59
+ // - `<template>` content is read: the SDK clones templates into the live DOM,
60
+ // so a ref there is a ref the shopper can reach;
61
+ // - comments and `<noscript>` content are not: neither becomes an element
62
+ // while the SDK runs.
63
+ const PACKAGE_REF_ATTRIBUTES = new Set(["data-next-package-id", "data-package-id"]);
64
+ const SHIPPING_REF_ATTRIBUTES = new Set(["data-next-shipping-id"]);
65
+ const PACKAGE_CONFIG_KEY = /["']?packageId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi;
66
+ const SHIPPING_CONFIG_KEY = /["']?shippingId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi;
67
+
68
+ export function extractRenderedRefs(content) {
69
+ const packageRefs = new Set();
70
+ const shippingRefs = new Set();
71
+ const readConfig = (text) => {
72
+ for (const match of String(text || "").matchAll(PACKAGE_CONFIG_KEY)) addRenderedRef(packageRefs, match[1] || match[2] || match[3]);
73
+ for (const match of String(text || "").matchAll(SHIPPING_CONFIG_KEY)) addRenderedRef(shippingRefs, match[1] || match[2] || match[3]);
74
+ };
75
+ const visit = (node) => {
76
+ const children = node.content ? node.content.childNodes : node.childNodes;
77
+ for (const child of children || []) {
78
+ if (!child.tagName) continue;
79
+ for (const attr of child.attrs || []) {
80
+ const name = attr.name.toLowerCase();
81
+ if (PACKAGE_REF_ATTRIBUTES.has(name)) addRenderedRef(packageRefs, attr.value);
82
+ else if (SHIPPING_REF_ATTRIBUTES.has(name)) addRenderedRef(shippingRefs, attr.value);
83
+ else readConfig(attr.value);
84
+ }
85
+ if (child.tagName.toLowerCase() === "script") {
86
+ readConfig((child.childNodes || []).map((text) => text.value || "").join(""));
87
+ }
88
+ visit(child);
89
+ }
90
+ };
91
+ visit(parseHtml(String(content || "")));
92
+ return { package_refs: packageRefs, shipping_refs: shippingRefs };
93
+ }
94
+
95
+ export function extractRenderedPackageRefs(content) {
96
+ return extractRenderedRefs(content).package_refs;
97
+ }
98
+
99
+ export function extractRenderedShippingRefs(content) {
100
+ return extractRenderedRefs(content).shipping_refs;
101
+ }
102
+
103
+ function addRenderedRef(refs, value) {
104
+ const ref = String(value || "").trim();
105
+ if (/^[A-Za-z0-9_-]+$/.test(ref)) refs.add(ref);
106
+ }
107
+
108
+ function notRun(reasonCode, reason, extra = {}) {
109
+ return { status: "not_run", reason_code: reasonCode, reason, ...extra };
110
+ }
111
+
112
+ // A ref as the campaign states it: a number or a non-empty string, compared as
113
+ // its string form, so `5` on the page and `"5"` in the API are the same ref.
114
+ function liveRef(value) {
115
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
116
+ if (typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value.trim())) return value.trim();
117
+ return null;
118
+ }
119
+
120
+ // The campaign body, or null when it is not one. Both lists must be arrays
121
+ // (an empty one is a campaign that serves nothing of that kind) and every
122
+ // entry must carry a ref, so a partial body is never read as "not served".
123
+ export function liveCampaignRefs(body) {
124
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
125
+ if (!Array.isArray(body.packages) || !Array.isArray(body.shipping_methods)) return null;
126
+ const packages = body.packages.map((pkg) => liveRef(pkg?.ref_id));
127
+ const shipping = body.shipping_methods.map((method) => liveRef(method?.ref_id));
128
+ if (packages.includes(null) || shipping.includes(null)) return null;
129
+ return { package_refs: [...new Set(packages)].sort(), shipping_refs: [...new Set(shipping)].sort() };
130
+ }
131
+
132
+ // The campaign's own ref as the CampaignSpec records it (`campaign.ref_id`,
133
+ // the numeric platform id), or null when it names none the proxy's
134
+ // `?ref_id=` would take.
135
+ export function campaignRefIdFromSpec(spec) {
136
+ const value = spec?.campaign?.ref_id;
137
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
138
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) return value.trim();
139
+ return null;
140
+ }
141
+
142
+ // The proxy's error text as one short line: the envelope's `error` string,
143
+ // whitespace collapsed, the key redacted if it were ever echoed, and capped.
144
+ // Never the raw body.
145
+ function proxyErrorText(body, apiKey) {
146
+ const raw = body && typeof body === "object" && !Array.isArray(body) && typeof body.error === "string" ? body.error : "";
147
+ let text = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
148
+ if (apiKey && text.includes(apiKey)) text = text.split(apiKey).join("[key]");
149
+ if (text.length > 160) text = `${text.slice(0, 157)}...`;
150
+ return text;
151
+ }
152
+
153
+ function parseJson(text) {
154
+ try {
155
+ return { ok: true, value: JSON.parse(text) };
156
+ } catch {
157
+ return { ok: false };
158
+ }
159
+ }
160
+
161
+ // A campaign's own identity as the proxy reads it: `ref_id`, else `id`, a
162
+ // scalar compared as its string form; null when it carries neither.
163
+ function campaignIdentity(campaign) {
164
+ return liveRef(campaign?.ref_id ?? campaign?.id);
165
+ }
166
+
167
+ function campaignMismatch(refId, carried) {
168
+ return notRun("campaign_mismatch", `The live campaign read asked for campaign ref ${refId} but got ${carried}, so another campaign's refs were not compared against.`);
169
+ }
170
+
171
+ // The one campaign in the envelope's `data`: the object itself, or from an
172
+ // array the entry whose identity is the one asked for (or the only entry, when
173
+ // no ref was asked for). A campaign asked for by ref must carry that ref, in
174
+ // either shape; an empty array does not carry it either. Returns { campaign }
175
+ // or { notRun }.
176
+ function pickCampaign(data, refId) {
177
+ if (!Array.isArray(data)) {
178
+ if (!refId) return { campaign: data };
179
+ const identity = campaignIdentity(data);
180
+ return identity === refId
181
+ ? { campaign: data }
182
+ : { notRun: campaignMismatch(refId, identity ? `campaign ref ${identity}` : "a campaign carrying no ref") };
183
+ }
184
+ if (refId) {
185
+ if (data.length === 0) return { notRun: campaignMismatch(refId, "no campaign") };
186
+ const matches = data.filter((entry) => campaignIdentity(entry) === refId);
187
+ if (matches.length === 1) return { campaign: matches[0] };
188
+ return matches.length === 0
189
+ ? { notRun: campaignMismatch(refId, `${data.length} campaign(s), none with that ref`) }
190
+ : { notRun: notRun("ambiguous_campaign", `The live campaign read returned ${matches.length} campaigns with ref ${refId}, so it could not tell which one the pages use.`) };
191
+ }
192
+ if (data.length === 1) return { campaign: data[0] };
193
+ return data.length === 0
194
+ ? { notRun: notRun("not_found", "The live campaign read returned no campaign for this key, so there was no live campaign to compare against.") }
195
+ : { notRun: notRun("ambiguous_campaign", `The live campaign read returned ${data.length} campaigns for this key and the CampaignSpec names no campaign.ref_id to pick one, so it could not tell which one the pages use.`) };
196
+ }
197
+
198
+ // One GET of the campaign the key belongs to, through the proxy. `apiKey` is
199
+ // the public Campaigns API key; it travels as the X-Campaign-Key header the
200
+ // proxy's other routes take and never appears in the result. `campaignRefId`,
201
+ // when known, asks the proxy for that one campaign. The proxy base passes the
202
+ // same transport gate as every other proxy request: https, or a loopback host
203
+ // over http.
204
+ export async function readLiveCampaign({
205
+ apiKey,
206
+ proxyBase = null,
207
+ fetchImpl = null,
208
+ campaignRefId = null,
209
+ timeoutMs = LIVE_CAMPAIGN_TIMEOUT_MS,
210
+ maxBytes = LIVE_CAMPAIGN_MAX_BYTES,
211
+ warn = undefined,
212
+ } = {}) {
213
+ if (typeof apiKey !== "string" || !apiKey.trim()) {
214
+ return notRun("no_key", "No public Campaigns API key resolved, so the live campaign was not read.");
215
+ }
216
+ if (typeof proxyBase !== "string" || !proxyBase.trim()) {
217
+ return notRun("not_read", "This invocation does not read the live campaign.");
218
+ }
219
+ const refId = campaignRefId == null ? null : String(campaignRefId).trim();
220
+ if (refId !== null && !/^\d+$/.test(refId)) {
221
+ return notRun("unexpected_ref", "The CampaignSpec's campaign.ref_id is not a numeric campaign id, so the live campaign was not read.");
222
+ }
223
+ let url;
224
+ try {
225
+ const { base } = assertSecureProxyBase(proxyBase, { label: "Live campaign read", credential: "the public campaign key", ...(warn ? { warn } : {}) });
226
+ // Built with URL so a base that already carries a query keeps it and
227
+ // ref_id is added as one more parameter, not appended after it.
228
+ const target = new URL(base);
229
+ target.pathname = `${target.pathname.replace(/\/+$/, "")}${LIVE_CAMPAIGN_PATH}`;
230
+ if (refId) target.searchParams.set("ref_id", refId);
231
+ url = target.href;
232
+ } catch (error) {
233
+ return notRun("insecure_proxy_base", `The live campaign was not read: ${error.message}`);
234
+ }
235
+ if (typeof fetchImpl !== "function") {
236
+ return notRun("fetch_unavailable", "No fetch is available in this runtime, so the live campaign was not read.");
237
+ }
238
+ const controller = new AbortController();
239
+ let response;
240
+ let text;
241
+ try {
242
+ ({ response, text } = await runWithDeadline(async () => {
243
+ const value = await fetchImpl(url, {
244
+ method: "GET",
245
+ redirect: "error",
246
+ headers: { Accept: "application/json", "X-Campaign-Key": apiKey.trim() },
247
+ signal: controller.signal,
248
+ });
249
+ // Loaded here rather than at the top: QA's commercial parity imports
250
+ // this module's extractors, and a static import back would be a cycle.
251
+ const { readBoundedResponseText } = await import("./qa-commercial-parity.mjs");
252
+ if (!value?.ok) {
253
+ // A non-2xx body is read only for the proxy's error line; an
254
+ // unreadable one leaves the status to speak for itself.
255
+ let errorText = null;
256
+ try {
257
+ errorText = await readBoundedResponseText(value, { maxBytes: LIVE_CAMPAIGN_ERROR_MAX_BYTES, kind: "live_campaign" });
258
+ } catch {
259
+ // the status is the finding
260
+ }
261
+ return { response: value, text: errorText };
262
+ }
263
+ return { response: value, text: await readBoundedResponseText(value, { maxBytes, kind: "live_campaign" }) };
264
+ }, {
265
+ timeoutMs,
266
+ onTimeout: () => controller.abort(),
267
+ timeoutError: () => Object.assign(new Error(`timed out after ${timeoutMs}ms`), { code: "timeout" }),
268
+ label: "readLiveCampaign",
269
+ }));
270
+ } catch (error) {
271
+ if (error?.code === "timeout") return notRun("timeout", `The live campaign read timed out after ${timeoutMs}ms.`);
272
+ if (error?.code === "live_campaign_response_too_large") return notRun("too_large", `The live campaign response exceeded ${maxBytes} bytes.`);
273
+ return notRun("network_error", `The live campaign read failed before a response: ${error?.message || String(error)}.`);
274
+ }
275
+ const requested = refId ? { campaign_ref_id: refId } : {};
276
+ const parsed = typeof text === "string" ? parseJson(text) : { ok: false };
277
+ if (!response?.ok) {
278
+ const status = Number.isInteger(response?.status) ? response.status : null;
279
+ const said = parsed.ok ? proxyErrorText(parsed.value, apiKey.trim()) : "";
280
+ const because = said ? ` The proxy said: ${said}` : "";
281
+ return notRun(
282
+ status === 404 ? "not_found" : "http_status",
283
+ status === 404
284
+ ? `The live campaign read answered 404${refId ? ` for campaign ref ${refId}` : " for this key"}: no live campaign was found to compare against.${because}`
285
+ : `The live campaign read answered ${status ?? "an unknown status"}, so the live campaign was not read.${because}`,
286
+ { http_status: status, ...requested },
287
+ );
288
+ }
289
+ if (!parsed.ok) return notRun("unparseable", "The live campaign response was not JSON.", requested);
290
+ const envelope = parsed.value;
291
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || typeof envelope.ok !== "boolean") {
292
+ return notRun("unexpected_body", "The live campaign response was not the proxy's envelope ({ ok, data }).", requested);
293
+ }
294
+ if (envelope.ok !== true) {
295
+ const said = proxyErrorText(envelope, apiKey.trim());
296
+ return notRun("proxy_error", `The proxy answered ok: false${said ? `: ${said}` : ""}, so the live campaign was not read.`, requested);
297
+ }
298
+ if (envelope.data == null || typeof envelope.data !== "object") {
299
+ // ok: true with no campaign; the proxy's error line, when it sent one, is
300
+ // the reason.
301
+ const said = proxyErrorText(envelope, apiKey.trim());
302
+ return said
303
+ ? notRun("proxy_error", `The proxy answered with no campaign in its data field: ${said}, so the live campaign was not read.`, requested)
304
+ : notRun("unexpected_body", "The live campaign response carried no campaign in its data field.", requested);
305
+ }
306
+ const picked = pickCampaign(envelope.data, refId);
307
+ if (picked.notRun) return { ...picked.notRun, ...requested };
308
+ const refs = liveCampaignRefs(picked.campaign);
309
+ if (!refs) return notRun("unexpected_body", "The live campaign did not carry package and shipping-method lists with a ref on every entry.", requested);
310
+ return { status: "read", ...requested, ...refs };
311
+ }
312
+
313
+ // The `--no-live-refs` opt-out on `doctor` and `qa run`: no request, and the
314
+ // check recorded not_run with reason `disabled`, never a pass.
315
+ export function liveRefsDisabled(args) {
316
+ return Boolean(args) && Object.hasOwn(args, "no-live-refs");
317
+ }
318
+
319
+ export function disabledLiveRead() {
320
+ return notRun("disabled", "--no-live-refs was given, so the live campaign was not read.");
321
+ }
322
+
323
+ // An env var whose name says it holds something other than a public campaign
324
+ // key. The shared resolver only asks that the name contain CAMPAIGN, so
325
+ // `CAMPAIGN_ADMIN_TOKEN` would pass it; this read sends the value off the
326
+ // machine, so it also refuses these words.
327
+ const NON_PUBLIC_KEY_ENV_WORD = /ADMIN|TOKEN|SECRET|PASSWORD|PRIVATE|STORE/i;
328
+
329
+ function refusedEnvKeySource(packet, resolved) {
330
+ const source = typeof packet?.campaign?.api_key_source === "string" ? packet.campaign.api_key_source.trim() : "";
331
+ if (!source.startsWith("env:")) return null;
332
+ const envName = source.slice("env:".length).trim();
333
+ if (!NON_PUBLIC_KEY_ENV_WORD.test(envName)) return null;
334
+ // Only when the env source is the one in play: a key from the packet or its
335
+ // CampaignSpec wins over it, and a refused packet or CampaignSpec value is
336
+ // already reported as that.
337
+ const envInPlay = resolved.key
338
+ ? resolved.origin === `env:${envName}`
339
+ : !resolved.rejected || resolved.rejected.kind === "unsupported_env_name" || resolved.rejected.source === `env:${envName}`;
340
+ return envInPlay ? envName : null;
341
+ }
342
+
343
+ // The packet-local CampaignSpec, read the way the key resolver reads it;
344
+ // undefined when the packet names none or it cannot be read.
345
+ function readPacketLocalSpec(packet, packetPath) {
346
+ const localPath = packet?.spec?.local_path;
347
+ if (typeof localPath !== "string" || !localPath.trim() || typeof packetPath !== "string" || !packetPath.trim()) return undefined;
348
+ try {
349
+ return readJsonIfExists(resolveFromFile(packetPath, localPath)) ?? undefined;
350
+ } catch {
351
+ return undefined;
352
+ }
353
+ }
354
+
355
+ // The key from the packet, its local CampaignSpec, or the declared
356
+ // campaign-key env var (the resolver the remit and Map write use; its shape
357
+ // and env-name gates apply, plus the stricter env-name gate above), then one
358
+ // read, for the campaign the CampaignSpec's `campaign.ref_id` names when it
359
+ // names one.
360
+ export async function readLiveCampaignForPacket({ packet, packetPath = null, spec = undefined, env = process.env, fetchImpl = null, proxyBase = null, timeoutMs, warn } = {}) {
361
+ const localSpec = spec !== undefined ? spec : readPacketLocalSpec(packet, packetPath);
362
+ const resolved = resolveCampaignsApiKeySource(packet, packetPath, env, localSpec === undefined ? {} : { spec: localSpec });
363
+ const refusedEnv = refusedEnvKeySource(packet, resolved);
364
+ if (refusedEnv) {
365
+ return notRun(
366
+ "key_source_refused",
367
+ `api_key_source "env:${refusedEnv}" names a variable that holds an admin, store or secret credential rather than the public Campaigns API key, so its value was not sent and the live campaign was not read. Point api_key_source at the public campaign key (for example env:CAMPAIGNS_API_KEY).`,
368
+ );
369
+ }
370
+ if (!resolved.key) {
371
+ return resolved.rejected
372
+ ? notRun("key_rejected", describeCampaignKeyRejection(resolved.rejected))
373
+ : notRun("no_key", "No public Campaigns API key resolved from the packet, its local CampaignSpec or the declared env source, so the live campaign was not read.");
374
+ }
375
+ const result = await readLiveCampaign({
376
+ apiKey: resolved.key,
377
+ fetchImpl,
378
+ proxyBase,
379
+ campaignRefId: campaignRefIdFromSpec(localSpec),
380
+ ...(timeoutMs ? { timeoutMs } : {}),
381
+ ...(warn ? { warn } : {}),
382
+ });
383
+ return { ...result, key_source: resolved.origin };
384
+ }
385
+
386
+ function sortedRefs(values) {
387
+ return [...new Set([...values].map(String))].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
388
+ }
389
+
390
+ // pages: [{ page_id, file?, in_spec?, package_refs, shipping_refs }] (Sets or
391
+ // arrays); `in_spec: false` marks a built page the CampaignSpec does not list.
392
+ // map: { package_refs, shipping_refs } from the CampaignSpec. live: a
393
+ // readLiveCampaign result, or undefined when the caller did not read.
394
+ export function evaluateLiveCampaignRefs({ pages = [], map = {}, live } = {}) {
395
+ const checkedPages = pages.filter((page) => page && page.page_id != null);
396
+ if (!live) live = notRun("not_read", "This invocation does not read the live campaign.");
397
+ if (live.status !== "read") {
398
+ return {
399
+ status: "not_run",
400
+ reason_code: live.reason_code,
401
+ reason: live.reason,
402
+ // A read that was attempted and failed is the operator's to see; no key
403
+ // is already reported by the key check, a caller that does not read
404
+ // has nothing to report, and `--no-live-refs` asked for no read.
405
+ attempted: !["no_key", "key_rejected", "not_read", "disabled"].includes(live.reason_code),
406
+ ...(live.http_status !== undefined ? { http_status: live.http_status } : {}),
407
+ checked_pages: checkedPages.length,
408
+ page_findings: [],
409
+ drift: null,
410
+ };
411
+ }
412
+ const livePackages = new Set(live.package_refs);
413
+ const liveShipping = new Set(live.shipping_refs);
414
+ const pageFindings = [];
415
+ for (const page of checkedPages) {
416
+ const missingShipping = sortedRefs(page.shipping_refs || []).filter((ref) => !liveShipping.has(ref));
417
+ const missingPackages = sortedRefs(page.package_refs || []).filter((ref) => !livePackages.has(ref));
418
+ const where = { page_id: String(page.page_id), ...(page.file ? { file: page.file } : {}), ...(page.in_spec === false ? { in_spec: false } : {}) };
419
+ if (missingShipping.length) pageFindings.push({ code: LIVE_REF_CODES.shipping, kind: "shipping", ...where, refs: missingShipping });
420
+ if (missingPackages.length) pageFindings.push({ code: LIVE_REF_CODES.package, kind: "package", ...where, refs: missingPackages });
421
+ }
422
+ const mapPackages = sortedRefs(map.package_refs || []);
423
+ const mapShipping = sortedRefs(map.shipping_refs || []);
424
+ const drift = {
425
+ packages: {
426
+ map_only: mapPackages.filter((ref) => !livePackages.has(ref)),
427
+ live_only: sortedRefs(livePackages).filter((ref) => !mapPackages.includes(ref)),
428
+ },
429
+ shipping_methods: {
430
+ map_only: mapShipping.filter((ref) => !liveShipping.has(ref)),
431
+ live_only: sortedRefs(liveShipping).filter((ref) => !mapShipping.includes(ref)),
432
+ },
433
+ };
434
+ const hasDrift = Object.values(drift).some((side) => side.map_only.length || side.live_only.length);
435
+ return {
436
+ status: pageFindings.length ? "blocked" : "pass",
437
+ checked_pages: checkedPages.length,
438
+ live_package_count: livePackages.size,
439
+ live_shipping_count: liveShipping.size,
440
+ page_findings: pageFindings,
441
+ drift: hasDrift ? drift : null,
442
+ };
443
+ }
444
+
445
+ export function liveRefFindingMessage(finding) {
446
+ const noun = finding.kind === "shipping" ? "shipping ID(s)" : "package ID(s)";
447
+ const effect = finding.kind === "shipping"
448
+ ? "the SDK falls back to another shipping method and the order is charged that method's price"
449
+ : "the SDK cannot add a package the campaign does not serve";
450
+ const subject = finding.in_spec === false ? `Built page ${finding.file || finding.page_id} (not a CampaignSpec page)` : `Page "${finding.page_id}"`;
451
+ return `${subject} references ${noun} the live campaign does not serve: ${finding.refs.join(", ")}. The CampaignSpec is not the authority here; ${effect}. Point the page at a ${finding.kind === "shipping" ? "shipping method" : "package"} the campaign serves, or restore it in the campaign.`;
452
+ }
453
+
454
+ export function campaignDriftMessage(drift) {
455
+ const parts = [];
456
+ const side = (label, values) => { if (values.length) parts.push(`${label}: ${values.join(", ")}`); };
457
+ side("packages in the CampaignSpec but not the live campaign", drift.packages.map_only);
458
+ side("packages in the live campaign but not the CampaignSpec", drift.packages.live_only);
459
+ side("shipping methods in the CampaignSpec but not the live campaign", drift.shipping_methods.map_only);
460
+ side("shipping methods in the live campaign but not the CampaignSpec", drift.shipping_methods.live_only);
461
+ return `The saved CampaignSpec and the live campaign differ — ${parts.join("; ")}. Re-save the Map from the live campaign so doctor and QA compare against what checkout serves.`;
462
+ }
463
+
464
+ export function liveRefsNotRunMessage(result) {
465
+ return `The live campaign ref check did not run: ${result.reason} Page shipping and package refs were compared against the CampaignSpec only.`;
466
+ }
package/src/login.mjs CHANGED
@@ -2,8 +2,8 @@ import { createCredentialStore } from './credential-store.mjs';
2
2
 
3
3
  export const GATEWAY = 'https://mcp.nextcommerce.com';
4
4
  export const CLIENT_ID = 'campaigns-os-owned-store-pilot';
5
- export const RESOURCE = GATEWAY + '/campaigns';
6
- export const SCOPE = 'campaigns:read';
5
+ export const RESOURCE = GATEWAY + '/mcp';
6
+ export const SCOPE = 'campaigns.read';
7
7
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
8
8
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
9
9
  const safeString = (value, max = 8192) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
@@ -52,6 +52,38 @@ function normalizeFieldValue(value) {
52
52
  return { valid: true, value: normalizeStoreProfileValue(value) };
53
53
  }
54
54
 
55
+ // An explicit empty spec value for one of the nine governed fields (`""`, or
56
+ // whitespace only, which the gate compares as `""`) says the merchant has no
57
+ // such value: sync blanks a starter demo value with it and the gate reads a
58
+ // blank target as `intentionally_empty`. A real (non-demo) target value is
59
+ // left as it is and still warns `target_only`. An absent or null key still
60
+ // means "not provided", and an empty string outside the nine carries no such
61
+ // meaning.
62
+ export function isAuthoritativeEmptyStoreProfileValue(field, value) {
63
+ return PAGE_KIT_STORE_PROFILE_FIELDS.includes(field)
64
+ && typeof value === "string"
65
+ && normalizeStoreProfileValue(value) === "";
66
+ }
67
+
68
+ // The sentence naming the fields a gate reads as intentionally empty. Doctor
69
+ // still requires campaign.store_url (its spec.store_profile check), so a ""
70
+ // there is named but not treated as settled.
71
+ export function storeProfileIntentionallyEmptyNote(fields) {
72
+ if (!fields.length) return "";
73
+ return ` Intentionally empty per the CampaignSpec: ${fields.join(", ")}.`
74
+ + (fields.includes("store_url")
75
+ ? " campaign.store_url is still required, so doctor's spec.store_profile check blocks on an empty store_url."
76
+ : "");
77
+ }
78
+
79
+ // The sentence for fields the spec sets to "" while the target holds a real,
80
+ // non-demo value: sync blanks only a recognised starter demo value, so the
81
+ // spec's "" was not applied and the value stays until someone removes it.
82
+ export function storeProfileEmptyNotAppliedNote(fields, where) {
83
+ if (!fields.length) return "";
84
+ return ` The CampaignSpec sets ${fields.map((field) => `campaign.${field}`).join(", ")} to "" (none), but the target holds a real, non-demo value there; page-kit sync blanks only a recognised starter demo value, so it did not apply the "". Remove the value from ${where} by hand if the merchant has none.`;
85
+ }
86
+
55
87
  export function isDemoResidue(field, value) {
56
88
  if (!value) return false;
57
89
  if (URL_FIELDS.has(field)) {
@@ -123,15 +155,21 @@ export function storeProfileSpecValueProblem(field, value) {
123
155
  // The edit a blocked row needs when sync cannot make it: a target-side
124
156
  // defect with no spec value behind it (a demo or malformed target value in a
125
157
  // field the spec does not carry) is corrected in the target, or the field is
126
- // added to the spec and synced; a present-but-unusable spec value is
127
- // corrected in the spec.
128
- function repairDescriptionForUnsyncableRows(rows, subject) {
158
+ // added to the spec and synced; a non-demo target value under a spec "" is
159
+ // removed or corrected in the target by hand; a present-but-unusable spec
160
+ // value is corrected in the spec.
161
+ function repairDescriptionForUnsyncableRows(rows, subject, authoritativeEmptyFields) {
129
162
  const where = `${subject.target_path}[${subject.public_route_slug}]`;
130
163
  const targetSide = rows.filter((row) => storeProfileSpecValueProblem(row.field, row.spec) === "missing");
164
+ const notCarried = targetSide.filter((row) => !authoritativeEmptyFields.has(row.field));
165
+ const specEmpty = targetSide.filter((row) => authoritativeEmptyFields.has(row.field));
131
166
  const specSide = rows.filter((row) => storeProfileSpecValueProblem(row.field, row.spec) !== "missing");
132
167
  const parts = [];
133
- if (targetSide.length) {
134
- parts.push(`Remove or correct ${where}.${targetSide.map((row) => row.field).join(", ")} (the CampaignSpec does not carry ${targetSide.length === 1 ? "this field" : "these fields"}, so page-kit sync has nothing to write over the target), or add campaign.${targetSide.map((row) => row.field).join(", campaign.")} to the spec and run page-kit sync.`);
168
+ if (notCarried.length) {
169
+ parts.push(`Remove or correct ${where}.${notCarried.map((row) => row.field).join(", ")} (the CampaignSpec does not carry ${notCarried.length === 1 ? "this field" : "these fields"}, so page-kit sync has nothing to write over the target), or add campaign.${notCarried.map((row) => row.field).join(", campaign.")} to the spec and run page-kit sync.`);
170
+ }
171
+ if (specEmpty.length) {
172
+ parts.push(`Remove or correct ${where}.${specEmpty.map((row) => row.field).join(", ")} by hand: the CampaignSpec sets campaign.${specEmpty.map((row) => row.field).join(", campaign.")} to "" (none), but page-kit sync blanks only a recognised starter demo value, so it did not apply the "" over this non-demo target value.`);
135
173
  }
136
174
  if (specSide.length) {
137
175
  parts.push(`Repair the CampaignSpec Store Profile field(s) ${specSide.map((row) => `${row.field} (${storeProfileSpecValueProblem(row.field, row.spec)})`).join(", ")}: page-kit sync writes only a well-shaped, non-demo spec value over the target. Fix the spec, then run page-kit sync.`);
@@ -141,10 +179,14 @@ function repairDescriptionForUnsyncableRows(rows, subject) {
141
179
 
142
180
  // Sync repairs a row by writing the spec's value over the target's, so it
143
181
  // needs a usable spec value: present, well-shaped for its field, and not the
144
- // starter demo value itself. matrixRow puts demo_residue ahead of the spec
145
- // comparison, so a residue row may have an empty (or itself demo) spec value.
146
- function syncRepairsRow(row) {
147
- return SYNC_REPAIRABLE_KINDS.has(row.kind) && storeProfileSpecValueProblem(row.field, row.spec) === null;
182
+ // starter demo value itself; or, for demo residue only, an explicit empty
183
+ // value, which sync writes as "". matrixRow puts demo_residue ahead of the
184
+ // spec comparison, so a residue row may have an absent (or itself demo) spec
185
+ // value.
186
+ function syncRepairsRow(row, authoritativeEmptyFields) {
187
+ return SYNC_REPAIRABLE_KINDS.has(row.kind)
188
+ && ((row.kind === "demo_residue" && authoritativeEmptyFields.has(row.field))
189
+ || storeProfileSpecValueProblem(row.field, row.spec) === null);
148
190
  }
149
191
 
150
192
  export function storeProfileDemoResidueFields(gate) {
@@ -171,6 +213,13 @@ function matrixRow(field, rawSpec, rawTarget) {
171
213
  if (isDemoResidue(field, target.value)) {
172
214
  return { field, kind: "demo_residue", spec: spec.value, target: target.value, severity: "blocker" };
173
215
  }
216
+ // The spec says the field is empty and a blank (or absent) target agrees. A
217
+ // real target value against a spec "" stays a target_only warning below:
218
+ // Maps saved "" for every cleared store field before "" meant empty, so it
219
+ // is not trusted over a value someone entered.
220
+ if (isAuthoritativeEmptyStoreProfileValue(field, rawSpec) && !target.value) {
221
+ return { field, kind: "intentionally_empty", spec: spec.value, target: target.value, severity: "clean" };
222
+ }
174
223
  if (spec.value && !target.value) {
175
224
  return { field, kind: "target_missing", spec: spec.value, target: target.value, severity: "blocker" };
176
225
  }
@@ -292,6 +341,11 @@ export function evaluatePageKitStoreProfile({
292
341
  const blocker_fields = matrix.filter((row) => row.severity === "blocker").map((row) => row.field);
293
342
  const warning_fields = matrix.filter((row) => row.severity === "warning").map((row) => row.field);
294
343
  const blockerRows = matrix.filter((row) => row.severity === "blocker");
344
+ const authoritativeEmptyFields = new Set(PAGE_KIT_STORE_PROFILE_FIELDS.filter((field) => isAuthoritativeEmptyStoreProfileValue(field, specCampaign?.[field])));
345
+ const intentionallyEmptyFields = matrix.filter((row) => row.kind === "intentionally_empty").map((row) => row.field);
346
+ const emptyNotAppliedFields = matrix
347
+ .filter((row) => row.kind === "target_only" && authoritativeEmptyFields.has(row.field))
348
+ .map((row) => row.field);
295
349
  const waivable = blockerRows.length > 0
296
350
  && blockerRows.every((row) => isStoreProfileDiscrepancyWaivable(row.kind));
297
351
  // Waiver history matters only while this exact checkpoint is blocked. Once
@@ -328,7 +382,10 @@ export function evaluatePageKitStoreProfile({
328
382
  ? `Target Store Profile has an active named-human waiver for blocking field(s): ${blocker_fields.join(", ")}.`
329
383
  : warning_fields.length
330
384
  ? `Target Store Profile has target-only value(s): ${warning_fields.join(", ")}.`
331
- : "Target Store Profile exactly matches the CampaignSpec for all nine governed fields.";
385
+ + storeProfileEmptyNotAppliedNote(emptyNotAppliedFields, `${subject.target_path}[${subject.public_route_slug}]`)
386
+ + storeProfileIntentionallyEmptyNote(intentionallyEmptyFields)
387
+ : "Target Store Profile exactly matches the CampaignSpec for all nine governed fields."
388
+ + storeProfileIntentionallyEmptyNote(intentionallyEmptyFields);
332
389
  return {
333
390
  id: PAGE_KIT_STORE_PROFILE_SCOPE,
334
391
  scope: PAGE_KIT_STORE_PROFILE_SCOPE,
@@ -345,7 +402,7 @@ export function evaluatePageKitStoreProfile({
345
402
  waiver,
346
403
  waiver_assessment,
347
404
  required_actions: status === "blocked" ? [
348
- blockerRows.every(syncRepairsRow)
405
+ blockerRows.every((row) => syncRepairsRow(row, authoritativeEmptyFields))
349
406
  ? {
350
407
  id: "repair_target",
351
408
  kind: "command",
@@ -356,7 +413,7 @@ export function evaluatePageKitStoreProfile({
356
413
  id: "repair_target",
357
414
  kind: "edit",
358
415
  command: null,
359
- description: repairDescriptionForUnsyncableRows(blockerRows.filter((row) => !syncRepairsRow(row)), subject),
416
+ description: repairDescriptionForUnsyncableRows(blockerRows.filter((row) => !syncRepairsRow(row, authoritativeEmptyFields)), subject, authoritativeEmptyFields),
360
417
  },
361
418
  ...(waivable ? [{
362
419
  id: "waive_checkpoint",