Blobs of Gizmoduck's r50485384e9on origin/gizmoduck/row210-r5 (basead95c888c), the 23 manifest paths, manifest 688d38b9. Duckworth approve round 2 (rev 2592) and round 3 (comment 29851, rev 2614). Round 3 is a rebase-only round over row 237 (d7d943199): broker.mjs is a clean 3-way merge (610af0c4), the 210 hunks sit away from 237's insert, and range-diff against r4c43008749is all equal. Mutants 52 of 53 killed on the merged tree. M22b is equivalent (rev 2584); its coverage moves to row 235. Preflight singles: seat sandbox 10/0, bus project-runtime 3/0. Gate onad95c888cplus these paths: 15 package suites 2796 pass 0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail (plus 156 node tests in the queue suite). Refactor moved to9d165be8csince, two briefs touching no manifest path. No live change: the stack runs from copy4429a104funtil the 10-24 re-pin window.
96 lines
5.3 KiB
JavaScript
96 lines
5.3 KiB
JavaScript
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
|
|
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
|
|
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
|
|
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
|
|
import { CliError } from "./errors.mjs";
|
|
|
|
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
|
|
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
|
|
"revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
|
|
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
|
|
// Row 210 (#1707, design D1): host.<id> takes --rotate-by, as gitea does.
|
|
const HOST_KIND = /^host\.[a-z0-9][a-z0-9-]{0,31}$/;
|
|
const dateFlag = (kind) => (HOST_KIND.test(kind ?? "") ? "--rotate-by" : DATE_FLAG[kind]);
|
|
const MAX_TOKEN = 16384;
|
|
|
|
function parse(args) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
|
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
|
|
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
out.values[arg] = args[++i];
|
|
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
else out.positional.push(arg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
|
|
async function readToken(stdin) {
|
|
let text = "";
|
|
for await (const chunk of stdin) {
|
|
text += chunk;
|
|
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
|
|
}
|
|
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
|
|
return text;
|
|
}
|
|
|
|
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
|
|
// expiring or expired. not-issued and revoked hold none.
|
|
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
|
|
|
|
export async function credentialsCommand(args, io, callFor) {
|
|
const parsed = parse(args);
|
|
const [verb = "status", agent, kind, ...extra] = parsed.positional;
|
|
const v = parsed.values;
|
|
const shape = {
|
|
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
|
|
issue: () => agent && dateFlag(kind) && v[dateFlag(kind)] && !v[dateFlag(kind) === "--rotate-by" ? "--expires" : "--rotate-by"] && !v["--reason"],
|
|
revoke: () => agent && (kind === undefined || dateFlag(kind)) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
|
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
|
};
|
|
shape.rotate = shape.issue;
|
|
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
// Q173 (duckworth N1): a token the tester issued would pass through its session.
|
|
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
|
|
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
|
|
}
|
|
try {
|
|
const call = callFor(parsed);
|
|
let result;
|
|
if (verb === "status") {
|
|
const rows = await call("credentials", {});
|
|
result = agent ? rows.filter((r) => r.role === agent) : rows;
|
|
if (!parsed.flags.has("--json")) {
|
|
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}${r.fingerprint ? `\t${r.fingerprint}` : ""}`).join("\n") + (result.length ? "\n" : ""));
|
|
return;
|
|
}
|
|
} else if (verb === "issue" || verb === "rotate") {
|
|
// rotate names an entry the bus holds now; checked before stdin is read.
|
|
if (verb === "rotate") {
|
|
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
|
|
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
|
|
}
|
|
const token = await readToken(io.stdin);
|
|
result = await call("credentials.issue", { agent, service: kind, token, date: v[dateFlag(kind)] });
|
|
// Row 210 (design D9): public material only. The from= address is the stack host's, which
|
|
// ops-01 fills in when it installs the line.
|
|
if (result.publicKey && !parsed.flags.has("--json")) {
|
|
const business = v["--business"] ?? "BUSINESS";
|
|
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
io.stdout.write(`authorized_keys line for ops-01:\nfrom="<stack host address>",restrict ${result.publicKey} mosaic-${business}-${agent}-${kind.slice("host.".length)}\n`);
|
|
return;
|
|
}
|
|
} else if (verb === "revoke") {
|
|
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
|
|
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
|
|
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
} catch (error) {
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, 2);
|
|
}
|
|
}
|