svc-mosaic-stack created mosaic-agents (team 1, bots pm/cto/coder/reviewer) and mosaic-sync (team 2, bot sync), made jason.woltje team admin, then left both teams so a team share gives svc no project access. The pm key gained projects read_all, read_one and update; no key has task or project delete or a share route (read back via GET /tokens?owner_id). Old pm key 2 revoked, now 401. Record holds ids and scopes only; checked for secrets. Co-Authored-By: Claude Opus 5.5 <[email protected]>
92 lines
6.7 KiB
JavaScript
92 lines
6.7 KiB
JavaScript
// Sage, 2026-10-11: Q58a and Q60 (Jason, relayed by Mos). svc-mosaic-stack creates the two teams Jason
|
|
// shares on his projects, then re-mints the pm key with project read and update. svc makes jason.woltje a
|
|
// team admin and then leaves both teams, so a team share gives svc nothing. The new pm key goes to a .new
|
|
// file (wx, 0600), is checked with reads only, renamed over pm-vikunja.token, and the 10-09 key is revoked.
|
|
// No delete is tried as a test. Refuses if either team exists or the pm bot's keys aren't exactly [2].
|
|
// Usage: node teams.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, OLD_PM = 2;
|
|
const TEAMS = {
|
|
"mosaic-agents": { description: "Mosaic Stack agent bots. Share at admin. Ask Mos before changing.", bots: ["pm", "cto", "coder", "reviewer"] },
|
|
"mosaic-sync": { description: "Mosaic Stack sync bot. Share at read. Ask Mos before changing.", bots: ["sync"] },
|
|
};
|
|
const PM = {
|
|
tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], tasks_relations: ["create", "delete"],
|
|
tasks_labels: ["create", "delete"], tasks_comments: ["create"], labels: ["read_all"],
|
|
projects: ["read_all", "read_one", "update", "views_buckets_tasks"],
|
|
};
|
|
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 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 PMFILE = `${DIR}/pm-vikunja.token`, NEWFILE = `${PMFILE}.new`;
|
|
if (existsSync(NEWFILE)) throw new Error("pm-vikunja.token.new exists; refusing");
|
|
const oldPm = readFileSync(PMFILE, "utf8").trim();
|
|
secrets.push(oldPm);
|
|
|
|
// Preconditions, all reads.
|
|
const have = items(must(await api("GET", "/teams?per_page=50", undefined, tok), "teams"), "teams");
|
|
for (const name of Object.keys(TEAMS)) if (have.some((t) => t.name === name)) throw new Error(`team ${name} exists; refusing`);
|
|
const pmKeys = () => api("GET", "/tokens?owner_id=6&per_page=50", undefined, tok).then((r) => items(must(r, "pm keys"), "pm keys"));
|
|
if ((await pmKeys()).map((k) => k.id).join() !== String(OLD_PM)) throw new Error("pm bot keys are not exactly [2]; refusing");
|
|
|
|
const rec = { at: new Date().toISOString(), origin: ORIGIN, by: "svc-mosaic-stack", teams: {}, problems: [] };
|
|
for (const [name, { description, bots }] of Object.entries(TEAMS)) {
|
|
const T = must(await api("POST", "/teams", { name, description }, tok), `team ${name}`).id;
|
|
for (const r of bots) must(await api("POST", `/teams/${T}/members`, { username: `bot-mosaic-stack-${r}` }, tok), `${name} add ${r}`);
|
|
must(await api("POST", `/teams/${T}/members`, { username: "jason.woltje" }, tok), `${name} add jason.woltje`);
|
|
const adm = must(await api("POST", `/teams/${T}/members/jason.woltje/admin`, undefined, tok), `${name} admin jason.woltje`);
|
|
const before = must(await api("GET", `/teams/${T}`, undefined, tok), `${name} read`);
|
|
const members = Object.fromEntries((before.members ?? []).map((m) => [m.username, m.admin]));
|
|
must(await api("DELETE", `/teams/${T}/members/svc-mosaic-stack`, undefined, tok), `${name} svc leaves`);
|
|
const after = await api("GET", `/teams/${T}`, undefined, tok);
|
|
delete members["svc-mosaic-stack"];
|
|
rec.teams[name] = { id: T, members, jasonAdminToggle: adm?.admin, svcReadAfterLeaving: after.status };
|
|
const want = [...bots.map((r) => `bot-mosaic-stack-${r}`), "jason.woltje"].sort().join();
|
|
if (Object.keys(members).sort().join() !== want) rec.problems.push(`${name} members differ`);
|
|
if (members["jason.woltje"] !== true) rec.problems.push(`${name}: jason.woltje not admin`);
|
|
if (after.status === 200) rec.problems.push(`${name}: svc still reads the team`);
|
|
}
|
|
|
|
// The pm key. Mint, store as .new, check with reads, rename over, revoke the 10-09 key.
|
|
const title = `mosaic-stack-pm-${new Date().toISOString().slice(0, 10)}`;
|
|
const m = await api("POST", "/tokens", { title, owner_id: 6, expires_at: EXP, permissions: PM }, tok);
|
|
if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint pm: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`);
|
|
const newPm = m.json.token;
|
|
secrets.push(newPm);
|
|
writeFileSync(NEWFILE, newPm, { flag: "wx", mode: 0o600 });
|
|
const ctl = await api("GET", `/tasks/${MISSING}`, undefined, newPm);
|
|
const list = await api("GET", "/projects?per_page=1", undefined, newPm);
|
|
rec.pm = { id: m.json.id, title, expires: m.json.expires_at, permissions: m.json.permissions, control: [ctl.status, ctl.json?.code], projectsList: list.status };
|
|
if (ctl.status !== 404 || ctl.json?.code !== 4002 || list.status !== 200) throw new Error(`new pm key check failed: ${JSON.stringify(rec.pm)}; .new kept, old key untouched`);
|
|
renameSync(NEWFILE, PMFILE);
|
|
rec.pm.fileMode = (statSync(PMFILE).mode & 0o777).toString(8);
|
|
if (readFileSync(PMFILE, "utf8").trim() !== newPm) rec.problems.push("pm file does not hold the new key");
|
|
must(await api("DELETE", `/tokens/${OLD_PM}`, undefined, tok), "revoke old pm key");
|
|
rec.pm.oldKeyAfterRevoke = (await api("GET", `/tasks/${MISSING}`, undefined, oldPm)).status;
|
|
rec.pm.keysNow = (await pmKeys()).map((k) => k.id);
|
|
if (rec.pm.expires !== EXP) rec.problems.push("pm expiry differs");
|
|
if (rec.pm.oldKeyAfterRevoke !== 401) rec.problems.push("old pm key still answers");
|
|
if (rec.pm.keysNow.join() !== String(rec.pm.id)) rec.problems.push("pm bot keys are not exactly the new one");
|
|
if (rec.pm.fileMode !== "600") rec.problems.push("pm 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);
|