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.
- package/.env.example +52 -0
- package/INSTALL.md +4 -3
- package/README.md +2 -1
- package/bin/example.mjs +88 -27
- package/bin/onboard.mjs +40 -2
- package/bin/start.mjs +7 -0
- package/build-info.json +2 -2
- package/docs/RELEASES.md +6 -4
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +30 -0
- package/driver/ask-ledger.mjs +69 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +41 -2
- package/driver/predelivery-lint.mjs +1 -1
- package/driver/publish/seed-pool.mjs +24 -9
- package/driver/record-carry.mjs +139 -0
- package/driver/reference-score.mjs +53 -3
- package/driver/reference-strip-signatures.mjs +68 -0
- package/driver/register-digest-record.mjs +31 -1
- package/driver/repairs.mjs +1 -1
- package/driver/suite-census.json +46 -16
- package/driver/verify.mjs +2 -2
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/whatif.mjs +10 -1
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ai-page-render-check.mjs +2 -1
- package/scripts/clearances-render-check.mjs +2 -1
- package/scripts/env-audit.mjs +20 -0
- package/scripts/headless-page.mjs +225 -0
- package/scripts/home-render-check.mjs +2 -1
- package/scripts/mint-reference-strip-backlog.mjs +41 -0
- package/scripts/release-await-cut.mjs +95 -7
- package/scripts/release-version-pr-checks.mjs +25 -1
- package/scripts/report-frame-check.mjs +12 -0
- package/scripts/report-screenshot.mjs +62 -2
- package/scripts/revisit-render-check.mjs +3 -2
- package/scripts/score.mjs +14 -0
- package/shared/access-audience.mjs +215 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/shared/tracked-files.mjs
CHANGED
|
@@ -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}`;
|