@redaktyn/cli 0.0.0-stage → 1.1.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.
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redaktyn-verify — check Redaktyn's claims without taking Redaktyn's word for any of them.
4
+ *
5
+ * Zero dependencies, Node built-ins only, one file. That is a requirement, not
6
+ * a preference: a verifier you have to trust is not a verifier. This is meant
7
+ * to be read end to end before it is run, and it is short enough to do that.
8
+ *
9
+ * redaktyn-verify recompute derive a digest from your secret; compare to the server's
10
+ * redaktyn-verify build hash what the server served you; compare to the signed manifest
11
+ * redaktyn-verify pin record the release log head as you see it today
12
+ * redaktyn-verify check confirm the log still reproduces the head you pinned
13
+ * redaktyn-verify egress assert a captured HAR contains no field the API does not accept
14
+ *
15
+ * WHAT THESE DO NOT PROVE, stated up front so nothing here is oversold:
16
+ *
17
+ * - Nothing establishes what the server does internally. `recompute` shows the
18
+ * stored value is a one-way function of your secret; it cannot show the
19
+ * server did not also write the plaintext somewhere it never sends back.
20
+ * Only source access, an audit, or self-hosting settles that.
21
+ * - `build` establishes that the bytes you were served match a signed manifest.
22
+ * Whether that manifest describes the published source is what
23
+ * `scripts/release/verify-reproducible.mjs` is for — build it yourself.
24
+ * - `check` catches a rewrite of the log served to you. A server can serve a
25
+ * different, internally consistent log to someone else, and nothing here
26
+ * would notice. Catching that needs a witness Redaktyn does not yet have.
27
+ *
28
+ * Requires Node 18+.
29
+ */
30
+
31
+ import { createHash, createHmac, pbkdf2 as _pbkdf2, verify as edVerify, createPublicKey } from "node:crypto";
32
+ import { promisify } from "node:util";
33
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { homedir } from "node:os";
36
+ import { createInterface } from "node:readline";
37
+
38
+ const pbkdf2 = promisify(_pbkdf2);
39
+
40
+ /* ═══════════════════════════════════════════════════════════════════════════
41
+ The digest pipeline. Must match @redaktyn/shared/secret-digest.ts exactly.
42
+ Reimplemented rather than imported: a verifier that pulls in the code it is
43
+ checking verifies nothing.
44
+ ═══════════════════════════════════════════════════════════════════════════ */
45
+
46
+ const PBKDF2_ITERATIONS = 600_000;
47
+ const INVISIBLE = /[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g;
48
+
49
+ function normalize(raw) {
50
+ return raw
51
+ .normalize("NFC")
52
+ .replace(/\r\n/g, "\n")
53
+ .replace(/[\r\u2028\u2029]/g, "\n")
54
+ .replace(INVISIBLE, "")
55
+ .trim();
56
+ }
57
+
58
+ function foldUuid(s) {
59
+ if (
60
+ s.length === 36 && s[8] === "-" && s[13] === "-" && s[18] === "-" && s[23] === "-" &&
61
+ /^[0-9a-fA-F]{32}$/.test(s.replace(/-/g, ""))
62
+ ) return s.toLowerCase();
63
+ return s;
64
+ }
65
+
66
+ async function deriveKey(passphrase, orgId) {
67
+ const salt = createHash("sha256").update(orgId, "utf8").digest();
68
+ return pbkdf2(passphrase, salt, PBKDF2_ITERATIONS, 32, "sha256");
69
+ }
70
+
71
+ function hmac(text, key) {
72
+ return createHmac("sha256", key).update(text, "utf8").digest("hex");
73
+ }
74
+
75
+ function digests(secret, key) {
76
+ const collapsed = foldUuid(normalize(secret).replace(/\s/g, ""));
77
+ return {
78
+ hmacHex: hmac(foldUuid(normalize(secret)), key),
79
+ hmacHexStripped: hmac(collapsed, key),
80
+ hmacHexLower: hmac(collapsed.toLowerCase(), key),
81
+ };
82
+ }
83
+
84
+ /* ═══════════════════════════════════════════════════════════════════════════
85
+ Signature and log verification
86
+ ═══════════════════════════════════════════════════════════════════════════ */
87
+
88
+ const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
89
+
90
+ function verifySignature(bytes, signatureB64, publicKeyB64) {
91
+ const raw = Buffer.from(publicKeyB64, "base64");
92
+ if (raw.length !== 32) return false;
93
+ const key = createPublicKey({
94
+ key: Buffer.concat([SPKI_PREFIX, raw]),
95
+ format: "der",
96
+ type: "spki",
97
+ });
98
+ return edVerify(null, bytes, key, Buffer.from(signatureB64, "base64"));
99
+ }
100
+
101
+ /** Canonical JSON — key order pinned, so hashes are reproducible. */
102
+ function canonicalJson(v) {
103
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
104
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
105
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
106
+ }
107
+
108
+ const GENESIS = "0".repeat(64);
109
+
110
+ function verifyChain(entries) {
111
+ let prev = GENESIS;
112
+ entries.forEach((entry, i) => {
113
+ if (entry.seq !== i + 1) fail(`Release log entry ${i + 1} is out of order (seq ${entry.seq}).`);
114
+ if (entry.prevHead !== prev) fail(`Release log entry ${entry.seq} does not follow ${entry.seq - 1}.`);
115
+ const { head, ...body } = entry;
116
+ const recomputed = createHash("sha256").update(canonicalJson(body), "utf8").digest("hex");
117
+ if (recomputed !== head) fail(`Release log entry ${entry.seq} was modified after it was written.`);
118
+ prev = head;
119
+ });
120
+ return prev;
121
+ }
122
+
123
+ /* ═══════════════════════════════════════════════════════════════════════════
124
+ Plumbing
125
+ ═══════════════════════════════════════════════════════════════════════════ */
126
+
127
+ const NO_COLOR = process.env["NO_COLOR"] != null || process.argv.includes("--no-color");
128
+ const c = {
129
+ b: NO_COLOR ? "" : "\x1b[1m", d: NO_COLOR ? "" : "\x1b[2m", r: NO_COLOR ? "" : "\x1b[0m",
130
+ g: NO_COLOR ? "" : "\x1b[32m", y: NO_COLOR ? "" : "\x1b[33m", red: NO_COLOR ? "" : "\x1b[31m",
131
+ };
132
+
133
+ const ok = (m) => console.log(` ${c.g}✓${c.r} ${m}`);
134
+ const bad = (m) => console.log(` ${c.red}✗${c.r} ${m}`);
135
+ const warn = (m) => console.log(` ${c.y}!${c.r} ${m}`);
136
+ const note = (m) => console.log(` ${c.d}${m}${c.r}`);
137
+
138
+ function fail(message) {
139
+ console.error(`\n${c.red}${message}${c.r}`);
140
+ process.exit(1);
141
+ }
142
+
143
+ function arg(name, fallback = null) {
144
+ const i = process.argv.indexOf(name);
145
+ return i >= 0 && process.argv[i + 1] != null ? process.argv[i + 1] : fallback;
146
+ }
147
+
148
+ function requireArg(name) {
149
+ const v = arg(name);
150
+ if (v == null) fail(`Missing required argument ${name}. Run with --help.`);
151
+ return v;
152
+ }
153
+
154
+ /** Base URL of the Redaktyn deployment being checked. */
155
+ function serverBase() {
156
+ return (arg("--server") ?? process.env["REDAKTYN_SERVER"] ?? "").replace(/\/+$/, "") ||
157
+ fail("Set --server https://your-redaktyn-host (or REDAKTYN_SERVER).");
158
+ }
159
+
160
+ async function getJson(url, headers = {}) {
161
+ const res = await fetch(url, { headers });
162
+ if (!res.ok) fail(`GET ${url} → HTTP ${res.status}`);
163
+ return res.json();
164
+ }
165
+
166
+ async function getBytes(url) {
167
+ const res = await fetch(url);
168
+ if (!res.ok) fail(`GET ${url} → HTTP ${res.status}`);
169
+ return Buffer.from(await res.arrayBuffer());
170
+ }
171
+
172
+ function promptHidden(label) {
173
+ return new Promise((resolve) => {
174
+ process.stdout.write(` ${label} `);
175
+ if (!process.stdin.isTTY) {
176
+ const rl = createInterface({ input: process.stdin });
177
+ rl.once("line", (l) => { rl.close(); resolve(l); });
178
+ return;
179
+ }
180
+ let value = "";
181
+ process.stdin.setRawMode(true);
182
+ process.stdin.resume();
183
+ process.stdin.setEncoding("utf8");
184
+ const onData = (ch) => {
185
+ if (ch === "\u0003") process.exit(130);
186
+ if (ch === "\r" || ch === "\n") {
187
+ process.stdin.removeListener("data", onData);
188
+ process.stdin.setRawMode(false);
189
+ process.stdin.pause();
190
+ process.stdout.write("\n");
191
+ resolve(value);
192
+ } else if (ch === "\u007f" || ch === "\b") {
193
+ if (value) { value = value.slice(0, -1); process.stdout.write("\b \b"); }
194
+ } else {
195
+ value += ch;
196
+ process.stdout.write("•");
197
+ }
198
+ };
199
+ process.stdin.on("data", onData);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Where pinned log heads are recorded. Overridable so the pin can live beside
205
+ * a CI job or a per-customer profile rather than only in one home directory —
206
+ * a pin that cannot be checked into the place doing the checking never gets
207
+ * checked.
208
+ */
209
+ const PIN_PATH =
210
+ process.env["REDAKTYN_VERIFY_PIN"] ?? join(homedir(), ".redaktyn-verify-pin.json");
211
+
212
+ /* ═══════════════════════════════════════════════════════════════════════════
213
+ recompute
214
+ ═══════════════════════════════════════════════════════════════════════════ */
215
+
216
+ async function cmdRecompute() {
217
+ const orgId = requireArg("--org-id");
218
+ const passphrase = process.env["REDAKTYN_PASSPHRASE"] ?? (await promptHidden("Organization passphrase:"));
219
+ if (!passphrase) fail("A passphrase is required — it is what the key is derived from.");
220
+ const secret = process.env["REDAKTYN_SECRET"] ?? (await promptHidden("Secret value:"));
221
+ if (!secret) fail("A secret value is required.");
222
+
223
+ console.log(`\n${c.b}Deriving key${c.r} PBKDF2-SHA256, ${PBKDF2_ITERATIONS.toLocaleString("en-US")} iterations`);
224
+ const key = await deriveKey(passphrase, orgId);
225
+ const local = digests(secret, key);
226
+
227
+ console.log(`\n${c.b}Computed locally${c.r}`);
228
+ for (const [k, v] of Object.entries(local)) console.log(` ${k.padEnd(16)} ${v}`);
229
+
230
+ const token = arg("--token") ?? process.env["REDAKTYN_TOKEN"];
231
+ if (!token) {
232
+ console.log(
233
+ `\n${c.d}No API token given, so nothing was compared against the server.\n` +
234
+ `Pass --token <admin JWT> to fetch GET /api/secrets and check whether the\n` +
235
+ `stored digests equal the values above.${c.r}`
236
+ );
237
+ return;
238
+ }
239
+
240
+ const stored = await getJson(`${serverBase()}/api/secrets`, { Authorization: `Bearer ${token}` });
241
+ console.log(`\n${c.b}Compared against ${stored.length} registered fingerprint(s)${c.r}`);
242
+
243
+ const match = stored.find((s) =>
244
+ ["hmacHex", "hmacHexStripped", "hmacHexLower"].some((k) => s[k] != null && s[k] === local[k])
245
+ );
246
+
247
+ if (!match) {
248
+ bad("No stored digest equals what was just computed.");
249
+ note("Either this secret has no registered fingerprint, or the passphrase differs");
250
+ note("from the one used to register it. A different passphrase yields an unrelated digest.");
251
+ process.exitCode = 1;
252
+ return;
253
+ }
254
+
255
+ ok(`Matched "${match.label}".`);
256
+ for (const k of ["hmacHex", "hmacHexStripped", "hmacHexLower"]) {
257
+ if (match[k] == null) warn(`${k} is not stored for this secret — that variant is unprotected.`);
258
+ else if (match[k] === local[k]) ok(`${k} identical.`);
259
+ else {
260
+ bad(`${k} differs from the local computation.`);
261
+ // A variant that disagrees is a real verification failure: that pass
262
+ // will not match this secret on a device. `bad()` only prints, so
263
+ // without this the command said "✗" and still exited 0 — and anything
264
+ // gating on the exit code, which is how this is meant to be used, read
265
+ // a mismatch as a pass.
266
+ process.exitCode = 1;
267
+ }
268
+ }
269
+ note("The server holds a one-way digest of this secret, not the secret.");
270
+ note("It does not follow that the server never received the plaintext by some");
271
+ note("other route — run `egress` against a capture to check what the client sends.");
272
+ }
273
+
274
+ /* ═══════════════════════════════════════════════════════════════════════════
275
+ build
276
+ ═══════════════════════════════════════════════════════════════════════════ */
277
+
278
+ async function cmdBuild() {
279
+ const base = serverBase();
280
+ const release = await getJson(`${base}/api/verify/release`);
281
+
282
+ if (release.released === false) {
283
+ warn("This deployment publishes no signed release manifest.");
284
+ note(release.detail ?? "");
285
+ note("Nothing can be verified about the served bundle. Build from source and");
286
+ note("compare, or ask the operator which commit is deployed.");
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+
291
+ const manifestBytes = Buffer.from(release.manifest, "utf8");
292
+ const manifest = JSON.parse(release.manifest);
293
+ console.log(`\n${c.b}Manifest${c.r} commit ${manifest.gitCommit} · ${manifest.artifactCount} artifacts`);
294
+
295
+ if (!release.signed) {
296
+ warn("The manifest is NOT signed — it lists hashes but attests to nothing.");
297
+ } else {
298
+ const expected = arg("--pubkey") ?? process.env["REDAKTYN_PUBKEY"];
299
+ const served = release.signature.publicKeyBase64;
300
+ if (expected == null) {
301
+ warn("Signature checked against the key this same server supplied.");
302
+ note("That only rules out accidental corruption. A server able to alter the");
303
+ note("manifest can alter the key beside it. Pass --pubkey <base64> with a key");
304
+ note("you obtained elsewhere to make this meaningful.");
305
+ } else if (expected !== served) {
306
+ bad("The server's signing key is not the key you pinned.");
307
+ note(`pinned ${expected}`);
308
+ note(`served ${served}`);
309
+ process.exitCode = 1;
310
+ return;
311
+ } else {
312
+ ok("Signing key matches the one you pinned.");
313
+ }
314
+ if (!verifySignature(manifestBytes, release.signature.signature, served)) {
315
+ bad("Ed25519 signature does not verify — the manifest has been altered.");
316
+ process.exitCode = 1;
317
+ return;
318
+ }
319
+ ok("Signature verifies over the manifest bytes.");
320
+ }
321
+
322
+ // Hash what this server actually serves and compare with the manifest.
323
+ const byPath = new Map(manifest.artifacts.map((a) => [a.path, a.sha256]));
324
+ const html = await getBytes(`${base}/`);
325
+ const checks = [["dashboard/index.html", html]];
326
+
327
+ for (const m of html.toString("utf8").matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)) {
328
+ checks.push([`dashboard${m[1]}`, await getBytes(`${base}${m[1]}`)]);
329
+ }
330
+
331
+ console.log(`\n${c.b}Served bytes vs manifest${c.r}`);
332
+ let mismatched = 0;
333
+ for (const [path, bytes] of checks) {
334
+ const actual = createHash("sha256").update(bytes).digest("hex");
335
+ const expected = byPath.get(path);
336
+ if (expected == null) {
337
+ warn(`${path} — not in the manifest`);
338
+ } else if (expected === actual) {
339
+ ok(`${path}`);
340
+ } else {
341
+ bad(`${path} — served bytes differ from the signed hash`);
342
+ mismatched++;
343
+ }
344
+ }
345
+
346
+ if (mismatched > 0) {
347
+ process.exitCode = 1;
348
+ return;
349
+ }
350
+
351
+ note("The bundle served to you matches the manifest. To close the loop, check");
352
+ note("that the manifest matches the published source: clone the repo at");
353
+ note(`${manifest.gitCommit.slice(0, 12)} and run scripts/release/verify-reproducible.mjs.`);
354
+ }
355
+
356
+ /* ═══════════════════════════════════════════════════════════════════════════
357
+ pin / check
358
+ ═══════════════════════════════════════════════════════════════════════════ */
359
+
360
+ async function fetchLog(base) {
361
+ const text = await (await fetch(`${base}/api/verify/log`)).text();
362
+ if (text.trimStart().startsWith("{") && text.includes('"released":false')) {
363
+ fail("This deployment publishes no release log.");
364
+ }
365
+ const entries = text.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l));
366
+ if (entries.length === 0) fail("The release log is empty.");
367
+ return entries;
368
+ }
369
+
370
+ async function cmdPin() {
371
+ const base = serverBase();
372
+ const entries = await fetchLog(base);
373
+ const head = verifyChain(entries);
374
+ ok(`Chain of ${entries.length} release(s) is internally consistent.`);
375
+
376
+ const pins = existsSync(PIN_PATH) ? JSON.parse(readFileSync(PIN_PATH, "utf8")) : {};
377
+ pins[base] = { head, seq: entries.length, pinnedAt: new Date().toISOString() };
378
+ writeFileSync(PIN_PATH, JSON.stringify(pins, null, 2) + "\n");
379
+
380
+ console.log(`\n head ${head}`);
381
+ console.log(` saved ${PIN_PATH}`);
382
+ note("Run `redaktyn-verify check` later. If releases up to this point are ever");
383
+ note("rewritten or dropped, the chain will no longer reproduce this head.");
384
+ }
385
+
386
+ async function cmdCheck() {
387
+ const base = serverBase();
388
+ if (!existsSync(PIN_PATH)) fail(`Nothing pinned yet — run \`redaktyn-verify pin --server ${base}\` first.`);
389
+ const pins = JSON.parse(readFileSync(PIN_PATH, "utf8"));
390
+ const pin = pins[base];
391
+ if (pin == null) fail(`No pin recorded for ${base}.`);
392
+
393
+ const entries = await fetchLog(base);
394
+ verifyChain(entries);
395
+ ok(`Chain of ${entries.length} release(s) is internally consistent.`);
396
+
397
+ if (entries.length < pin.seq) {
398
+ bad(`The log has shrunk: ${pin.seq} entries were pinned, ${entries.length} are served now.`);
399
+ process.exitCode = 1;
400
+ return;
401
+ }
402
+
403
+ const atPin = entries[pin.seq - 1];
404
+ if (atPin.head !== pin.head) {
405
+ bad(`Release ${pin.seq} has changed since you pinned it.`);
406
+ note(`pinned ${pin.head}`);
407
+ note(`served ${atPin.head}`);
408
+ note("History was rewritten. Treat every signature from this server as suspect.");
409
+ process.exitCode = 1;
410
+ return;
411
+ }
412
+
413
+ ok(`Release ${pin.seq} still hashes to the head you pinned on ${pin.pinnedAt.slice(0, 10)}.`);
414
+ if (entries.length > pin.seq) {
415
+ ok(`${entries.length - pin.seq} release(s) appended since, with your pin intact beneath them.`);
416
+ }
417
+ note("This detects a rewrite of the log served to you. It cannot detect a server");
418
+ note("that serves a different, self-consistent log to somebody else.");
419
+ }
420
+
421
+ /* ═══════════════════════════════════════════════════════════════════════════
422
+ egress
423
+ ═══════════════════════════════════════════════════════════════════════════ */
424
+
425
+ async function cmdEgress() {
426
+ const harPath = requireArg("--har");
427
+ if (!existsSync(harPath)) fail(`No such file: ${harPath}`);
428
+ const har = JSON.parse(readFileSync(harPath, "utf8"));
429
+ const { endpoints } = await getJson(`${serverBase()}/api/verify/fields`);
430
+
431
+ /** Matches a concrete request path against an allowlist path with :params. */
432
+ const routeFor = (pathname) =>
433
+ endpoints.find((e) => {
434
+ const pattern = "^" + e.path.replace(/:[^/]+/g, "[^/]+") + "$";
435
+ return new RegExp(pattern).test(pathname);
436
+ });
437
+
438
+ const entries = har?.log?.entries ?? [];
439
+ if (entries.length === 0) fail("That HAR contains no entries.");
440
+
441
+ let inspected = 0;
442
+ const violations = [];
443
+
444
+ for (const entry of entries) {
445
+ const req = entry.request;
446
+ if (req?.postData?.text == null) continue;
447
+ let pathname;
448
+ try { pathname = new URL(req.url).pathname; } catch { continue; }
449
+ const route = routeFor(pathname);
450
+ if (route == null || route.method !== req.method) continue;
451
+
452
+ let body;
453
+ try { body = JSON.parse(req.postData.text); } catch { continue; }
454
+ inspected++;
455
+
456
+ const unknownTop = Object.keys(body).filter((k) => !route.fields.includes(k));
457
+ for (const k of unknownTop) violations.push(`${req.method} ${pathname} → "${k}"`);
458
+
459
+ const nested = route.itemFields;
460
+ if (nested && Array.isArray(body[nested.field])) {
461
+ for (const item of body[nested.field]) {
462
+ if (item == null || typeof item !== "object") continue;
463
+ for (const k of Object.keys(item)) {
464
+ if (!nested.fields.includes(k)) {
465
+ violations.push(`${req.method} ${pathname} → ${nested.field}[].${k}`);
466
+ }
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ console.log(`\n${c.b}Inspected ${inspected} Redaktyn API request(s) in ${harPath}${c.r}`);
473
+ if (inspected === 0) {
474
+ warn("None of the captured requests hit a Redaktyn ingest endpoint.");
475
+ note("Record the capture while registering a fingerprint or triggering a block.");
476
+ return;
477
+ }
478
+
479
+ if (violations.length > 0) {
480
+ bad(`${violations.length} field(s) outside the published allowlist:`);
481
+ for (const v of [...new Set(violations)]) console.log(` ${v}`);
482
+ process.exitCode = 1;
483
+ return;
484
+ }
485
+
486
+ ok("Every field sent is one the API publicly declares it accepts.");
487
+ note("This covers the requests in your capture, to endpoints on the allowlist.");
488
+ note("It says nothing about requests you did not record, or about what the");
489
+ note("server does with the fields it does accept.");
490
+ }
491
+
492
+ /* ═══════════════════════════════════════════════════════════════════════════
493
+ Entry point
494
+ ═══════════════════════════════════════════════════════════════════════════ */
495
+
496
+ function help() {
497
+ console.log(`
498
+ ${c.b}redaktyn-verify${c.r} — check Redaktyn's claims for yourself
499
+
500
+ ${c.b}COMMANDS${c.r}
501
+
502
+ ${c.b}recompute${c.r} --org-id <uuid> [--token <jwt>]
503
+ Derives the HMAC key from your passphrase and recomputes all three
504
+ digests. With a token, compares them against GET /api/secrets.
505
+ Passphrase and secret are read from a hidden prompt, or from
506
+ REDAKTYN_PASSPHRASE / REDAKTYN_SECRET.
507
+
508
+ ${c.b}build${c.r} [--pubkey <base64>]
509
+ Fetches the signed release manifest, verifies the Ed25519 signature,
510
+ then hashes the bundle this server actually serves and compares.
511
+ Supply --pubkey with a key obtained independently — verifying against
512
+ the key the same server just handed you proves very little.
513
+
514
+ ${c.b}pin${c.r}
515
+ Records the release log head as it stands today, in
516
+ ~/.redaktyn-verify-pin.json (override with REDAKTYN_VERIFY_PIN).
517
+
518
+ ${c.b}check${c.r}
519
+ Re-fetches the log and confirms it still reproduces your pinned head.
520
+
521
+ ${c.b}egress${c.r} --har <file.har>
522
+ Asserts that every field sent to a Redaktyn ingest endpoint in the capture
523
+ is one the API publicly declares. Record with DevTools → Network →
524
+ "Export HAR" while registering a fingerprint.
525
+
526
+ ${c.b}COMMON${c.r}
527
+
528
+ --server <url> Redaktyn base URL (or REDAKTYN_SERVER)
529
+ --no-color Disable ANSI colour
530
+ --help
531
+
532
+ ${c.b}WHAT THIS CANNOT SHOW${c.r}
533
+
534
+ ${c.d}Nothing here establishes what the server does with data after receiving it.
535
+ A one-way digest proves the stored value cannot be reversed; it does not
536
+ prove the plaintext was never written elsewhere. That needs source access,
537
+ an independent audit, or a deployment you host.${c.r}
538
+ `);
539
+ }
540
+
541
+ const command = process.argv[2];
542
+ const commands = {
543
+ recompute: cmdRecompute,
544
+ build: cmdBuild,
545
+ pin: cmdPin,
546
+ check: cmdCheck,
547
+ egress: cmdEgress,
548
+ };
549
+
550
+ if (command == null || command === "--help" || command === "-h" || commands[command] == null) {
551
+ help();
552
+ process.exit(command == null || command.startsWith("-") ? 0 : 1);
553
+ }
554
+
555
+ commands[command]().catch((err) => {
556
+ fail(err instanceof Error ? err.message : String(err));
557
+ });