clearotron 0.2.0 → 0.2.1

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 (43) hide show
  1. package/.env.example +52 -0
  2. package/INSTALL.md +4 -3
  3. package/README.md +2 -1
  4. package/bin/example.mjs +88 -27
  5. package/bin/onboard.mjs +40 -2
  6. package/bin/start.mjs +7 -0
  7. package/build-info.json +2 -2
  8. package/docs/RELEASES.md +6 -4
  9. package/docs/architecture/04-configuration-reference.md +1 -1
  10. package/driver/CHANGELOG.md +30 -0
  11. package/driver/ask-ledger.mjs +69 -1
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +41 -2
  14. package/driver/predelivery-lint.mjs +1 -1
  15. package/driver/publish/seed-pool.mjs +24 -9
  16. package/driver/record-carry.mjs +139 -0
  17. package/driver/reference-score.mjs +53 -3
  18. package/driver/reference-strip-signatures.mjs +68 -0
  19. package/driver/register-digest-record.mjs +31 -1
  20. package/driver/repairs.mjs +1 -1
  21. package/driver/suite-census.json +46 -16
  22. package/driver/verify.mjs +2 -2
  23. package/mcp-server/CHANGELOG.md +8 -0
  24. package/mcp-server/lib/whatif.mjs +10 -1
  25. package/mcp-server/package.json +1 -1
  26. package/package.json +1 -1
  27. package/portal-ui/package.json +1 -1
  28. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  29. package/providers/oauth-mcp-bridge/package.json +1 -1
  30. package/scripts/ai-page-render-check.mjs +2 -1
  31. package/scripts/clearances-render-check.mjs +2 -1
  32. package/scripts/env-audit.mjs +20 -0
  33. package/scripts/headless-page.mjs +225 -0
  34. package/scripts/home-render-check.mjs +2 -1
  35. package/scripts/mint-reference-strip-backlog.mjs +41 -0
  36. package/scripts/release-await-cut.mjs +95 -7
  37. package/scripts/release-version-pr-checks.mjs +25 -1
  38. package/scripts/report-frame-check.mjs +12 -0
  39. package/scripts/report-screenshot.mjs +62 -2
  40. package/scripts/revisit-render-check.mjs +3 -2
  41. package/scripts/score.mjs +14 -0
  42. package/shared/access-audience.mjs +215 -0
  43. package/shared/tracked-files.mjs +31 -0
@@ -19,6 +19,7 @@
19
19
  // receives, so it loads the brand webfonts the client's browser loads. Two scripts, two intents; do not
20
20
  // "fix" either to match the other.
21
21
  import { spawn } from "node:child_process";
22
+ import { assertPageLoaded, cjkCharsIn, cjkVerdict, fontsCovering } from "./headless-page.mjs"; // tracker issue 227 — did chrome open the report, or its own error page?
22
23
  import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
23
24
  import { tmpdir } from "node:os";
24
25
  import { join, dirname, resolve } from "node:path";
@@ -29,6 +30,9 @@ const OUT = resolve(process.argv[3] ?? join(ROOT, "docs", "assets", "example-rep
29
30
  const WIDTH = 1280, HEIGHT = 1040;
30
31
  // The frame starts here rather than at the top of the document — see the scroll block below.
31
32
  const ANCHOR = process.argv.includes("--anchor") ? process.argv[process.argv.indexOf("--anchor") + 1] : "h1";
33
+ // WHAT ONLY A REPORT CARRIES. `h1` is the ANCHOR — where to start the frame — and it is on every HTML
34
+ // page including Chrome's error interstitial, so it cannot also be the proof that this IS a report.
35
+ const MARKER = process.argv.includes("--marker") ? process.argv[process.argv.indexOf("--marker") + 1] : "[data-run-id]";
32
36
 
33
37
  const src = process.argv[2];
34
38
  if (!src) { console.error("usage: node scripts/report-screenshot.mjs <report.html> [out.png]"); process.exit(2); }
@@ -69,6 +73,34 @@ const sessionId = sess.sessionId;
69
73
  const cmd = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, sessionId, method, params })); });
70
74
 
71
75
  await cmd("Page.enable");
