vikunja: Q58a/Q60 teams 1 and 2, pm key 7 replaces key 2 (sage)

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]>
This commit is contained in:
2026-10-11 12:56:04 -05:00
co-authored by Claude Opus 5.5
parent 4acadaae4e
commit 20735a3706
3 changed files with 197 additions and 0 deletions
@@ -0,0 +1,75 @@
{
"at": "2026-10-11T17:55:25.226Z",
"origin": "https://tasks.woltje.com",
"by": "svc-mosaic-stack",
"teams": {
"mosaic-agents": {
"id": 1,
"members": {
"jason.woltje": true,
"bot-mosaic-stack-pm": false,
"bot-mosaic-stack-cto": false,
"bot-mosaic-stack-coder": false,
"bot-mosaic-stack-reviewer": false
},
"jasonAdminToggle": true,
"svcReadAfterLeaving": 403
},
"mosaic-sync": {
"id": 2,
"members": {
"jason.woltje": true,
"bot-mosaic-stack-sync": false
},
"jasonAdminToggle": true,
"svcReadAfterLeaving": 403
}
},
"problems": [],
"pm": {
"id": 7,
"title": "mosaic-stack-pm-2026-10-11",
"expires": "2027-01-07T00:00:00Z",
"permissions": {
"labels": [
"read_all"
],
"projects": [
"read_all",
"read_one",
"update",
"views_buckets_tasks"
],
"tasks": [
"read_one",
"create",
"update"
],
"tasks_assignees": [
"create",
"delete"
],
"tasks_comments": [
"create"
],
"tasks_labels": [
"create",
"delete"
],
"tasks_relations": [
"create",
"delete"
]
},
"control": [
404,
4002
],
"projectsList": 200,
"fileMode": "600",
"oldKeyAfterRevoke": 401,
"keysNow": [
7
]
}
}
@@ -0,0 +1,31 @@
// Sage, 2026-10-11: read-only. svc-mosaic-stack lists its teams and each bot's api tokens (ids, titles,
// expiries, scopes). Token values never come back from a list; the output is still checked for secrets.
// Usage: node bot-keys.mjs https://tasks.woltje.com SVC_PASSWORD_FILE
import { readFileSync } from "node:fs";
const [ORIGIN, PW] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
const BASE = ORIGIN + "/api/v2";
const BOTS = { pm: 6, cto: 7, coder: 8, reviewer: 9, sync: 10 };
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 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 rec = { at: new Date().toISOString(), teams: [], bots: {} };
const teams = must(await api("GET", "/teams?per_page=50", undefined, tok), "teams").items;
if (!Array.isArray(teams)) throw new Error("teams: no items array");
for (const t of teams) rec.teams.push({ id: t.id, name: t.name, members: (t.members ?? []).map((m) => [m.username, m.admin]) });
for (const [role, id] of Object.entries(BOTS)) {
const l = must(await api("GET", `/tokens?owner_id=${id}&per_page=50`, undefined, tok), `tokens ${role}`).items;
if (!Array.isArray(l)) throw new Error(`tokens ${role}: no items array`);
rec.bots[role] = l.map((k) => ({ id: k.id, title: k.title, expires: k.expires_at, permissions: k.permissions, hasValue: typeof k.token === "string" && k.token.length > 0 }));
}
const out = JSON.stringify(rec, null, 1) + "\n";
if ([password, tok].some((s) => out.includes(s))) throw new Error("secret in output; not printed");
process.stdout.write(out);
+91
View File
@@ -0,0 +1,91 @@
// 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);