@nextcommerce/campaigns-os 1.48.0 → 1.52.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 (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -0,0 +1,1019 @@
1
+ // Policy links resolve: for each configured campaign.store_* policy URL,
2
+ // `qa run --browser` reports two rows, both family browser-runtime:
3
+ // - presence (policy.presence:campaign:<field>, assertion
4
+ // qc.policy.presence:<field>): a rendered anchor on the visited pages points
5
+ // to the configured URL;
6
+ // - availability (policy.availability:campaign:<field>, assertion
7
+ // qc.policy.availability:<field>): a bounded, header-only GET chain reaches
8
+ // an HTML page.
9
+ //
10
+ // This module holds the URL normalization and classes, the presence counts,
11
+ // the availability probe, the result rules and the anchor reader. The page
12
+ // visits stay in qa-browser.mjs (runPageBrowserChecks), which calls
13
+ // readPageAnchors once per visited page; runBrowserChecks then calls
14
+ // runPolicyLinkChecks once, after the page checks.
15
+ //
16
+ // Privacy: link text is compared inside the page and never leaves it; presence
17
+ // keeps counts. A query is compared in memory and kept only as query_sha256
18
+ // (the sha256 of its sorted key=value pairs). Every stored URL is
19
+ // origin+path, written as the persisted-verdict projection
20
+ // (src/qa-url-privacy.mjs) leaves it, so a row and its verdict assertion carry
21
+ // the same observation. That projection can merge distinct paths (an encoded
22
+ // "?" in a path is redacted), so no rule compares stored URLs: the configured
23
+ // value (configured_identity) and each hop (chain_identity) also keep sha256
24
+ // hashes of the raw URL, path and host, and the rules compare those. A row's
25
+ // accept state carries the path and host hashes (the forms the pass rule
26
+ // compares), so an accept lapses when a raw URL changes behind the same stored
27
+ // text, but not for a change the pass rule treats as the same destination.
28
+ //
29
+ // Time: one run budget (createPolicyLinkBudget) bounds the time the policy
30
+ // link checks add to a QA run. Every anchor read and the probes take their
31
+ // deadline from it, nothing starts once it is spent, and what it cut short
32
+ // reads unexercised.
33
+ //
34
+ // rederiveQcResult(observation) is the rule the QC readers call; the producer
35
+ // builds every row through it, and the availability outcome is a function of
36
+ // the stored request chain, so a stored outcome only stands when the chain
37
+ // re-derives to it.
38
+ import { createHash } from "node:crypto";
39
+
40
+ import { runWithDeadline } from "./deadline.mjs";
41
+ import { buildQcResult, toQaAssertion } from "./qc-results.mjs";
42
+ import { REDACTED_QUERY, TRUNCATED, redactPersisted } from "./qa-url-privacy.mjs";
43
+
44
+ export const POLICY_PRESENCE_CHECK = "policy.presence";
45
+ export const POLICY_AVAILABILITY_CHECK = "policy.availability";
46
+ const CHECKS = Object.freeze([POLICY_PRESENCE_CHECK, POLICY_AVAILABILITY_CHECK]);
47
+ const FAMILY = "browser-runtime";
48
+ const PAGE = "campaign";
49
+
50
+ // The CampaignSpec policy fields, in the order rows are written. store_url is
51
+ // not a policy.
52
+ export const POLICY_LINK_FIELDS = Object.freeze(["store_terms", "store_privacy", "store_returns", "store_shipping", "store_contact"]);
53
+
54
+ // Cost bound: at most 5 distinct URLs per run (one per field at most), all
55
+ // probed at once; per URL up to 6 requests (1 + 5 redirects), 5 s per request
56
+ // (its body cancel included) and a 15 s deadline; at most 30 requests. The
57
+ // anchor reads and the probes together add at most addedMs to the run.
58
+ export const POLICY_LINK_LIMITS = Object.freeze({
59
+ maxUrls: 5,
60
+ maxRedirects: 5,
61
+ requestTimeoutMs: 5_000,
62
+ deadlineMs: 15_000,
63
+ addedMs: 20_000,
64
+ });
65
+ // The anchor read of one page, after its load settled.
66
+ const ANCHOR_READ_MS = 5_000;
67
+ // A page with more anchors than this is not read (its read would be partial).
68
+ const MAX_ANCHORS = 10_000;
69
+ // Anchor text is compared in full, inside the page. A page with an anchor text
70
+ // longer than this many characters is not read (its read would be partial).
71
+ const MAX_TEXT = 1_048_576;
72
+ // What the probe waits for a canceled body to settle, within its request's
73
+ // deadline.
74
+ const CANCEL_SETTLE_MS = 1_000;
75
+
76
+ const SCHEMES = Object.freeze(["http", "https", "mailto", "other", "invalid"]);
77
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
78
+ const NOT_REQUESTED = "browser_checks_not_requested";
79
+ const INVALID = "configured_url_invalid";
80
+ const NON_HTTP = "non_http_destination";
81
+ const NETWORK = "network_unavailable";
82
+ const PAGES_NOT_READ = "pages_not_read";
83
+ const HTML_TYPES = Object.freeze(["text/html", "application/xhtml+xml"]);
84
+
85
+ // Availability outcome → result.
86
+ const AVAILABILITY_RESULTS = Object.freeze({
87
+ pass: "pass",
88
+ not_found: "warning",
89
+ server_error: "warning",
90
+ redirected_to_root: "warning",
91
+ redirected_elsewhere: "review",
92
+ redirect_loop: "review",
93
+ redirect_limit: "review",
94
+ auth_required: "review",
95
+ rate_limited: "review",
96
+ non_html_response: "review",
97
+ unexpected_status: "review",
98
+ [INVALID]: "review",
99
+ [NETWORK]: "unexercised",
100
+ [NON_HTTP]: "excluded",
101
+ [NOT_REQUESTED]: "excluded",
102
+ });
103
+
104
+ // The English wording hint per field, matched case-insensitively at the start
105
+ // of a word of the anchor text.
106
+ const HINTS = Object.freeze({
107
+ store_terms: ["terms"],
108
+ store_privacy: ["privacy"],
109
+ store_returns: ["refund", "return"],
110
+ store_shipping: ["shipping"],
111
+ store_contact: ["contact"],
112
+ });
113
+ // Each pattern's source; the anchor read tests it inside the page, flag "i".
114
+ const HINT_SOURCES = Object.freeze(Object.fromEntries(Object.entries(HINTS).map(([field, words]) => [field, `\\b(?:${words.join("|")})`])));
115
+
116
+ const SHA = /^sha256:[a-f0-9]{64}$/;
117
+ const CONTENT_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
118
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
119
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
120
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
121
+ const sha256 = (text) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
122
+ // A stored string is written as the persisted-verdict projection
123
+ // (redactPersisted, which the verdict applies when it is assembled) leaves it.
124
+ const persistable = (text) => redactPersisted(text);
125
+ const isPersistable = (text) => typeof text === "string" && persistable(text) === text;
126
+ // Every stored object has a closed key list, defined next to the code that
127
+ // writes it. The producer writes each object through record(), so it holds
128
+ // exactly those keys; the reader refuses one that does not (hasExactKeys).
129
+ const record = (keys, values) => Object.fromEntries(keys.map((key) => [key, values[key]]));
130
+ const hasExactKeys = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Clock and run budget
134
+
135
+ // The clock every deadline reads: a monotonic now() in milliseconds and the
136
+ // timer pair runWithDeadline arms. Tests inject their own.
137
+ export const SYSTEM_CLOCK = Object.freeze({
138
+ now: () => performance.now(),
139
+ setTimer: (callback, ms) => setTimeout(callback, ms),
140
+ clearTimer: (handle) => clearTimeout(handle),
141
+ });
142
+
143
+ // The run budget of the policy link checks: at most `addedMs` of time added to
144
+ // the QA run (contract §1.4 Cost: "Added QA wall clock is at most 20 s").
145
+ // Created once, when the checks start, and passed to every step. A step is
146
+ // one anchor read, or the probes of all URLs at once; it runs through step(),
147
+ // which hands it `endsAt` (the clock time the budget runs out) and charges
148
+ // its elapsed time to the budget. The time between steps (the page visits) is
149
+ // not added time and is not charged. A step that finds the budget spent does
150
+ // nothing.
151
+ export function createPolicyLinkBudget({ addedMs = POLICY_LINK_LIMITS.addedMs, clock = SYSTEM_CLOCK } = {}) {
152
+ let spent = 0;
153
+ return {
154
+ clock,
155
+ async step(operation) {
156
+ const started = clock.now();
157
+ try {
158
+ return await operation(started + (addedMs - spent));
159
+ } finally {
160
+ spent += clock.now() - started;
161
+ }
162
+ },
163
+ };
164
+ }
165
+
166
+ // Runs `operation` until the clock reaches `endsAt` (see runWithDeadline).
167
+ const untilClock = (clock, endsAt, operation, options = {}) => runWithDeadline(operation, {
168
+ ...options,
169
+ timeoutMs: endsAt - clock.now(),
170
+ setTimer: clock.setTimer,
171
+ clearTimer: clock.clearTimer,
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Normalization and URL classes
176
+
177
+ // The query as a sorted key=value multiset, or null when it has no pair.
178
+ function queryKey(url) {
179
+ const pairs = [...url.searchParams].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).sort();
180
+ return pairs.length ? pairs.join("&") : null;
181
+ }
182
+
183
+ const trimSlash = (path) => (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path);
184
+ const safeDecode = (text) => {
185
+ try {
186
+ return decodeURIComponent(text);
187
+ } catch {
188
+ return text;
189
+ }
190
+ };
191
+
192
+ // The hashes of one raw http(s) URL that the availability rules compare:
193
+ // url_sha256 its exact origin+path (with query_sha256, a hop's identity for
194
+ // loops), path_sha256 its path with the trailing slash ignored, host_sha256
195
+ // its host without a leading "www." (the pass allowances). Stored as an http(s)
196
+ // configured value's configured_identity and as a hop's chain_identity entry.
197
+ const IDENTITY_KEYS = Object.freeze(["url_sha256", "path_sha256", "host_sha256"]);
198
+ const sameIdentity = (a, b) => IDENTITY_KEYS.every((key) => a[key] === b[key]);
199
+ // The hashes an accept state carries: path and host only, so a trailing slash
200
+ // or a leading "www." leaves the state as it was.
201
+ const STATE_IDENTITY_KEYS = Object.freeze(["path_sha256", "host_sha256"]);
202
+ const stateIdentity = (ids) => (ids === null ? null : record(STATE_IDENTITY_KEYS, ids));
203
+ const urlIdentity = (protocol, host, pathname) => record(IDENTITY_KEYS, {
204
+ url_sha256: sha256(`${protocol}//${host}${pathname}`),
205
+ path_sha256: sha256(trimSlash(pathname)),
206
+ host_sha256: sha256(host.replace(/^www\./, "")),
207
+ });
208
+ const ROOT_PATH_SHA = sha256("/");
209
+
210
+ // The projection's marker as the URL parser writes it in a path (its angle
211
+ // brackets percent-encoded). A stored path the projection redacted ends with
212
+ // the marker; parsed again, that suffix is read as the marker.
213
+ const ENCODED_REDACTED_QUERY = encodeURI(REDACTED_QUERY);
214
+ const withMarker = (text) => (text.endsWith(ENCODED_REDACTED_QUERY) ? `${text.slice(0, -ENCODED_REDACTED_QUERY.length)}${REDACTED_QUERY}` : text);
215
+
216
+ // The stored value of a URL whose projection does not read back as itself:
217
+ // its scheme (and, where the scheme needs one, its host) with the marker.
218
+ function placeholderOf(url) {
219
+ if (url.protocol === "mailto:") return `mailto:${REDACTED_QUERY}`;
220
+ const opaque = `${url.protocol}${REDACTED_QUERY}`;
221
+ return URL.canParse(opaque) ? opaque : `${url.protocol}//${url.host}/${REDACTED_QUERY}`;
222
+ }
223
+
224
+ // The stored value of a URL whose placeholder does not read back as itself
225
+ // either (its scheme or host is too long for the projection's bound): a fixed
226
+ // value of its class, with the marker.
227
+ const BOUNDED_PLACEHOLDERS = Object.freeze({
228
+ http: `http://host-redacted.invalid/${REDACTED_QUERY}`,
229
+ https: `https://host-redacted.invalid/${REDACTED_QUERY}`,
230
+ mailto: `mailto:${REDACTED_QUERY}`,
231
+ other: `scheme-redacted:${REDACTED_QUERY}`,
232
+ });
233
+
234
+ // One URL as the rules compare it, in one pass (see parseTarget).
235
+ function classifyTarget(value) {
236
+ if (typeof value !== "string" || !value.trim()) return { scheme: "invalid" };
237
+ let url;
238
+ try {
239
+ url = new URL(value.trim());
240
+ } catch {
241
+ return { scheme: "invalid" };
242
+ }
243
+ const protocol = url.protocol.toLowerCase();
244
+ if (protocol === "http:" || protocol === "https:") {
245
+ if (!url.hostname) return { scheme: "invalid" };
246
+ url.hash = "";
247
+ url.username = "";
248
+ url.password = "";
249
+ const query = queryKey(url);
250
+ const originPath = `${protocol}//${url.host}${url.pathname}`;
251
+ return {
252
+ scheme: protocol.slice(0, -1),
253
+ stored: persistable(withMarker(originPath)),
254
+ placeholder: placeholderOf(url),
255
+ query,
256
+ querySha: query == null ? null : sha256(query),
257
+ ids: urlIdentity(protocol, url.host, url.pathname),
258
+ match: `${protocol}//${url.host}${trimSlash(url.pathname)}`,
259
+ host: url.host,
260
+ request: url.href,
261
+ };
262
+ }
263
+ if (protocol === "mailto:") {
264
+ const address = safeDecode(url.pathname).trim().toLowerCase();
265
+ return { scheme: "mailto", stored: persistable(`mailto:${address}`), placeholder: placeholderOf(url), query: null, querySha: null, match: `mailto:${address}` };
266
+ }
267
+ const bare = url.href.split(/[?#]/)[0].replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1");
268
+ return { scheme: "other", stored: persistable(withMarker(bare)), placeholder: placeholderOf(url), query: null, querySha: null, match: bare };
269
+ }
270
+
271
+ // One URL as the rules compare it. `scheme` is the class; `stored` is the
272
+ // persisted value; for http(s), `ids` are the hashes of the raw URL
273
+ // (urlIdentity), `match` the presence identity (trailing slash ignored, query
274
+ // kept) and `request` the URL a probe fetches (no fragment, no userinfo). The
275
+ // query text never leaves this object.
276
+ //
277
+ // Invariant: every stored value parseTarget returns is a fixed point of both
278
+ // parseTarget and the persisted-verdict projection, so a stored identity
279
+ // persisted in a verdict reads back as itself (readIdentity holds every
280
+ // stored identity to that). The projection is one when, classified again, it
281
+ // is the same class with the same stored value. When it is not (the
282
+ // persisted-verdict projection replaced it whole or cut it, or it no longer
283
+ // classifies as a URL of its class), the placeholder is stored:
284
+ // it depends only on the scheme and host it names, so it parses back to
285
+ // itself, and it is one when the projection leaves it unchanged. A
286
+ // placeholder longer than the projection's bound is not, and the bounded
287
+ // placeholder of its class is stored instead. Decisions never read the stored
288
+ // value: they compare `ids` and `match`, taken from the raw URL.
289
+ function parseTarget(value) {
290
+ const { placeholder, ...target } = classifyTarget(value);
291
+ if (target.scheme === "invalid") return target;
292
+ const again = classifyTarget(target.stored);
293
+ if (again.scheme === target.scheme && again.stored === target.stored) return target;
294
+ return { ...target, stored: persistable(placeholder) === placeholder ? placeholder : BOUNDED_PLACEHOLDERS[target.scheme] };
295
+ }
296
+
297
+ const sameTarget = (a, b) => Boolean(a?.match) && a.match === b.match && a.query === b.query;
298
+ const samePath = (a, b) => Boolean(a?.match) && a.match === b.match;
299
+
300
+ // The configured policy fields of a spec: every non-empty string value (the
301
+ // fields the QC handoff treats as applicable).
302
+ export function configuredPolicyFields(spec) {
303
+ const campaign = isPlainObject(spec?.campaign) ? spec.campaign : {};
304
+ return POLICY_LINK_FIELDS.filter((field) => isNonEmptyString(campaign[field])).map((field) => ({ field, value: campaign[field] }));
305
+ }
306
+
307
+ export const hasPolicyLinkFields = (spec) => configuredPolicyFields(spec).length > 0;
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Presence
311
+
312
+ const normalText = (text) => String(text ?? "").replace(/\s+/g, " ").trim();
313
+
314
+ // The labels footer_links declares for the configured URL.
315
+ function declaredLabels(spec, target) {
316
+ const links = Array.isArray(spec?.campaign?.footer_links) ? spec.campaign.footer_links : [];
317
+ const labels = [];
318
+ for (const link of links) {
319
+ if (!isPlainObject(link) || !isNonEmptyString(link.label) || typeof link.url !== "string") continue;
320
+ if (sameTarget(parseTarget(link.url), target)) labels.push(normalText(link.label));
321
+ }
322
+ return labels;
323
+ }
324
+
325
+ // The declared labels of every configured field, by field: what the anchor
326
+ // read compares each anchor's text with.
327
+ function declaredLabelsByField(spec) {
328
+ const labels = {};
329
+ for (const { field, value } of configuredPolicyFields(spec)) {
330
+ const target = parseTarget(value);
331
+ if (target.scheme !== "invalid") labels[field] = declaredLabels(spec, target);
332
+ }
333
+ return labels;
334
+ }
335
+
336
+ // The stored presence block: the counts presenceCounts writes.
337
+ const PRESENCE_KEYS = Object.freeze(["pages_expected", "pages_read", "pages_with_match", "path_match_query_differs", "label_declared", "label_anchor_mismatch_pages", "hint_anchor_elsewhere"]);
338
+
339
+ // The presence counts of one configured field over the visited pages
340
+ // ({read, anchors}; each anchor {href, label_fields, hint_fields} as
341
+ // readPageAnchors gives it). Only pages whose anchors were read are compared.
342
+ export function presenceCounts({ field, target, pages, labels = [] }) {
343
+ const counts = {
344
+ pages_expected: pages.length,
345
+ pages_read: pages.filter((page) => page.read === true).length,
346
+ pages_with_match: 0,
347
+ path_match_query_differs: 0,
348
+ label_declared: labels.length > 0,
349
+ label_anchor_mismatch_pages: 0,
350
+ hint_anchor_elsewhere: 0,
351
+ };
352
+ if (target.scheme === "invalid") return record(PRESENCE_KEYS, { ...counts, label_declared: false });
353
+ for (const page of pages) {
354
+ if (page.read !== true) continue;
355
+ let matched = false;
356
+ let queryDiffers = false;
357
+ let mislabelled = false;
358
+ for (const anchor of page.anchors) {
359
+ const points = parseTarget(anchor.href);
360
+ if (sameTarget(points, target)) {
361
+ matched = true;
362
+ continue;
363
+ }
364
+ if ((target.scheme === "http" || target.scheme === "https") && samePath(points, target)) queryDiffers = true;
365
+ if (labels.length && anchor.label_fields.includes(field)) mislabelled = true;
366
+ if (anchor.hint_fields.includes(field)) counts.hint_anchor_elsewhere += 1;
367
+ }
368
+ if (matched) counts.pages_with_match += 1;
369
+ if (queryDiffers) counts.path_match_query_differs += 1;
370
+ if (mislabelled) counts.label_anchor_mismatch_pages += 1;
371
+ }
372
+ return record(PRESENCE_KEYS, counts);
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Availability outcome (shared by the probe and the reader)
377
+
378
+ const isHttpStored = (text) => {
379
+ if (!isPersistable(text)) return false;
380
+ try {
381
+ const url = new URL(text);
382
+ return (url.protocol === "http:" || url.protocol === "https:") && !url.search && !url.hash;
383
+ } catch {
384
+ return false;
385
+ }
386
+ };
387
+ const validQuery = (value) => value === null || SHA.test(value);
388
+ const validHop = (hop) => hasExactKeys(hop, HOP_KEYS) && isHttpStored(hop.url) && validQuery(hop.query_sha256) && Number.isSafeInteger(hop.status) && hop.status >= 100 && hop.status <= 599;
389
+ // The raw-URL hashes kept for a stored URL, in an object of exactly `keys`.
390
+ //
391
+ // Rule: the hashes agree with every part of the raw URL the stored URL still
392
+ // shows, whatever the projection redacted. The projection never cuts a host,
393
+ // so host_sha256 is always the hash of the stored host (only a bounded
394
+ // placeholder shows no host). When the stored path is shown whole (neither
395
+ // redacted nor truncated), the stored URL is the raw origin+path, so
396
+ // url_sha256 (scheme, host and path) and path_sha256 are its hashes too.
397
+ // Otherwise the scheme is held by sameStoredUrl: requests with one url_sha256
398
+ // have one stored URL.
399
+ function validIdentity(ids, stored, keys = IDENTITY_KEYS) {
400
+ if (!hasExactKeys(ids, keys) || !IDENTITY_KEYS.every((key) => SHA.test(ids[key]))) return false;
401
+ const url = new URL(stored);
402
+ if (stored === BOUNDED_PLACEHOLDERS[url.protocol.slice(0, -1)]) return true;
403
+ const recomputed = urlIdentity(url.protocol, url.host, url.pathname);
404
+ const shown = stored.includes(REDACTED_QUERY) || stored.endsWith(TRUNCATED) ? ["host_sha256"] : IDENTITY_KEYS;
405
+ return shown.every((key) => ids[key] === recomputed[key]);
406
+ }
407
+ // Whether every pair [stored URL, its hashes] with the same url_sha256 has the
408
+ // same stored URL and hashes, as the stored value and the hashes are both
409
+ // taken from the raw origin+path alone.
410
+ function sameStoredUrl(entries) {
411
+ const byUrl = new Map();
412
+ for (const [stored, ids] of entries) {
413
+ const shown = JSON.stringify([stored, ids.path_sha256, ids.host_sha256]);
414
+ if ((byUrl.get(ids.url_sha256) ?? shown) !== shown) return false;
415
+ byUrl.set(ids.url_sha256, shown);
416
+ }
417
+ return true;
418
+ }
419
+ // A request's identity: its exact raw origin+path and its query.
420
+ const requestKey = (ids, querySha) => `${ids.url_sha256} ${querySha}`;
421
+
422
+ // What a stored http(s) URL shows of its destination: its host without a
423
+ // leading "www." and its path with the trailing slash ignored, or, when the
424
+ // projection cut the path (redacted or truncated), the start of the raw path
425
+ // it still shows (`cut`). null when it shows no destination (a bounded
426
+ // placeholder); false when it is not written as its own origin+path.
427
+ function shownDestination(stored) {
428
+ const url = new URL(stored);
429
+ if (stored === BOUNDED_PLACEHOLDERS[url.protocol.slice(0, -1)]) return null;
430
+ const origin = `${url.protocol}//${url.host}`;
431
+ if (!stored.startsWith(origin)) return false;
432
+ const marker = [REDACTED_QUERY, TRUNCATED].find((end) => stored.endsWith(end));
433
+ const path = stored.slice(origin.length, marker ? -marker.length : undefined);
434
+ return { host: url.host.replace(/^www\./, ""), path: marker ? path : trimSlash(path), cut: Boolean(marker) };
435
+ }
436
+ // Whether two shown paths can be one path with the trailing slash ignored: a
437
+ // cut path is the start of a raw path that is the other path or the other
438
+ // path with a slash added.
439
+ function shownPathsAgree(a, b) {
440
+ if (!a.cut && !b.cut) return a.path === b.path;
441
+ if (!b.cut) return `${b.path}/`.startsWith(a.path);
442
+ if (!a.cut) return `${a.path}/`.startsWith(b.path);
443
+ return a.path.startsWith(b.path) || b.path.startsWith(a.path);
444
+ }
445
+ const shownAgree = (a, b, compare) => a !== false && b !== false && (a === null || b === null || compare(a, b));
446
+ // Whether two stored URLs can be one destination under the pass allowances. A
447
+ // placeholder is one destination only with another placeholder.
448
+ const sameShownDestination = (a, b) => {
449
+ const [x, y] = [shownDestination(a), shownDestination(b)];
450
+ return (x === null) === (y === null) && shownAgree(x, y, (p, q) => p.host === q.host && shownPathsAgree(p, q));
451
+ };
452
+ // Whether a stored URL can be at the root path.
453
+ const showsRoot = (stored) => shownAgree(shownDestination(stored), { path: "/", cut: false }, shownPathsAgree);
454
+
455
+ // Whether the final URL is the configured one, allowing only a scheme, a
456
+ // leading "www." or a trailing-slash difference. Both are compared through the
457
+ // hashes of their raw URLs.
458
+ //
459
+ // Rule: an outcome that says two requests are one destination stands only
460
+ // when their stored URLs say so too, under exactly the allowances the
461
+ // decision makes; a stored chain where they do not re-derives to nothing.
462
+ // pass: the stored final and configured URLs agree but for the scheme, a
463
+ // leading "www." and a trailing slash (sameShownDestination), a bounded
464
+ // placeholder agreeing only with another placeholder; a redacted or
465
+ // truncated path is compared up to its cut. redirected_to_root: the stored
466
+ // final URL shows the root path (showsRoot). redirect_loop: the two requests have one url_sha256, so
467
+ // one stored URL (sameStoredUrl). The producer always writes agreeing stored
468
+ // URLs, as equal raw URLs give equal stored URLs.
469
+ function passesAs(final, configured) {
470
+ return final.query_sha256 === configured.query_sha256
471
+ && final.ids.host_sha256 === configured.ids.host_sha256
472
+ && final.ids.path_sha256 === configured.ids.path_sha256;
473
+ }
474
+
475
+ function finalOutcome(final, contentType, configured) {
476
+ const status = final.status;
477
+ if (status >= 200 && status < 300) {
478
+ if (!passesAs(final, configured)) {
479
+ if (final.ids.path_sha256 !== ROOT_PATH_SHA || configured.ids.path_sha256 === ROOT_PATH_SHA) return "redirected_elsewhere";
480
+ return showsRoot(final.url) ? "redirected_to_root" : null;
481
+ }
482
+ if (!sameShownDestination(final.url, configured.url)) return null;
483
+ return HTML_TYPES.includes(contentType) ? "pass" : "non_html_response";
484
+ }
485
+ if (status === 404 || status === 410) return "not_found";
486
+ if (status >= 500) return "server_error";
487
+ if (status === 401 || status === 403) return "auth_required";
488
+ if (status === 429) return "rate_limited";
489
+ return "unexpected_status";
490
+ }
491
+
492
+ // The outcome a stored availability block re-derives to, or null when the
493
+ // block is not one a probe of the configured value can have written.
494
+ function availabilityOutcome({ scheme, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, run_scope: runScope }, block, { maxRedirects = POLICY_LINK_LIMITS.maxRedirects } = {}) {
495
+ if (!hasExactKeys(block, BLOCK_KEYS) || !Array.isArray(block.chain)) return null;
496
+ const { chain, chain_identity: ids, next_hop: nextHop } = block;
497
+ if (!Array.isArray(ids)) return null;
498
+ const empty = chain.length === 0 && ids.length === 0 && block.final === null && block.final_query_sha256 === null && block.status === null && block.content_type === null && nextHop === null;
499
+ if (runScope === NOT_REQUESTED) return empty ? NOT_REQUESTED : null;
500
+ if (runScope != null) return null;
501
+ if (scheme === "invalid") return empty ? INVALID : null;
502
+ if (scheme === "mailto" || scheme === "other") return empty ? NON_HTTP : null;
503
+ if (!chain.length) return empty ? NETWORK : null;
504
+ if (chain.length > maxRedirects + 1 || !chain.every(validHop)) return null;
505
+ if (ids.length !== chain.length || !ids.every((hopIds, index) => validIdentity(hopIds, chain[index].url))) return null;
506
+ const requests = chain.map((hop, index) => [hop.url, ids[index]]);
507
+ if (!sameStoredUrl(requests)) return null;
508
+ // The first request is the configured URL, its hashes included; every hop
509
+ // but the last is a redirect; no request repeats (a repeat ends the chain as
510
+ // a loop instead).
511
+ if (chain[0].url !== configured || chain[0].query_sha256 !== configuredQuery || !sameIdentity(ids[0], configuredIds)) return null;
512
+ if (!chain.slice(0, -1).every((hop) => REDIRECT_STATUSES.has(hop.status))) return null;
513
+ const keys = chain.map((hop, index) => requestKey(ids[index], hop.query_sha256));
514
+ if (new Set(keys).size !== keys.length) return null;
515
+ const last = chain.at(-1);
516
+ if (block.final !== last.url || block.final_query_sha256 !== last.query_sha256 || block.status !== last.status) return null;
517
+ if (block.content_type !== null && !CONTENT_TYPE.test(block.content_type)) return null;
518
+ if (REDIRECT_STATUSES.has(last.status) && nextHop !== null) {
519
+ if (!isPlainObject(nextHop) || !isHttpStored(nextHop.url) || !validQuery(nextHop.query_sha256) || !validIdentity(nextHop, nextHop.url, NEXT_HOP_KEYS)) return null;
520
+ if (!sameStoredUrl([...requests, [nextHop.url, nextHop]])) return null;
521
+ if (keys.includes(requestKey(nextHop, nextHop.query_sha256))) return "redirect_loop";
522
+ if (chain.length === maxRedirects + 1) return "redirect_limit";
523
+ // The redirect was due but its request got no response in time.
524
+ return NETWORK;
525
+ }
526
+ if (nextHop !== null) return null;
527
+ return finalOutcome({ ...last, ids: ids.at(-1) }, block.content_type, { ...chain[0], ids: ids[0] });
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Availability probe
532
+
533
+ // The stored availability block, as probePolicyUrl writes it.
534
+ const BLOCK_KEYS = Object.freeze(["chain", "chain_identity", "final", "final_query_sha256", "status", "content_type", "outcome", "next_hop"]);
535
+ const emptyBlock = (outcome) => record(BLOCK_KEYS, { chain: [], chain_identity: [], final: null, final_query_sha256: null, status: null, content_type: null, outcome, next_hop: null });
536
+
537
+ const contentTypeOf = (value) => {
538
+ const type = String(value ?? "").split(";")[0].trim().toLowerCase();
539
+ return CONTENT_TYPE.test(type) ? type : null;
540
+ };
541
+
542
+ const cancelBody = (response) => {
543
+ try {
544
+ const canceled = response?.body?.cancel?.();
545
+ if (canceled && typeof canceled.then === "function") return canceled.then(() => {}, () => {});
546
+ } catch {
547
+ // A body that cannot be canceled has nothing more to settle.
548
+ }
549
+ return null;
550
+ };
551
+
552
+ // One GET with redirect "manual": the status and headers, then the body is
553
+ // canceled without reading it. The whole request, its body cancel included,
554
+ // ends by `endsAt` on `clock`: it rejects when it has not, whether or not
555
+ // `fetchImpl` honours the abort signal. A cancel that has not settled after
556
+ // CANCEL_SETTLE_MS (and before `endsAt`) is left to finish on its own.
557
+ async function headersOnly(fetchImpl, url, { endsAt, clock }) {
558
+ const controller = new AbortController();
559
+ const pending = Promise.resolve().then(() => fetchImpl(url, {
560
+ method: "GET",
561
+ redirect: "manual",
562
+ signal: controller.signal,
563
+ headers: { accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1" },
564
+ }));
565
+ try {
566
+ return await untilClock(clock, endsAt, async () => {
567
+ const response = await pending;
568
+ const answer = {
569
+ status: response.status,
570
+ location: response.headers?.get?.("location") ?? null,
571
+ contentType: contentTypeOf(response.headers?.get?.("content-type")),
572
+ };
573
+ const canceled = cancelBody(response);
574
+ if (canceled) await untilClock(clock, clock.now() + CANCEL_SETTLE_MS, () => canceled).catch(() => {});
575
+ return answer;
576
+ }, { onTimeout: () => controller.abort(), label: "probePolicyUrl" });
577
+ } catch (error) {
578
+ // A response that arrives after the deadline is canceled unread.
579
+ pending.then(cancelBody, () => {});
580
+ throw error;
581
+ }
582
+ }
583
+
584
+ // The next request of a redirect, or null when its Location is missing or
585
+ // is not an http(s) URL.
586
+ function redirectTarget(location, from) {
587
+ if (typeof location !== "string" || !location.trim()) return null;
588
+ let next;
589
+ try {
590
+ next = new URL(location.trim(), from);
591
+ } catch {
592
+ return null;
593
+ }
594
+ if (next.protocol !== "http:" && next.protocol !== "https:") return null;
595
+ const target = parseTarget(next.href);
596
+ return target.scheme === "http" || target.scheme === "https" ? target : null;
597
+ }
598
+
599
+ // A stored chain hop, and the stored next hop of a redirect that ended the
600
+ // chain (with its raw-URL hashes).
601
+ const HOP_KEYS = Object.freeze(["url", "query_sha256", "status"]);
602
+ const NEXT_HOP_KEYS = Object.freeze(["url", "query_sha256", ...IDENTITY_KEYS]);
603
+ const hopOf = (target, status) => record(HOP_KEYS, { url: target.stored, query_sha256: target.querySha, status });
604
+ const nextHopOf = (target) => record(NEXT_HOP_KEYS, { url: target.stored, query_sha256: target.querySha, ...target.ids });
605
+
606
+ // Probes one configured value: GET with redirect "manual", following up to
607
+ // `maxRedirects` redirects (off-origin included). Each request ends by the
608
+ // earliest of its own `timeoutMs`, the chain's `deadlineMs` and `endsAt` (the
609
+ // run budget's end), all on `clock`; nothing is requested once one of them is
610
+ // reached, and a response is accepted only when it, its body cancel included,
611
+ // completed before its request's end. Resolves to the availability block
612
+ // {chain, chain_identity, final, final_query_sha256, status, content_type,
613
+ // outcome, next_hop}; never rejects. A value that is not an absolute http(s)
614
+ // URL sends nothing. `fetchImpl` defaults to the global fetch at call time.
615
+ export async function probePolicyUrl(url, {
616
+ maxRedirects = POLICY_LINK_LIMITS.maxRedirects,
617
+ timeoutMs = POLICY_LINK_LIMITS.requestTimeoutMs,
618
+ deadlineMs = POLICY_LINK_LIMITS.deadlineMs,
619
+ fetchImpl = globalThis.fetch,
620
+ clock = SYSTEM_CLOCK,
621
+ endsAt = Infinity,
622
+ } = {}) {
623
+ const configured = parseTarget(url);
624
+ if (configured.scheme === "invalid") return emptyBlock(INVALID);
625
+ if (configured.scheme === "mailto" || configured.scheme === "other") return emptyBlock(NON_HTTP);
626
+ const deadlineAt = Math.min(clock.now() + deadlineMs, endsAt);
627
+ const chain = [];
628
+ const chainIdentity = [];
629
+ let contentType = null;
630
+ let nextHop = null;
631
+ let current = configured;
632
+ const finish = () => {
633
+ const last = chain.at(-1) ?? null;
634
+ const block = record(BLOCK_KEYS, {
635
+ chain,
636
+ chain_identity: chainIdentity,
637
+ final: last?.url ?? null,
638
+ final_query_sha256: last?.query_sha256 ?? null,
639
+ status: last?.status ?? null,
640
+ content_type: last ? contentType : null,
641
+ outcome: null,
642
+ next_hop: nextHop,
643
+ });
644
+ const observed = { scheme: configured.scheme, configured: configured.stored, configured_query_sha256: configured.querySha, configured_identity: configured.ids };
645
+ block.outcome = availabilityOutcome(observed, block, { maxRedirects });
646
+ // A chain the rules cannot read is kept as unanswered: never a pass.
647
+ return block.outcome ? block : emptyBlock(NETWORK);
648
+ };
649
+ const seen = new Set();
650
+ for (;;) {
651
+ const requestEndsAt = Math.min(clock.now() + timeoutMs, deadlineAt);
652
+ if (!(requestEndsAt > clock.now())) return finish();
653
+ let answer;
654
+ try {
655
+ answer = await headersOnly(fetchImpl, current.request, { endsAt: requestEndsAt, clock });
656
+ } catch {
657
+ return finish();
658
+ }
659
+ // A response whose processing ended at or after its deadline is not one
660
+ // the probe got in time.
661
+ if (!(clock.now() < requestEndsAt)) return finish();
662
+ chain.push(hopOf(current, answer.status));
663
+ chainIdentity.push(current.ids);
664
+ seen.add(requestKey(current.ids, current.querySha));
665
+ contentType = answer.contentType;
666
+ nextHop = null;
667
+ if (!REDIRECT_STATUSES.has(answer.status)) return finish();
668
+ const next = redirectTarget(answer.location, current.request);
669
+ if (!next) return finish();
670
+ nextHop = nextHopOf(next);
671
+ // A repeat is found in memory (exact raw URLs, query included), before
672
+ // the repeated URL is requested again.
673
+ if (seen.has(requestKey(next.ids, next.querySha))) return finish();
674
+ if (chain.length > maxRedirects) return finish();
675
+ current = next;
676
+ }
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // Result rules
681
+
682
+ const PRESENCE_COUNTS = Object.freeze(PRESENCE_KEYS.filter((key) => key !== "label_declared"));
683
+
684
+ // The identity fields shared by both checks, or null when they are not
685
+ // consistent with the scheme: a stored configured value is one the producer
686
+ // writes for its class, so classified again it is that class and that stored
687
+ // value, byte for byte.
688
+ function readIdentity(observation) {
689
+ if (!isPlainObject(observation)) return null;
690
+ const { check, field, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, scheme } = observation;
691
+ if (!CHECKS.includes(check) || !POLICY_LINK_FIELDS.includes(field) || !SCHEMES.includes(scheme)) return null;
692
+ const scoped = Object.hasOwn(observation, "run_scope");
693
+ if (!hasExactKeys(observation, observationKeys(check, scoped))) return null;
694
+ if (!validQuery(configuredQuery)) return null;
695
+ const runScope = scoped ? observation.run_scope : null;
696
+ if (scoped && runScope !== NOT_REQUESTED) return null;
697
+ if (scheme === "invalid") {
698
+ if (configured !== null || configuredQuery !== null || configuredIds !== null) return null;
699
+ } else {
700
+ const target = parseTarget(configured);
701
+ if (target.scheme !== scheme || target.stored !== configured) return null;
702
+ // Only an http(s) value keeps a query (as its hash) and the hashes of its
703
+ // raw URL, which agree with what its stored value shows.
704
+ if (scheme === "http" || scheme === "https") {
705
+ if (!validIdentity(configuredIds, configured)) return null;
706
+ } else if (configuredQuery !== null || configuredIds !== null) {
707
+ return null;
708
+ }
709
+ }
710
+ return { check, field, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, scheme, run_scope: runScope };
711
+ }
712
+
713
+ function readPresence(presence, scheme) {
714
+ if (!hasExactKeys(presence, PRESENCE_KEYS) || !PRESENCE_COUNTS.every((key) => isCount(presence[key])) || typeof presence.label_declared !== "boolean") return null;
715
+ const { pages_expected: expected, pages_read: read } = presence;
716
+ if (read > expected) return null;
717
+ if (["pages_with_match", "path_match_query_differs", "label_anchor_mismatch_pages"].some((key) => presence[key] > read)) return null;
718
+ if (presence.label_anchor_mismatch_pages > 0 && !presence.label_declared) return null;
719
+ if (!(scheme === "http" || scheme === "https") && presence.path_match_query_differs !== 0) return null;
720
+ if (read === 0 && presence.hint_anchor_elsewhere !== 0) return null;
721
+ return presence;
722
+ }
723
+
724
+ function decidePresence(identity, presence) {
725
+ if (identity.run_scope === NOT_REQUESTED) return { result: "excluded", reason_code: NOT_REQUESTED, read: false };
726
+ if (identity.scheme === "invalid") return { result: "review", reason_code: INVALID, read: false };
727
+ // Presence is decided only when every expected page had its anchors read.
728
+ if (!(presence.pages_expected > 0 && presence.pages_read === presence.pages_expected)) return { result: "unexercised", reason_code: PAGES_NOT_READ, read: true };
729
+ if (presence.label_anchor_mismatch_pages > 0) return { result: "warning", reason_code: "destination_mismatch", read: true };
730
+ if (presence.pages_with_match === 0 && presence.path_match_query_differs > 0) return { result: "warning", reason_code: "policy_link_query_differs", read: true };
731
+ if (presence.pages_with_match === 0) return { result: "warning", reason_code: "policy_link_absent", read: true };
732
+ if (presence.hint_anchor_elsewhere > 0) return { result: "review", reason_code: "possible_policy_link_mismatch", read: true };
733
+ return { result: "pass", reason_code: null, read: true };
734
+ }
735
+
736
+ function derivePresence(identity, observation) {
737
+ const presence = readPresence(observation.presence, identity.scheme);
738
+ if (!presence) return null;
739
+ const decided = decidePresence(identity, presence);
740
+ const coverage = decided.read
741
+ ? { observed: presence.pages_read, expected: presence.pages_expected, limits: decided.result === "unexercised" ? [PAGES_NOT_READ] : [] }
742
+ : { observed: 0, expected: null, limits: [] };
743
+ return {
744
+ decided,
745
+ coverage,
746
+ state: {
747
+ reason_code: decided.reason_code,
748
+ configured: identity.configured,
749
+ configured_query_sha256: identity.configured_query_sha256,
750
+ configured_identity: stateIdentity(identity.configured_identity),
751
+ pages_expected: presence.pages_expected,
752
+ pages_read: presence.pages_read,
753
+ pages_with_match: presence.pages_with_match,
754
+ path_match_query_differs: presence.path_match_query_differs,
755
+ label_anchor_mismatch_pages: presence.label_anchor_mismatch_pages,
756
+ hint_anchor_elsewhere: presence.hint_anchor_elsewhere,
757
+ },
758
+ };
759
+ }
760
+
761
+ function deriveAvailability(identity, observation) {
762
+ const block = observation.availability;
763
+ const outcome = availabilityOutcome(identity, block);
764
+ // The stored outcome stands only when the chain re-derives to it.
765
+ if (!outcome || block.outcome !== outcome) return null;
766
+ const result = AVAILABILITY_RESULTS[outcome];
767
+ const reasonCode = outcome === "pass" ? null : outcome;
768
+ const coverage = result === "excluded" || outcome === INVALID
769
+ ? { observed: 0, expected: null, limits: [] }
770
+ : outcome === NETWORK
771
+ ? { observed: 0, expected: 1, limits: [NETWORK] }
772
+ : { observed: 1, expected: 1, limits: [] };
773
+ return {
774
+ decided: { result, reason_code: reasonCode },
775
+ coverage,
776
+ state: {
777
+ reason_code: reasonCode,
778
+ configured: identity.configured,
779
+ configured_query_sha256: identity.configured_query_sha256,
780
+ configured_identity: stateIdentity(identity.configured_identity),
781
+ chain: block.chain.map(({ url, query_sha256: querySha, status }) => ({ url, query_sha256: querySha, status })),
782
+ chain_identity: block.chain_identity.map(stateIdentity),
783
+ final: block.final,
784
+ final_query_sha256: block.final_query_sha256,
785
+ status: block.status,
786
+ content_type: block.content_type,
787
+ next_hop_identity: stateIdentity(block.next_hop),
788
+ },
789
+ };
790
+ }
791
+
792
+ // Re-derives one row from its stored observation, or returns null when the
793
+ // observation is not one this rule can read (the reader then reports
794
+ // evidence_not_reproducible).
795
+ export function rederiveQcResult(observation) {
796
+ try {
797
+ const identity = readIdentity(observation);
798
+ if (!identity) return null;
799
+ const derived = identity.check === POLICY_PRESENCE_CHECK ? derivePresence(identity, observation) : deriveAvailability(identity, observation);
800
+ if (!derived) return null;
801
+ const { result, reason_code: reasonCode } = derived.decided;
802
+ return {
803
+ check: identity.check,
804
+ subject: { check: identity.check, page: PAGE, key: identity.field },
805
+ result,
806
+ reason_code: reasonCode,
807
+ members: [],
808
+ accept_eligible: result === "warning",
809
+ coverage: derived.coverage,
810
+ state: derived.state,
811
+ };
812
+ } catch {
813
+ return null;
814
+ }
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // Rows and assertions
819
+
820
+ export function policyLinkQcRow(observation, { measuredAt = new Date().toISOString() } = {}) {
821
+ const derived = rederiveQcResult(observation);
822
+ if (!derived) return null;
823
+ return buildQcResult({
824
+ check: derived.check,
825
+ leg: "qa",
826
+ subject: derived.subject,
827
+ result: derived.result,
828
+ reason_code: derived.reason_code,
829
+ state: derived.state,
830
+ observation,
831
+ members: derived.members,
832
+ accept_eligible: derived.accept_eligible,
833
+ coverage: derived.coverage,
834
+ measured_at: measuredAt,
835
+ });
836
+ }
837
+
838
+ // The row's verdict assertion, under the id qc.<check>:<field>. It names its
839
+ // row in evidence.qc.result_id, which is how readers pair them.
840
+ export function policyLinkQaAssertion(row) {
841
+ return { ...toQaAssertion(row, { family: FAMILY }), id: `qc.${row.check}:${row.subject.key}` };
842
+ }
843
+
844
+ // The stored observation of one row: the field's identity, the check's block
845
+ // and, for a run-scope row only, run_scope.
846
+ const observationKeys = (check, scoped) => [
847
+ "check", "field", "configured", "configured_query_sha256", "configured_identity", "scheme",
848
+ check === POLICY_PRESENCE_CHECK ? "presence" : "availability",
849
+ ...(scoped ? ["run_scope"] : []),
850
+ ];
851
+ const identityOf = (check, field, target) => ({
852
+ check,
853
+ field,
854
+ configured: target.scheme === "invalid" ? null : target.stored,
855
+ configured_query_sha256: target.scheme === "invalid" ? null : target.querySha,
856
+ configured_identity: target.ids ?? null,
857
+ scheme: target.scheme,
858
+ });
859
+
860
+ const emptyPresence = (pagesExpected) => record(PRESENCE_KEYS, {
861
+ pages_expected: pagesExpected,
862
+ pages_read: 0,
863
+ pages_with_match: 0,
864
+ path_match_query_differs: 0,
865
+ label_declared: false,
866
+ label_anchor_mismatch_pages: 0,
867
+ hint_anchor_elsewhere: 0,
868
+ });
869
+
870
+ // Both rows of one field. An observation the rules cannot read (which the
871
+ // producer never writes) is kept unread: unexercised, never a pass.
872
+ function fieldRows({ field, target, presence, availability, runScope = null }, measuredAt) {
873
+ const observationOf = (check, block) => record(observationKeys(check, Boolean(runScope)), { ...identityOf(check, field, target), ...block, run_scope: runScope });
874
+ const presenceObservation = observationOf(POLICY_PRESENCE_CHECK, { presence });
875
+ const availabilityObservation = observationOf(POLICY_AVAILABILITY_CHECK, { availability });
876
+ const presenceRow = policyLinkQcRow(presenceObservation, { measuredAt })
877
+ ?? policyLinkQcRow({ ...presenceObservation, presence: emptyPresence(presence.pages_expected) }, { measuredAt });
878
+ const availabilityRow = policyLinkQcRow(availabilityObservation, { measuredAt })
879
+ ?? policyLinkQcRow({ ...availabilityObservation, availability: emptyBlock(target.scheme === "http" || target.scheme === "https" ? NETWORK : availability.outcome) }, { measuredAt });
880
+ return [presenceRow, availabilityRow].filter(Boolean);
881
+ }
882
+
883
+ const topologyPageCount = (topologies) => (Array.isArray(topologies) ? topologies : [])
884
+ .reduce((total, topology) => total + (Array.isArray(topology?.pages) ? topology.pages.length : 0), 0);
885
+
886
+ // The run-scope rows of a QA run without browser checks: presence and
887
+ // availability excluded / browser_checks_not_requested for every configured
888
+ // field. Nothing is read and no request is made.
889
+ export function policyLinkNotRequestedRows(spec, topologies, { measuredAt = new Date().toISOString() } = {}) {
890
+ const pagesExpected = topologyPageCount(topologies);
891
+ return configuredPolicyFields(spec).flatMap(({ field, value }) => fieldRows({
892
+ field,
893
+ target: parseTarget(value),
894
+ presence: emptyPresence(pagesExpected),
895
+ availability: emptyBlock(NOT_REQUESTED),
896
+ runScope: NOT_REQUESTED,
897
+ }, measuredAt));
898
+ }
899
+
900
+ const isFieldList = (value) => Array.isArray(value) && value.every((field) => POLICY_LINK_FIELDS.includes(field));
901
+ const isReadAnchor = (anchor) => isPlainObject(anchor) && typeof anchor.href === "string" && isFieldList(anchor.label_fields) && isFieldList(anchor.hint_fields);
902
+
903
+ // The policy link leg of `qa run --browser`, run after the page checks.
904
+ // `pages` holds one {read, anchors} entry per page visit the page checks
905
+ // attempted (readPageAnchors). Each distinct configured http(s) URL is probed
906
+ // once, all at once, as one step of `budget` (the run budget the anchor reads
907
+ // drew on). Returns the rows and their verdict assertions.
908
+ export async function runPolicyLinkChecks({ spec, pages = [], fetchImpl, measuredAt = null, budget = createPolicyLinkBudget() } = {}) {
909
+ const fields = configuredPolicyFields(spec).map(({ field, value }) => ({ field, target: parseTarget(value) }));
910
+ if (!fields.length) return { rows: [], assertions: [] };
911
+ const visits = (Array.isArray(pages) ? pages : []).map((page) => (page?.read === true && Array.isArray(page.anchors) && page.anchors.every(isReadAnchor) ? { read: true, anchors: page.anchors } : { read: false, anchors: [] }));
912
+ // There are at most five policy fields, so at most POLICY_LINK_LIMITS.maxUrls
913
+ // distinct URLs.
914
+ const urls = [];
915
+ for (const { target } of fields) {
916
+ if ((target.scheme === "http" || target.scheme === "https") && !urls.includes(target.request) && urls.length < POLICY_LINK_LIMITS.maxUrls) urls.push(target.request);
917
+ }
918
+ // A probe the budget leaves no time for sends nothing and reads
919
+ // network_unavailable (contract §1.4 Cost).
920
+ const blocks = new Map(urls.length ? await budget.step((endsAt) => Promise.all(urls.map(async (url) => [url, await probePolicyUrl(url, {
921
+ ...(fetchImpl ? { fetchImpl } : {}),
922
+ clock: budget.clock,
923
+ endsAt,
924
+ })]))) : []);
925
+ const at = measuredAt ?? new Date().toISOString();
926
+ const rows = fields.flatMap(({ field, target }) => {
927
+ const availability = target.scheme === "http" || target.scheme === "https"
928
+ ? structuredClone(blocks.get(target.request) ?? emptyBlock(NETWORK))
929
+ : emptyBlock(target.scheme === "invalid" ? INVALID : NON_HTTP);
930
+ const presence = presenceCounts({ field, target, pages: visits, labels: target.scheme === "invalid" ? [] : declaredLabels(spec, target) });
931
+ return fieldRows({ field, target, presence, availability }, at);
932
+ });
933
+ return { rows, assertions: rows.map(policyLinkQaAssertion) };
934
+ }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // Anchor reader
938
+
939
+ // The page's rendered anchors are read in an isolated world of the main
940
+ // frame, reached through the Chrome DevTools Protocol: the page's own scripts
941
+ // share the DOM with that world but not its globals or prototypes, so a page
942
+ // that overrides querySelectorAll, href or textContent cannot change what is
943
+ // read. Each a[href] gives its href as the browser resolves it against
944
+ // document.baseURI. Its text is compared in that world, in full, and never
945
+ // leaves it: the read keeps the fields whose declared label equals the text
946
+ // (whitespace collapsed, as declaredLabels normalizes a label) and the fields
947
+ // whose wording hint it holds. A page whose read would be partial (too many
948
+ // anchors, or a text over MAX_TEXT) is not read.
949
+ const WORLD_NAME = "campaigns-os-policy-links";
950
+
951
+ function anchorReadExpression(maxAnchors, maxText, labels, hints) {
952
+ const anchors = document.querySelectorAll("a[href]");
953
+ if (anchors.length > maxAnchors) return { complete: false, anchors: [] };
954
+ const patterns = Object.keys(hints).map((field) => [field, new RegExp(hints[field], "i")]);
955
+ const read = [];
956
+ for (const anchor of Array.from(anchors)) {
957
+ let href = anchor.href;
958
+ if (typeof href !== "string") {
959
+ try {
960
+ href = new URL(anchor.getAttribute("href"), document.baseURI).href;
961
+ } catch {
962
+ href = String(anchor.getAttribute("href") ?? "");
963
+ }
964
+ }
965
+ const text = String(anchor.textContent ?? "");
966
+ if (text.length > maxText) return { complete: false, anchors: [] };
967
+ const normal = text.replace(/\s+/g, " ").trim();
968
+ read.push({
969
+ href,
970
+ label_fields: Object.keys(labels).filter((field) => labels[field].includes(normal)),
971
+ hint_fields: patterns.filter(([, pattern]) => pattern.test(text)).map(([field]) => field),
972
+ });
973
+ }
974
+ return { complete: true, anchors: read };
975
+ }
976
+
977
+ const anchorReadSource = (spec) => `(${anchorReadExpression})(${MAX_ANCHORS}, ${MAX_TEXT}, ${JSON.stringify(declaredLabelsByField(spec))}, ${JSON.stringify(HINT_SOURCES)})`;
978
+
979
+ // The anchors of the page's current document, or null when they could not be
980
+ // read in full (the page then counts as not read). The read is one step of
981
+ // `budget` and ends by the earlier of ANCHOR_READ_MS and the budget's end; it
982
+ // does not start once the budget is spent, and its anchors are kept only when
983
+ // it completed before its end. Never throws.
984
+ export async function readPageAnchors(context, browserPage, { spec = null, budget = createPolicyLinkBudget() } = {}) {
985
+ const { clock } = budget;
986
+ return budget.step(async (endsAt) => {
987
+ const readEndsAt = Math.min(clock.now() + ANCHOR_READ_MS, endsAt);
988
+ if (!(readEndsAt > clock.now())) return null;
989
+ let session = null;
990
+ let ended = false;
991
+ try {
992
+ const anchors = await untilClock(clock, readEndsAt, async () => {
993
+ const opened = await context.newCDPSession(browserPage);
994
+ if (ended) {
995
+ Promise.resolve().then(() => opened.detach()).catch(() => {});
996
+ return null;
997
+ }
998
+ session = opened;
999
+ const { frameTree } = await session.send("Page.getFrameTree");
1000
+ const { executionContextId } = await session.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, worldName: WORLD_NAME });
1001
+ const { result, exceptionDetails } = await session.send("Runtime.evaluate", { contextId: executionContextId, expression: anchorReadSource(spec), returnByValue: true });
1002
+ const value = exceptionDetails ? null : result?.value;
1003
+ if (!isPlainObject(value) || value.complete !== true || !Array.isArray(value.anchors)) return null;
1004
+ return value.anchors.every(isReadAnchor) ? value.anchors : null;
1005
+ }, { onTimeout: () => { ended = true; }, label: "readPageAnchors" });
1006
+ // A read that completed at or after its end (its own deadline or the
1007
+ // budget's end) is not one the read got in time, even when no timer
1008
+ // fired.
1009
+ return clock.now() < readEndsAt ? anchors : null;
1010
+ } catch {
1011
+ return null;
1012
+ } finally {
1013
+ ended = true;
1014
+ // The session is closed in the background, so a detach that stalls
1015
+ // adds no time.
1016
+ if (session) Promise.resolve().then(() => session.detach()).catch(() => {});
1017
+ }
1018
+ });
1019
+ }