76
+
77
+ // ── IS THIS THE REPORT, OR CHROME'S OWN ERROR PAGE? (tracker issue 227) ──────────────────────────────
78
+ //
79
+ // Chrome is launched with the file URL as an ARGUMENT, so there is no navigation response to check and
80
+ // nothing here ever asked. When the file could not be read, Chrome showed `ERR_ACCESS_DENIED` — a page
81
+ // with an `<h1>` — the anchor below resolved against it, the clip was taken, and this exited 0 having
82
+ // written 38 KB of grey error page over the README's example frame. The successful run and the failed
83
+ // one differed in the log by an anchor offset and a font count, neither of which was asserted on.
84
+ //
85
+ // `existsSync` above does not cover it: a file that EXISTS and cannot be READ passes that check and
86
+ // fails in Chrome. So does a file that is readable and is not a report.
87
+ //
88
+ // THE MARKER IS THE RUN ID, not a tag. `h1` is what the error page has; `[data-run-id]` is what only a
89
+ // rendered report has, and naming it in the log is what makes the success line say what it certified
90
+ // rather than "an h1 was found".
91
+ const evaluate = async (expression) => {
92
+ const r = await cmd("Runtime.evaluate", { expression, returnByValue: true });
93
+ return r?.result?.result?.value;
94
+ };
95
+ const loaded = await assertPageLoaded(evaluate, {
96
+ expected: `file://${page}`,
97
+ marker: `document.querySelector(${JSON.stringify(MARKER)})`,
98
+ markerName: `a report element (${MARKER})`,
99
+ what: "report-screenshot",
100
+ });
101
+ if (!loaded.ok) { chrome.kill(); process.exit(1); }
102
+ const runId = await evaluate(`(document.querySelector(${JSON.stringify(MARKER)})?.getAttribute("data-run-id") ?? "")`);
103
+
72
104
  // The fonts are the point of allowing the network at all, so wait for them rather than for a fixed
73
105
  // sleep: a timer long enough on this box is a timer too short on a slower one, and the failure is a
74
106
  // screenshot in the wrong typeface that nobody notices until it is in the README.
@@ -91,11 +123,39 @@ if (typeof top !== "number") {
91
123
  console.error(`report-screenshot: no element matched ${JSON.stringify(ANCHOR)} — nothing to anchor the frame to.`);
92
124
  chrome.kill(); process.exit(1);
93
125
  }
126
+ // ── CAN THIS BOX DRAW WHAT THE PAGE SAYS? (tracker issue 227) ───────────────────────────────────────
127
+ //
128
+ // The default demo product's report carries the mark's native-script renderings — ベンクリ, ベンコリ,
129
+ // ヴェンコリ — and they are load-bearing: the verdict sentence reads "A live Japanese class 9
130
+ // registration reading ベンクリ covers measuring and testing instruments". With no CJK-capable font
131
+ // those render as empty boxes, twice in the captured frame, and nothing said so.
132
+ //
133
+ // The wait for `document.fonts.ready` above was written against exactly this class — "the failure is a
134
+ // screenshot in the wrong typeface that nobody notices until it is in the README" — and solved the
135
+ // TYPEFACE half. This is the WRITING-SYSTEM half, in the same script.
136
+ //
137
+ // REFUSES rather than warns. This writes an image that goes into the README by hand; a warning on a
138
+ // terminal nobody is reading when the file is already written is the shape that produced the defect
139
+ // above it. `--allow-tofu` is there for a reader who genuinely wants the frame anyway and has been told
140
+ // what is in it.
141
+ const pageText = await evaluate("document.body ? document.body.innerText : ''");
142
+ const cjkChars = cjkCharsIn(pageText);
143
+ const glyphs = cjkVerdict({ cjkChars, covering: fontsCovering("ja"),
144
+ sample: (String(pageText).match(/[\u3040-\u30ff\u4e00-\u9fff]{2,8}/u) ?? [])[0] ?? "" });
145
+ if (!glyphs.ok && !process.argv.includes("--allow-tofu")) {
146
+ console.error(`report-screenshot: ${glyphs.why}`);
147
+ console.error(" Pass --allow-tofu to capture it anyway, knowing the frame is missing those glyphs.");
148
+ chrome.kill(); process.exit(1);
149
+ }
150
+ if (!glyphs.ok) console.error(`report-screenshot: WARNING — ${glyphs.why} Capturing anyway (--allow-tofu).`);
151
+
94
152
  const y = Math.max(top - 56, 0); // a little air above the title, so the page does not read as cropped
