// `mosaic grants` (row 236, #1742, #436b r5): the broker's grants verbs for the Gitea grant path. run // starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the // admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke // and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never // argv, and nothing here prints a token: the answers carry ids and names only. Row 298 (#1804, Q228): // scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are // the human's, and the set is the human's Approve of the change. import { CliError } from "./errors.mjs"; import { orgSelector } from "./org-selector.mjs"; export const GRANTS_USAGE = "mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " + "revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " + "admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " + "admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " + "scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)"; const MAX_TOKEN = 16384; const MAX_SCOPE = 65536; const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"]; function parse(args) { const out = { positional: [], flags: new Set(), values: {} }; // --org and --business are one selector in this parser's scope (rename design r2). const selector = orgSelector(() => new CliError(GRANTS_USAGE, 4)); for (let i = 0; i < args.length; i++) { const arg = args[i]; if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; } else if (arg === "--json" || arg === "--tester" || arg === "--finish") { if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4); out.flags.add(arg); } else if (VALUES.includes(arg)) { if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(GRANTS_USAGE, 4); out.values[arg] = args[++i]; } else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4); else out.positional.push(arg); } if (selector.given) out.values["--business"] = selector.value; return out; } // The whole of stdin, one token, as `mosaic credentials issue` reads it. 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; } // Row 298: the whole of stdin, one JSON scope. {} is the empty scope. async function readScope(stdin) { let text = ""; for await (const chunk of stdin) { text += chunk; if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2); } if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4); try { return JSON.parse(text); } catch { throw new CliError("refused: the scope on stdin is not JSON", 4); } } // Each shape: the positional count after the verb, the flags and values it takes and needs. const SHAPES = { status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] }, run: { n: 1, flags: [], need: [], may: ["--business"] }, revoke: { n: 2, flags: ["--tester"], need: ["--reason"], may: ["--business"] }, sweep: { n: 1, flags: ["--tester"], need: [], may: ["--business"] }, "admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] }, "admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] }, "admin remove": { n: 1, flags: [], need: [], may: ["--business"] }, scope: { n: 0, flags: [], need: [], may: ["--business"] }, "scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] }, }; const lines = (s) => { const out = []; out.push(s.admin?.error ? `admin: ${s.admin.error}` : s.admin ? `admin: ${s.admin.account} on ${s.admin.url}, rotate by ${s.admin.rotateBy}${s.admin.rotationDue ? " (due)" : ""}` : "admin: none"); if (s.adminTokenLeft) out.push(`admin token left: ${s.adminTokenLeft}`); for (const g of s.grants) out.push(["grant", g.agent, g.user, `token ${g.tokenId}`, g.projects.join(","), `rotate by ${g.rotateBy}`, g.decision].join("\t")); for (const o of s.open) out.push(["open", o.decision, o.agent].join("\t")); for (const o of s.running) out.push(["running", o.decision, o.agent, o.step ?? "spent"].join("\t")); for (const o of s.settlePending) out.push(["settle-pending", o.decision, o.agent].join("\t")); for (const o of s.tokenUnknown) out.push(["token-unknown", o.agent, o.decision].join("\t")); for (const e of s.serviceLeft) out.push(["left", e.agent, e.user, String(e.id)].join("\t")); for (const r of s.revoking) out.push(["revoking", r.agent, r.revoke].join("\t")); return out.join("\n") + "\n"; }; export async function grantsCommand(args, io, callFor) { const parsed = parse(args); let [verb, ...rest] = parsed.positional; if (verb === "admin") { verb = `admin ${rest[0] ?? ""}`; rest = rest.slice(1); } else if (verb === "scope" && rest[0] === "set") { verb = "scope set"; rest = rest.slice(1); } // Own keys only, so `constructor` is a usage error, not a verb. const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null; if ( !shape || rest.length !== shape.n || [...parsed.flags].some((f) => !shape.flags.includes(f)) || Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) || shape.need.some((v) => !Object.hasOwn(parsed.values, v)) || (verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") || (verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by")) ) throw new CliError(GRANTS_USAGE, 4); try { const call = callFor(parsed); const v = parsed.values; let request; if (verb === "status") request = ["grants.status", { service: "gitea" }]; else if (verb === "run") request = ["grants.run", { decision: rest[0] }]; else if (verb === "revoke") request = ["grants.revoke", { service: "gitea", agent: rest[0], reason: v["--reason"] }]; else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }]; else if (verb === "admin set") request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }]; else if (verb === "scope") request = ["grants.scope", {}]; else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }]; else if (verb === "admin rotate") request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }]; else request = ["grants.admin.remove", { service: "gitea" }]; const result = await call(...request); io.stdout.write(verb === "status" && !parsed.flags.has("--json") ? lines(result) : JSON.stringify(result, null, 2) + "\n"); } catch (error) { if (error instanceof CliError) throw error; throw new CliError(error.message, 2); } }