From e22e0d73f3b7c22b2d344c5f7ab57e77614472ee Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 12:46:46 -0500 Subject: [PATCH] sessions, vikunja-setup: #198 launchpad-pm bot key minted, team 1 add via ops-01 mint-bot.mjs generalizes mint-mos.mjs (user, name and key title as arguments; slot written without a trailing newline). bot-launchpad-pm is user 12, key 12, scopes identical to key 11 (Q61a cut). The key reads no project until ops-01 adds user 12 to team 1. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-12_mint-launchpad-pm.json | 103 ++++++++++++++++++ agents/sage/work/vikunja-setup/mint-bot.mjs | 83 ++++++++++++++ docs/SESSIONS.md | 1 + 3 files changed, 187 insertions(+) create mode 100644 agents/sage/work/vikunja-setup/2026-10-12_mint-launchpad-pm.json create mode 100644 agents/sage/work/vikunja-setup/mint-bot.mjs diff --git a/agents/sage/work/vikunja-setup/2026-10-12_mint-launchpad-pm.json b/agents/sage/work/vikunja-setup/2026-10-12_mint-launchpad-pm.json new file mode 100644 index 000000000..66a5a0804 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-12_mint-launchpad-pm.json @@ -0,0 +1,103 @@ +{ + "at": "2026-10-12T17:45:23.564Z", + "user": "bot-launchpad-pm", + "problems": [], + "permissions": { + "projects": [ + "background", + "background_get", + "backgrounds_upload", + "create", + "projectusers", + "read_all", + "read_one", + "tasks_by_index", + "update", + "users_search", + "views_buckets", + "views_buckets_post", + "views_buckets_put", + "views_buckets_tasks", + "views_buckets_tasks_get" + ], + "projects_views": [ + "create", + "read_all", + "read_one", + "update" + ], + "projects_views_tasks": [ + "read_all" + ], + "tasks": [ + "create", + "create_bulk", + "position", + "read", + "read_all", + "read_one", + "update", + "update_bulk" + ], + "tasks_assignees": [ + "create", + "delete", + "read_all", + "update_bulk" + ], + "tasks_attachments": [ + "create", + "read_all", + "read_one" + ], + "tasks_comments": [ + "create", + "read_all", + "read_one", + "update" + ], + "tasks_labels": [ + "create", + "delete", + "read_all", + "update_bulk" + ], + "tasks_relations": [ + "create", + "delete" + ], + "labels": [ + "create", + "read_all", + "read_one", + "update" + ], + "filters": [ + "create", + "read_one", + "update" + ], + "reactions": [ + "create", + "delete", + "read_all" + ] + }, + "botCreate": 201, + "botId": 12, + "tokenCreate": 201, + "tokenId": 12, + "expires": "2027-01-07T00:00:00Z", + "startsTk": true, + "file": "/home/jwoltje/.config/mosaic-dev/secrets/mosaic-stack/launchpad-pm-vikunja.token", + "mode": "600", + "inverse": "svc: DELETE /api/v1/tokens/12", + "keysNow": [ + 12 + ], + "newKeyProjects": { + "status": 200, + "count": 0 + }, + "newKeyTask198": 403 +} diff --git a/agents/sage/work/vikunja-setup/mint-bot.mjs b/agents/sage/work/vikunja-setup/mint-bot.mjs new file mode 100644 index 000000000..cca5e98ea --- /dev/null +++ b/agents/sage/work/vikunja-setup/mint-bot.mjs @@ -0,0 +1,83 @@ +// Sage, 2026-10-12: Vikunja #198, keys for seats that have none (Mos sage-0085), generalized from +// mint-mos.mjs (#151). svc-mosaic-stack mints one bot user and one API key for it. The key goes +// straight to a 0600 file (flag wx, never overwritten, no trailing newline, like the other slots) +// and never reaches stdout. Scopes are Mos's catalogue (projects*, tasks*, labels, +// filters, reactions) cut to Q61a's limit: no delete or duplicate on tasks, projects, views, buckets, +// labels or filters, and no share, team, user, token, webhook, migration, other or notification group. +// The deletes kept are join rows the pm already holds (assignees, labels, relations) and own reactions. +// svc left both teams on 2026-10-11 (decision 85), so ops-01 adds the bot to team 1 (ops01-0096 +// pattern); until then the key reaches no project. Inverse: svc revokes the key (DELETE /api/v1/tokens/:id). +// Usage: node mint-bot.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_FILE OUT_JSON USER NAME TITLE +import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs"; +const [ORIGIN, PW, TOKEN_FILE, OUT, USER, NAME, TITLE] = 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"); +if (!TOKEN_FILE || existsSync(TOKEN_FILE)) throw new Error("TOKEN_FILE missing or exists; refusing"); +if (!/^bot-[a-z0-9-]{3,40}$/.test(USER ?? "") || !NAME || !/^[a-z0-9-]{8,72}$/.test(TITLE ?? "")) throw new Error("USER, NAME or TITLE missing or malformed"); +const EXP = "2027-01-07T00:00:00Z"; +const GROUPS = ["projects", "projects_views", "projects_views_tasks", "tasks", "tasks_assignees", "tasks_attachments", + "tasks_comments", "tasks_labels", "tasks_relations", "labels", "filters", "reactions"]; +const JOIN_DELETES = ["tasks_assignees", "tasks_labels", "tasks_relations", "reactions"]; +const forbidden = (g, p) => !GROUPS.includes(g) || (/delete|duplicate/.test(p) && !JOIN_DELETES.includes(g)); +async function api(method, path, body, auth) { + const headers = { "Content-Type": "application/json" }; + if (auth) headers.Authorization = `Bearer ${auth}`; + const r = await fetch(ORIGIN + 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", "/api/v2/login", { username: "svc-mosaic-stack", password }), "login svc").token; +if (!tok) throw new Error("login svc: no token field"); +const secrets = [password, tok]; +const rec = { at: new Date().toISOString(), user: USER, problems: [] }; +const save = () => { + const out = JSON.stringify(rec, null, 1) + "\n"; + if (secrets.some((s) => s && out.includes(s))) throw new Error("secret in the record; not written"); + writeFileSync(OUT, out); +}; + +// Preconditions before any write: the bot doesn't exist, and the scope set is clean. +const bots = must(await api("GET", "/api/v1/user/bots", undefined, tok), "list bots"); +const botList = bots?.items ?? bots; +if (!Array.isArray(botList)) throw new Error("list bots: no array"); +if (botList.some((b) => b.username === USER)) throw new Error(`${USER} exists; refusing`); +const routes = must(await api("GET", "/api/v2/routes", undefined, tok), "routes"); +const permissions = {}; +for (const g of GROUPS) { + const ps = Object.keys(routes[g] ?? {}).filter((p) => !forbidden(g, p)).sort(); + if (ps.length) permissions[g] = ps; +} +for (const [g, ps] of Object.entries(permissions)) for (const p of ps) if (forbidden(g, p)) throw new Error(`forbidden ${g}.${p}`); +rec.permissions = permissions; +writeFileSync(OUT, "", { flag: "wx" }); + +const b = await api("PUT", "/api/v1/user/bots", { username: USER, name: NAME }, tok); +rec.botCreate = b.status; +if (b.status >= 300 || !Number.isSafeInteger(b.json?.id)) { rec.problems.push(`bot create: ${b.status} ${b.json?.message ?? ""}`); save(); process.exit(2); } +rec.botId = b.json.id; +save(); +const t = await api("PUT", "/api/v1/tokens", { title: TITLE, owner_id: rec.botId, expires_at: EXP, permissions }, tok); +rec.tokenCreate = t.status; +if (t.status >= 300 || !Number.isSafeInteger(t.json?.id) || typeof t.json?.token !== "string") { rec.problems.push(`token create: ${t.status} ${t.json?.message ?? ""}`); save(); process.exit(2); } +const key = t.json.token; +secrets.push(key); +writeFileSync(TOKEN_FILE, key, { flag: "wx", mode: 0o600 }); +Object.assign(rec, { tokenId: t.json.id, expires: t.json.expires_at, startsTk: key.startsWith("tk_"), file: TOKEN_FILE, mode: (statSync(TOKEN_FILE).mode & 0o777).toString(8), + inverse: `svc: DELETE /api/v1/tokens/${t.json.id}` }); +// Read back as svc: the bot holds exactly this key with these scopes. +const keys = must(await api("GET", `/api/v2/tokens?owner_id=${rec.botId}&per_page=50`, undefined, tok), "bot keys").items ?? []; +rec.keysNow = keys.map((k) => k.id); +const back = keys.find((k) => k.id === rec.tokenId); +const norm = (p) => JSON.stringify(Object.fromEntries(Object.entries(p ?? {}).map(([g, l]) => [g, [...l].sort()]).sort(([a], [c]) => a.localeCompare(c)))); +if (!back || norm(back.permissions) !== norm(permissions)) rec.problems.push("key read-back scopes differ"); +if (rec.keysNow.length !== 1) rec.problems.push("bot keys are not exactly the new one"); +// Reads only with the new key. Until ops-01 adds the bot to team 1 it should reach no project. +const k = await api("GET", "/api/v2/projects?per_page=50", undefined, key); +rec.newKeyProjects = { status: k.status, count: (k.json?.items ?? []).length }; +const t198 = await api("GET", "/api/v2/tasks/198", undefined, key); +rec.newKeyTask198 = t198.status; +save(); +process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); +if (rec.problems.length) process.exit(2); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 760eb95a9..e6ba26724 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -754,3 +754,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-12T17:27:49Z | sage | cutover live, decision 109 | verified the mosaic.woltje.com Console read-only after Mos's 17:21Z start (status active, send on, healthz 200); decision 109: landings touching webui, bus or cli in the canonical checkout are live changes until the Console runs from a pinned copy; BUILD-LOG entry 2026-10-12T17:38:19Z | sage | decision 110, follow-ups to Vikunja | Jason's Q109 via Mos sage-0081: Vikunja project 19 holds Mosaic work items; filed #153-#161 with the pm slot, read back with sync; #146 and #147 cited for the 10-13 rows; queue and Gitea stay the review and landing record; BUILD-LOG entry 2026-10-12T17:42:50Z | sage | Vikunja #151 Mos key | Mos sage-0083: svc-mosaic-stack minted bot-mosaic-stack-mos (user 11) and key 11 to mosaic-stack/mos-vikunja.token (0600, expires 2027-01-07), Mos's catalogue cut to Q61a (no task/project delete or duplicate, no share/team/token groups); svc left teams (decision 85) so team 1 membership is Jason's step; /tasks/151 403 until then; cto, coder, reviewer slots read 200 on v2 at check +2026-10-12T17:46:46Z | sage | Vikunja #198 launchpad-pm key | Mos sage-0085: svc-mosaic-stack minted bot-launchpad-pm (user 12) and key 12 to mosaic-stack/launchpad-pm-vikunja.token (0600, 43 bytes, expires 2027-01-07) with mint-bot.mjs, the mint-mos.mjs pattern generalized; scopes identical to key 11 (Q61a cut); /tasks/198 403 and 0 projects until ops-01 adds user 12 to team 1 (Mos routes it); seat list to Mos