95
153
  const shot = await cmd("Page.captureScreenshot", { format: "png", captureBeyondViewport: true,
96
154
  clip: { x: 0, y, width: WIDTH, height: HEIGHT, scale: 1 } });
97
155
  if (!shot?.result?.data) { console.error(`report-screenshot: chrome returned no image. ${JSON.stringify(shot).slice(0, 300)}`); chrome.kill(); process.exit(1); }
98
156
  writeFileSync(OUT, Buffer.from(shot.result.data, "base64"));
99
- const loaded = await cmd("Runtime.evaluate", { expression: "document.fonts.size + ':' + [...document.fonts].filter(f=>f.status==='loaded').length", returnByValue: true });
157
+ const fonts = await cmd("Runtime.evaluate", { expression: "document.fonts.size + ':' + [...document.fonts].filter(f=>f.status==='loaded').length", returnByValue: true });
100
158
  chrome.kill();
101
- console.log(`report-screenshot: wrote ${OUT} (${WIDTH}x${HEIGHT}, anchor ${JSON.stringify(ANCHOR)} at y=${Math.round(y)}, fonts ${loaded?.result?.result?.value ?? "?"})`);
159
+ // NAMES WHAT IT CERTIFIED. "an h1 was found" is true of the error page this used to photograph; the
160
+ // run id is read out of the document and is the thing a reader can check against the report they meant.
161
+ console.log(`report-screenshot: wrote ${OUT} of run ${runId || "(no run id in the page)"} (${WIDTH}x${HEIGHT}, anchor ${JSON.stringify(ANCHOR)} at y=${Math.round(y)}, fonts ${fonts?.result?.result?.value ?? "?"}, ${glyphs.kind})`);
@@ -59,6 +59,7 @@
59
59
  // MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one. Run it
60
60
  // as a user with `ulimit -v unlimited`. The dbus/UPower errors Chrome prints on a headless box are noise.
61
61
 
62
+ import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
62
63
  import { createServer } from 'node:http'
63
64
  import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
64
65
  import { join, extname, dirname } from 'node:path'
@@ -284,7 +285,7 @@ const record = {}
284
285
 
285
286
  // The shell, once. Everything after this is client-side.
286
287
  epoch = 'boot'
287
- await cmd('Page.navigate', { url: `${origin}/portal/home` })
288
+ await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
288
289
  if (!(await settle())) say(false, 'the shell never went quiet within 12s — it is still requesting')
289
290
  const bootPath = await where()
290
291
  say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
@@ -375,7 +376,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
375
376
  // URL and the SECOND is the Back button, which is the revisit a reader actually performs and the one that
376
377
  // runs AppShell's popstate listener rather than its click funnel.
377
378
  epoch = 'result:1'
378
- await cmd('Page.navigate', { url: `${origin}/portal/result/${RUN_ID}` })
379
+ await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
379
380
  const rq1 = await settle()
380
381
  const rp1 = await where()
381
382
  const rt1 = await screenText()
package/scripts/score.mjs CHANGED
@@ -646,6 +646,20 @@ function print(id, ref, run, s, delta, refPath) {
646
646
  for (const c of B.collisions)
647
647
  console.log(` · ${c.owner}: reference "${c.entry}" is ${c.bucket}, surfaced "${c.noise}" is noise`);
648
648
  }
649
+ // ── SAME PROPRIETOR, DIFFERENT MARKS — REPORTED, NEVER SUPPRESSING (tracker issue 249) ───────────
650
+ //
651
+ // These used to print under the line above, which tells the reader not to read the recall numbers.
652
+ // One large filer anywhere in a matter therefore suppressed a whole run's measurement, and did: R2's
653
+ // real 88% → 63% recall movement went unquoted on the regression issue because `Novartis AG` held one
654
+ // withheld reference mark and one surfaced non-reference mark. Those two rows are both true.
655
+ //
656
+ // Kept visible because a reader may still want the pairing, and deliberately WITHOUT a verdict: this
657
+ // is an observation about the matter, not a fault in the scorer.
658
+ if (B.ownerEchoes?.length) {
659
+ console.log(`\n same-proprietor rows (${B.ownerEchoes.length}) — NOT a collision, and the recall numbers above stand:`);
660
+ for (const c of B.ownerEchoes)
661
+ console.log(` · ${c.owner}: reference "${c.entry}" is ${c.bucket}, surfaced "${c.noise}" is noise (different marks)`);
662
+ }
649
663
 
