@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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +648 -5103
- package/README.md +32 -11
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +158 -21
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +5 -2
- package/src/doctor/checks.mjs +320 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-browser.mjs +538 -28
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- 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 + '/
|
|
6
|
-
export const SCOPE = 'campaigns
|
|
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
|
|
127
|
-
// corrected in the spec
|
|
128
|
-
|
|
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 (
|
|
134
|
-
parts.push(`Remove or correct ${where}.${
|
|
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
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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",
|