From 20735a3706d581e1d52063ed5437d810b2b67072 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 12:56:04 -0500 Subject: [PATCH] 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 --- .../work/vikunja-setup/2026-10-11_teams.json | 75 +++++++++++++++ agents/sage/work/vikunja-setup/bot-keys.mjs | 31 +++++++ agents/sage/work/vikunja-setup/teams.mjs | 91 +++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 agents/sage/work/vikunja-setup/2026-10-11_teams.json create mode 100644 agents/sage/work/vikunja-setup/bot-keys.mjs create mode 100644 agents/sage/work/vikunja-setup/teams.mjs diff --git a/agents/sage/work/vikunja-setup/2026-10-11_teams.json b/agents/sage/work/vikunja-setup/2026-10-11_teams.json new file mode 100644 index 000000000..05d232a77 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-11_teams.json @@ -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 + ] + } +} diff --git a/agents/sage/work/vikunja-setup/bot-keys.mjs b/agents/sage/work/vikunja-setup/bot-keys.mjs new file mode 100644 index 000000000..18a71fadb --- /dev/null +++ b/agents/sage/work/vikunja-setup/bot-keys.mjs @@ -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); diff --git a/agents/sage/work/vikunja-setup/teams.mjs b/agents/sage/work/vikunja-setup/teams.mjs new file mode 100644 index 000000000..ce629448a --- /dev/null +++ b/agents/sage/work/vikunja-setup/teams.mjs @@ -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);