650
664
  for (const [name, label] of [["withheld", "WITHHELD — in this run's own records, absent from its findings"],
651
665
  ["lost", "LOST — never retrieved"],
@@ -71,3 +71,218 @@ export function audienceLabel(aud) {
71
71
  if (!list.length) return "(none)";
72
72
  return list.map((a) => `${String(a).slice(0, 8)}…`).join(",");
73
73
  }
74
+
75
+ // ── THE AUDIENCE THE EDGE ISSUES, AS OPPOSED TO THE ONE THIS INSTALL EXPECTS (tracker issue 241) ───
76
+ //
77
+ // Everything above reads LOCAL configuration. What follows reads the EDGE, so `doctor` can compare the
78
+ // two — the half of the recreation trap with no symptom of its own. Deleting and recreating a
79
+ // Cloudflare Access application changes the audience; the existing warning fires on the CHALLENGE being
80
+ // wrong, so on a box where somebody recreated the application and then fixed the sign-in, every layer
81
+ // reads healthy and the stale audience stays invisible until a real request is rejected.
82
+ //
83
+ // ── WHERE THE AUDIENCE IS, AND WHERE IT IS NOT ───────────────────────────────────────────────────
84
+ //
85
+ // Access does not publish it in RFC 8414 discovery metadata. Two investigations looked there, correctly
86
+ // found nothing, and one concluded the route did not exist. It is in the REDIRECT handed to an
87
+ // unauthenticated caller: the login URL carries a `kid` parameter and a `meta` JWT naming the same value.
88
+ //
89
+ // ── THIS DECODES; IT DOES NOT VERIFY, AND THAT IS DELIBERATE ─────────────────────────────────────
90
+ //
91
+ // The meta token is public routing information served to a caller with no credential — its own
92
+ // `auth_status` reads `NONE`. It is not a bearer token and nothing here trusts it: the only thing taken
93
+ // from it is a string, which is then compared against local configuration. Verifying it would mean
94
+ // dragging in the credential path and `jose` with it — which this file's header explains at length that
95
+ // it must not do — to authenticate a document that authenticates nobody, and would invite a later
96
+ // reader to believe a valid signature here meant a request was authorised. It does not.
97
+ //
98
+ // ── AND THE READ IS CROSS-CHECKED AGAINST ITSELF ─────────────────────────────────────────────────
99
+ //
100
+ // The same value arrives two independent ways in one response: the `kid` parameter and the token's own
101
+ // `aud`. Using one when two are there throws away the only free integrity check on the read, and it is
102
+ // the check that would catch this code keying on a field Cloudflare later moves. When they disagree
103
+ // this reports the disagreement rather than picking a winner.
104
+ /** The path that says a hostname is Access-fronted. */
105
+ export const ACCESS_LOGIN_PATH = "/cdn-cgi/access/login/";
106
+
107
+ /** Base64url → utf8, with no exception escaping to the caller as a silent empty string. */
108
+ function decodeSegment(seg) {
109
+ const raw = Buffer.from(String(seg), "base64url").toString("utf8");
110
+ if (!raw) throw new Error("the segment decoded to nothing");
111
+ return raw;
112
+ }
113
+
114
+ /**
115
+ * The audience, read out of the location an unauthenticated request was redirected to.
116
+ *
117
+ * `kind` is the whole answer and every branch has one, because the failures here are NOT
118
+ * interchangeable and the caller must be able to tell them apart:
119
+ *
120
+ * unreachable the edge could not be asked. A could-not-look, never agreement.
121
+ * not-fronted the hostname answered without an Access challenge. Its own verdict, and the
122
+ * single most important one: this check must not be able to SUCCEED by failing to
123
+ * find an edge, which is the exact shape of the defect it exists to catch.
124
+ * unreadable there is a challenge, and the audience could not be read out of it. Also a
125
+ * could-not-look — the shape may have moved, and a moved shape is not agreement.
126
+ * disagree `kid` and the token's `aud` name different audiences.
127
+ * read one audience, agreed by both sources.
128
+ */
129
+ // The marker an Access-fronted API path puts in `WWW-Authenticate`. Keyed on the RESOURCE-METADATA path
130
+ // rather than on "Bearer", which any OAuth resource anywhere would send — this names Cloudflare Access
131
+ // specifically, and it is the string the real door returns.
132
+ const ACCESS_RESOURCE_RE = /cloudflare-access-protected-resource/i;
133
+
134
+ export function readAudience({ location = "", status = null, error = null, wwwAuthenticate = "", viaEdge = false } = {}) {
135
+ if (error) return { kind: "unreachable", why: String(error?.message ?? error).slice(0, 200) };
136
+ // ── THREE DOORS, NOT ONE (tracker issue 251) ────────────────────────────────────────────────────
137
+ //
138
+ // This returned `not-fronted` for every response with no redirect, and measured against production's
139
+ // four configured hostnames that one label covered three materially different states:
140
+ //
141
+ // trademark.cordillera.ch 302 redirect present → audience read, kid agrees
142
+ // mcp.cordillera.ch/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
143
+ // clients-mcp.cordillera.ch/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
144
+ // agent-mcp.cordillera.ch/mcp 502 no redirect → not-fronted ← an origin fault
145
+ //
146
+ // None of these was a false pass — every one returned ok:false, which is the property that matters
147
+ // most and is untouched. It was a WRONG DIAGNOSIS on a safe failure, and the cost is a reader's hour
148
+ // spent hunting an Access application that exists and is working.
149
+ if (!location) {
150
+ // FRONTED, API PATH. Managed OAuth on an MCP path answers RFC 9728 style instead of redirecting a
151
+ // browser: a 401 whose `WWW-Authenticate` names a Cloudflare Access protected-resource document.
152
+ // Measured on the real door — that document resolves 200, says `protected: true`, and carries NO
153
+ // audience. So this is a stated could-not-look about the audience ON A DOOR THAT IS CONFIRMED
154
+ // PRESENT, which is neither a pass nor "no door".
155
+ if (ACCESS_RESOURCE_RE.test(String(wwwAuthenticate ?? ""))) {
156
+ return { kind: "fronted-api",
157
+ why: `the hostname answered ${status ?? "401"} as an OAuth-protected resource, naming a Cloudflare `
158
+ + "Access protected-resource document — it is fronted, and this route carries no audience to read" };
159
+ }
160
+ // THE EDGE ANSWERED AND THE ORIGIN DID NOT. A 5xx carrying `cf-ray` is Cloudflare reporting that it
161
+ // reached the door and the thing behind it did not answer. Calling that "nothing is fronting this
162
+ // hostname" is exactly backwards.
163
+ if (viaEdge && Number(status) >= 500) {
164
+ return { kind: "origin-failed",
165
+ why: `the edge answered ${status} for this hostname — Cloudflare reached the door and the origin `
166
+ + "behind it did not answer, so the audience could not be asked for" };
167
+ }
168
+ return { kind: "not-fronted", why: `the hostname answered ${status ?? "with no redirect"} and sent no Access challenge` };
169
+ }
170
+ let url;
171
+ try { url = new URL(location); } catch {
172
+ return { kind: "unreadable", why: `the redirect target is not a URL: ${String(location).slice(0, 120)}` };
173
+ }
174
+ if (!url.pathname.includes(ACCESS_LOGIN_PATH)) {
175
+ return { kind: "not-fronted", why: `the redirect goes to ${url.origin}${url.pathname}, which is not an Access login` };
176
+ }
177
+ const kid = url.searchParams.get("kid") || "";
178
+ const token = url.searchParams.get("meta") || "";
179
+ if (!token) return { kind: "unreadable", why: "the Access login carries no `meta` token", kid };
180
+ const parts = token.split(".");
181
+ if (parts.length < 2) return { kind: "unreadable", why: "the `meta` token is not a JWT", kid };
182
+ let payload;
183
+ try { payload = JSON.parse(decodeSegment(parts[1])); } catch (e) {
184
+ return { kind: "unreadable", why: `the \`meta\` token's payload could not be read (${String(e?.message ?? e).slice(0, 80)})`, kid };
185
+ }
186
+ const aud = typeof payload?.aud === "string" ? payload.aud : "";
187
+ if (!aud) return { kind: "unreadable", why: "the `meta` token's payload names no `aud`", kid };
188
+ // BOTH SOURCES, OR NEITHER IS TRUSTED. A `kid` that is absent is itself a change in the shape.
189
+ if (!kid) return { kind: "unreadable", why: "the Access login carries no `kid` to cross-check the token against", aud };
190
+ if (kid !== aud) {
191
+ return { kind: "disagree", aud, kid, hostname: payload?.hostname ?? "", why: "the two sources name different audiences" };
192
+ }
193
+ return { kind: "read", aud, kid, hostname: payload?.hostname ?? "" };
194
+ }
195
+
196
+ /**
197
+ * What to tell the reader, given what was configured and what the edge said.
198
+ *
199
+ * NOTHING HERE RETURNS "fine" FOR A QUESTION IT COULD NOT ASK. Each unhappy kind carries its own
200
+ * sentence naming what was not established, because "no problem reported" and "no problem" are the two
201
+ * things this whole check exists to keep apart.
202
+ */
203
+ export function audienceVerdict({ configured = "", read = {} } = {}) {
204
+ // MEMBERSHIP, NOT EQUALITY, and this file already learned that once. A deployment runs one Access
205
+ // application PER AUDIENCE and `CLEAROTRON_OIDC_AUDIENCE` may name several; the edge issues ONE for
206
+ // the hostname being asked. `===` against a configured list reports every correct multi-application
207
+ // deployment as a mismatch — the same mistake `audienceIncludes` above exists to have stopped making.
208
+ const cfg = accessAudience(configured);
209
+ const cfgList = Array.isArray(cfg) ? cfg : (cfg ? [cfg] : []);
210
+ switch (read.kind) {
211
+ case "unreachable":
212
+ return { kind: "could-not-look", ok: false,
213
+ message: `the edge could not be reached, so the audience it issues is unknown (${read.why}). `
214
+ + "This is a failure to look, not agreement." };
215
+ case "not-fronted":
216
+ return { kind: "not-fronted", ok: false,
217
+ message: `nothing is fronting this hostname with Access — ${read.why}. A door that is not there `
218
+ + "cannot be the one this install is configured for, so this is a finding rather than a pass." };
219
+ // NEITHER A PASS NOR "NO DOOR". The door is confirmed present and the audience is not readable by
220
+ // this route — that is a stated could-not-look, and it must not read as either of the two things it
221
+ // is not. Whether an API path's audience is reachable at all is open; nothing in the
222
+ // protected-resource document carries it.
223
+ case "fronted-api":
224
+ return { kind: "fronted-api", ok: false,
225
+ message: `this hostname IS fronted by Access — ${read.why}. The audience could not be compared, `
226
+ + "which is a could-not-look about the audience and not a finding about the door." };
227
+ case "origin-failed":
228
+ return { kind: "could-not-look", ok: false,
229
+ message: `the edge is up and the origin behind it is not — ${read.why}. Nothing here says anything `
230
+ + "about the audience; it says the service is down." };
231
+ case "unreadable":
232
+ return { kind: "could-not-look", ok: false,
233
+ message: `the Access challenge is there but its audience could not be read (${read.why}). `
234
+ + "The shape may have moved; a shape that moved is not agreement." };
235
+ case "disagree":
236
+ return { kind: "sources-disagree", ok: false,
237
+ message: `the edge's own two answers disagree: the login's \`kid\` says ${read.kid} and the `
238
+ + `\`meta\` token says ${read.aud}. Neither is trustworthy on its own, so nothing is compared.` };
239
+ case "read": {
240
+ if (!cfgList.length) {
241
+ return { kind: "not-configured", ok: false,
242
+ message: `the edge issues ${read.aud} for this hostname, and CLEAROTRON_OIDC_AUDIENCE is unset, `
243
+ + "so every request it fronts is checked against nothing." };
244
+ }
245
+ if (!audienceIncludes(cfg, read.aud)) {
246
+ // BOTH VALUES, NEVER "audience mismatch". A reader who is told only that two things differ has
247
+ // to go and find both of them, and the whole point of asking the edge was to hand them over.
248
+ return { kind: "mismatch", ok: false,
249
+ message: `the audience does not match. This install is configured with ${cfgList.join(", ")}, and the edge `
250
+ + `issues ${read.aud} for ${read.hostname || "this hostname"}. Requests will be rejected `
251
+ + "until they agree — recreating an Access application changes the audience." };
252
+ }
253
+ return { kind: "agree", ok: true,
254
+ message: `the audience matches the one the edge issues for ${read.hostname || "this hostname"} (${read.aud})` };
255
+ }
256
+ default:
257
+ return { kind: "could-not-look", ok: false,
258
+ message: `the audience check returned no verdict it knows (${JSON.stringify(read.kind)}), which is `
259
+ + "a fault in this check rather than a finding about the door." };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * One unauthenticated request, with a bound on how long it may take.
265
+ *
266
+ * `redirect: "manual"` because the REDIRECT is the answer — following it fetches Cloudflare's login page
267
+ * and throws away the only thing being read. Failure is returned rather than thrown: the caller's job is
268
+ * to say "could not look", and an exception here would reach `doctor` as a crash instead.
269
+ */
270
+ export async function probeAudience({ url, fetchImpl = fetch, timeoutMs = 5000, signalFor = null } = {}) {
271
+ if (!url) return { error: new Error("no hostname is configured to ask") };
272
+ const ac = signalFor ? null : new AbortController();
273
+ const t = ac ? setTimeout(() => ac.abort(new Error(`no answer within ${timeoutMs}ms`)), timeoutMs) : null;
274
+ try {
275
+ const res = await fetchImpl(url, { redirect: "manual", signal: signalFor ?? ac.signal });
276
+ // TWO MORE HEADERS, AND THEY ARE THE WHOLE OF tracker issue 251. Without them every non-redirecting
277
+ // answer collapses to "no redirect", and three different doors read as one. `www-authenticate` is
278
+ // how an Access-fronted API path announces itself; `cf-ray` is how a 5xx says the EDGE answered and
279
+ // the origin behind it did not. Both are on the response already — nothing extra is fetched.
280
+ return { status: res.status, location: res.headers?.get?.("location") ?? "",
281
+ wwwAuthenticate: res.headers?.get?.("www-authenticate") ?? "",
282
+ viaEdge: Boolean(res.headers?.get?.("cf-ray")) };
283
+ } catch (e) {
284
+ return { error: e };
285
+ } finally {
286
+ if (t) clearTimeout(t);
287
+ }
288
+ }
@@ -114,5 +114,36 @@ export function grepTrackedFiles(guard, { root, args }) {
114
114
  return files;
115
115
  }
116
116
 
117
+ /**
118
+ * The tracked corpus WITH ITS INDEX MODES, or null when this tree has no checkout to read it from.
119
+ *
120
+ * WHY THIS IS HERE RATHER THAN LEFT TO ITS ONE CALLER (tracker issue 235). The executable-bits guard
121
+ * needs `ls-files -s`, which carries the mode, and `trackedFiles` above deliberately returns paths
122
+ * only. So that guard called the helper for its skip contract and then spawned git a SECOND time,
123
+ * raw, for the modes — correct, because the raw call sits behind the helper's null check, but
124
+ * indistinguishable from an unguarded one to any reader or check looking at the call sites.
125
+ *
126
+ * The invariant is "corpus enumeration degrades to a stated skip", and `-s` is corpus enumeration.
127
+ * Extending the helper keeps that true by construction instead of by the ordering of two calls.
128
+ *
129
+ * @returns {Map<string,string>|null} path -> index mode ("100644", "100755", …) — null means SKIP
130
+ */
131
+ export function trackedIndexModes(guard, { root }) {
132
+ const r = run(root, ["ls-files", "-s"]);
133
+ if (r.status !== 0) {
134
+ say(`${GUARD_SKIPPED_MARKER} ${guard} — ${reasonFrom(r)}; ${NO_CORPUS_REMEDY}`);
135
+ return null;
136
+ }
137
+ const out = new Map();
138
+ for (const line of r.stdout.split("\n")) {
139
+ if (!line.trim()) continue;
140
+ // `<mode> <object> <stage>\t<path>` — the tab is the only safe separator, a path may contain spaces.
141
+ const [meta, path] = line.split("\t");
142
+ if (path) out.set(path, meta.split(" ")[0]);
143
+ }
144
+ say(`${GUARD_OK_MARKER} — ${guard}: ${out.size} tracked file(s) with modes`);
145
+ return out;
146
+ }
147
+
117
148
  /** The one-line reason a guard hands to `t.skip()`, so the TAP line says it too. */
118
149
  export const skipReason = (guard) => `${guard}: not a git checkout — ${NO_CORPUS_REMEDY}`;