88 lines
6.3 KiB
JavaScript
88 lines
6.3 KiB
JavaScript
// Sage, 2026-10-11: Q61a (Jason, relayed by Mos: "full agentic access", no task or project delete,
|
|
// no share route). svc-mosaic-stack re-mints the pm and sync keys with added scopes: the pm gets
|
|
// projects create plus bucket and view writes so the agents install #19's buckets, and the sync bot
|
|
// gets projects read_all to find child projects. Each new key goes to a .new file (wx, 0600), is
|
|
// checked with reads only, renamed over the old file, and the old key is revoked. No delete or write
|
|
// is tried as a test; the bus startup probes run on the next start. Refuses if a bot's keys aren't
|
|
// exactly the expected old key, or if a scope set holds a forbidden group or permission.
|
|
// Usage: node remint.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_DIR OUT_JSON
|
|
import { readFileSync, writeFileSync, existsSync, renameSync, statSync } from "node:fs";
|
|
const [ORIGIN, PW, DIR, OUT] = process.argv.slice(2);
|
|
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
|
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing to rerun");
|
|
const BASE = ORIGIN + "/api/v2";
|
|
const EXP = "2027-01-07T00:00:00Z", MISSING = 2147483647;
|
|
const KEYS = {
|
|
pm: { owner: 6, old: 7, add: { projects: ["create", "views_buckets", "views_buckets_put", "views_buckets_post"], projects_views: ["read_all", "update"] } },
|
|
sync: { owner: 10, old: 6, add: { projects: ["read_all"] } },
|
|
};
|
|
const FORBIDDEN_GROUPS = ["projects_shares", "projects_teams", "projects_users", "projects_webhooks", "teams", "users", "tokens"];
|
|
// Delete on tasks and projects (and their views and buckets) is Jason's alone. Unassign, unlink and
|
|
// unlabel are deletes of a join row, not of a task, and the pm already holds them.
|
|
const forbidden = (g, p) => FORBIDDEN_GROUPS.includes(g) || ((g === "tasks" || g.startsWith("projects")) && /delete|duplicate/.test(p));
|
|
async function api(method, path, body, auth) {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (auth) headers.Authorization = `Bearer ${auth}`;
|
|
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
|
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
|
return { status: r.status, json };
|
|
}
|
|
const must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; };
|
|
const items = (j, l) => { if (!Array.isArray(j?.items)) throw new Error(`${l}: no items array`); return j.items; };
|
|
const norm = (p) => Object.fromEntries(Object.entries(p).map(([g, l]) => [g, [...new Set(l)].sort()]).sort(([a], [b]) => a.localeCompare(b)));
|
|
|
|
const password = readFileSync(PW, "utf8").trim();
|
|
const tok = must(await api("POST", "/login", { username: "svc-mosaic-stack", password }), "login svc").token;
|
|
if (!tok) throw new Error("login svc: no token field");
|
|
const secrets = [password, tok];
|
|
const keysOf = (owner) => api("GET", `/tokens?owner_id=${owner}&per_page=50`, undefined, tok).then((r) => items(must(r, `keys ${owner}`), `keys ${owner}`));
|
|
|
|
// Preconditions for both keys before any write.
|
|
const plan = {};
|
|
for (const [role, k] of Object.entries(KEYS)) {
|
|
const file = `${DIR}/${role}-vikunja.token`;
|
|
if (existsSync(`${file}.new`)) throw new Error(`${role}-vikunja.token.new exists; refusing`);
|
|
const l = await keysOf(k.owner);
|
|
if (l.map((x) => x.id).join() !== String(k.old)) throw new Error(`${role} bot keys are not exactly [${k.old}]; refusing`);
|
|
const perms = structuredClone(l[0].permissions);
|
|
for (const [g, ps] of Object.entries(k.add)) perms[g] = [...(perms[g] ?? []), ...ps];
|
|
for (const [g, ps] of Object.entries(perms)) for (const p of ps) if (forbidden(g, p)) throw new Error(`${role}: forbidden scope ${g}.${p}; refusing`);
|
|
const oldValue = readFileSync(file, "utf8").trim();
|
|
secrets.push(oldValue);
|
|
plan[role] = { file, oldValue, before: norm(l[0].permissions), perms: norm(perms) };
|
|
}
|
|
|
|
const rec = { at: new Date().toISOString(), origin: ORIGIN, by: "svc-mosaic-stack", keys: {}, problems: [] };
|
|
for (const [role, k] of Object.entries(KEYS)) {
|
|
const { file, oldValue, before, perms } = plan[role];
|
|
const title = `mosaic-stack-${role}-${new Date().toISOString().slice(0, 10)}-q61a`;
|
|
const m = await api("POST", "/tokens", { title, owner_id: k.owner, expires_at: EXP, permissions: perms }, tok);
|
|
if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint ${role}: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`);
|
|
const value = m.json.token;
|
|
secrets.push(value);
|
|
writeFileSync(`${file}.new`, value, { flag: "wx", mode: 0o600 });
|
|
const ctl = await api("GET", `/tasks/${MISSING}`, undefined, value);
|
|
const list = await api("GET", "/projects?per_page=1", undefined, value);
|
|
const r = { id: m.json.id, title, expires: m.json.expires_at, before, permissions: norm(m.json.permissions ?? {}), control: [ctl.status, ctl.json?.code], projectsList: list.status };
|
|
rec.keys[role] = r;
|
|
if (ctl.status !== 404 || ctl.json?.code !== 4002 || list.status !== 200) throw new Error(`new ${role} key check failed: ${JSON.stringify(r)}; .new kept, old key ${k.old} untouched`);
|
|
if (JSON.stringify(r.permissions) !== JSON.stringify(perms)) rec.problems.push(`${role} permissions differ from the request`);
|
|
renameSync(`${file}.new`, file);
|
|
r.fileMode = (statSync(file).mode & 0o777).toString(8);
|
|
if (readFileSync(file, "utf8").trim() !== value) rec.problems.push(`${role} file does not hold the new key`);
|
|
must(await api("DELETE", `/tokens/${k.old}`, undefined, tok), `revoke old ${role} key`);
|
|
r.revoked = k.old;
|
|
r.oldKeyAfterRevoke = (await api("GET", `/tasks/${MISSING}`, undefined, oldValue)).status;
|
|
r.keysNow = (await keysOf(k.owner)).map((x) => x.id);
|
|
if (r.expires !== EXP) rec.problems.push(`${role} expiry differs`);
|
|
if (r.oldKeyAfterRevoke !== 401) rec.problems.push(`old ${role} key still answers`);
|
|
if (r.keysNow.join() !== String(r.id)) rec.problems.push(`${role} bot keys are not exactly the new one`);
|
|
if (r.fileMode !== "600") rec.problems.push(`${role} file mode not 600`);
|
|
}
|
|
|
|
const out = JSON.stringify(rec, null, 1) + "\n";
|
|
if (secrets.some((s) => out.includes(s))) throw new Error("secret in the record; not written");
|
|
writeFileSync(OUT, out, { flag: "wx" });
|
|
process.stdout.write(out);
|
|
if (rec.problems.length) process.exit(2);
|