From 02da0929c40d1d98b1a01bef2937e6320a291210 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 14:47:29 -0500 Subject: [PATCH] vikunja: Q80, delete #19 view 76 extra buckets 55, 56, 158, 160 with a one-time key (sage) Jason: "Q80: Have the PM decide. Then fix". project-manager decided to delete all four and moved their tasks to todo. Mos ruled no standing delete scope on any bot key. svc-mosaic-stack minted key 10 for the pm user with only projects.views_buckets_delete and a 30-minute expiry, held in memory only. The pre-read showed all four buckets empty. Four DELETEs answered 200, and key 10 was revoked (204) at once. The pm user now lists only key 8. The read-back shows buckets 57 done, 159 todo (default), 161 in-progress, 248 in-review and 162 blocked, with task counts unchanged. Co-Authored-By: Claude Opus 5.5 --- .../vikunja-setup/2026-10-11_q80-delete.json | 162 ++++++++++++++++++ agents/sage/work/vikunja-setup/q80-delete.mjs | 128 ++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 agents/sage/work/vikunja-setup/2026-10-11_q80-delete.json create mode 100644 agents/sage/work/vikunja-setup/q80-delete.mjs diff --git a/agents/sage/work/vikunja-setup/2026-10-11_q80-delete.json b/agents/sage/work/vikunja-setup/2026-10-11_q80-delete.json new file mode 100644 index 000000000..073c78f5f --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-11_q80-delete.json @@ -0,0 +1,162 @@ +{ + "at": "2026-10-11T19:45:07.470Z", + "project": 19, + "view": 76, + "steps": [ + { + "at": "2026-10-11T19:45:07.657Z", + "op": "read board", + "view": { + "default": 159, + "done": 57, + "mode": "manual" + }, + "buckets": [ + { + "id": 55, + "title": "To-Do", + "tasks": 0 + }, + { + "id": 56, + "title": "Doing", + "tasks": 0 + }, + { + "id": 57, + "title": "done", + "tasks": 13 + }, + { + "id": 158, + "title": "Backlog", + "tasks": 0 + }, + { + "id": 159, + "title": "todo", + "tasks": 13 + }, + { + "id": 160, + "title": "Pending", + "tasks": 0 + }, + { + "id": 161, + "title": "in-progress", + "tasks": 3 + }, + { + "id": 248, + "title": "in-review", + "tasks": 0 + }, + { + "id": 162, + "title": "blocked", + "tasks": 1 + } + ] + }, + { + "at": "2026-10-11T19:45:07.904Z", + "op": "mint", + "id": 10, + "title": "mosaic-stack-pm-q80-bucket-delete-once-20261011T1945", + "owner": 6, + "expires": "2026-10-11T20:15:07Z", + "permissions": { + "projects": [ + "views_buckets_delete" + ] + } + }, + { + "at": "2026-10-11T19:45:07.951Z", + "op": "delete bucket", + "id": 55, + "title": "To-Do", + "inverse": "PUT /api/v1/projects/19/views/76/buckets title \"To-Do\" (pm key; new id)", + "status": 200 + }, + { + "at": "2026-10-11T19:45:08.010Z", + "op": "delete bucket", + "id": 56, + "title": "Doing", + "inverse": "PUT /api/v1/projects/19/views/76/buckets title \"Doing\" (pm key; new id)", + "status": 200 + }, + { + "at": "2026-10-11T19:45:08.026Z", + "op": "delete bucket", + "id": 158, + "title": "Backlog", + "inverse": "PUT /api/v1/projects/19/views/76/buckets title \"Backlog\" (pm key; new id)", + "status": 200 + }, + { + "at": "2026-10-11T19:45:08.083Z", + "op": "delete bucket", + "id": 160, + "title": "Pending", + "inverse": "PUT /api/v1/projects/19/views/76/buckets title \"Pending\" (pm key; new id)", + "status": 200 + }, + { + "at": "2026-10-11T19:45:08.155Z", + "op": "revoke", + "id": 10, + "status": 204 + } + ], + "problems": [], + "revokeCheck": { + "liveKeyGet": [ + 401, + 11 + ], + "revokedKeyGet": [ + 401, + 11 + ], + "pmKeysNow": [ + 8 + ] + }, + "after": { + "view": { + "default": 159, + "done": 57, + "mode": "manual" + }, + "buckets": [ + { + "id": 57, + "title": "done", + "tasks": 13 + }, + { + "id": 159, + "title": "todo", + "tasks": 13 + }, + { + "id": 161, + "title": "in-progress", + "tasks": 3 + }, + { + "id": 248, + "title": "in-review", + "tasks": 0 + }, + { + "id": 162, + "title": "blocked", + "tasks": 1 + } + ] + } +} diff --git a/agents/sage/work/vikunja-setup/q80-delete.mjs b/agents/sage/work/vikunja-setup/q80-delete.mjs new file mode 100644 index 000000000..da738bdf7 --- /dev/null +++ b/agents/sage/work/vikunja-setup/q80-delete.mjs @@ -0,0 +1,128 @@ +// Sage, 2026-10-11: Q80 (Jason: "Have the PM decide. Then fix"; project-manager decided to delete +// the four extra buckets on #19 view 76; Mos ruled no standing delete scope on any bot key). +// svc-mosaic-stack mints a one-time key for the pm user with only projects.views_buckets_delete and +// a 30-minute expiry, deletes buckets 55, 56, 158 and 160, and revokes the key at once. The key stays +// in memory and is never written or printed. Before the mint, the sync key reads the whole board +// (done tasks included) and refuses unless the four buckets hold no task and the five adapter buckets +// are in place. After, the sync key reads the board back. Rollback: the pm key recreates an empty +// bucket by title (PUT /api/v1/projects/19/views/76/buckets); the old ids don't come back. +// Usage: node q80-delete.mjs https://tasks.woltje.com SVC_PASSWORD_FILE SYNC_TOKEN_FILE OUT_JSON +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +const [ORIGIN, PW, SYNC, 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 P = 19, V = 76, PM_USER = 6, MISSING = 2147483647; +const DROP = [55, 56, 158, 160]; +const KEEP = { 57: "done", 159: "todo", 161: "in-progress", 248: "in-review", 162: "blocked" }; +const SCOPE = { projects: ["views_buckets_delete"] }; +async function api(base, method, path, body, auth) { + const headers = { "Content-Type": "application/json" }; + if (auth) headers.Authorization = `Bearer ${auth}`; + const r = await fetch(`${ORIGIN}/api/${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 list = (j) => j?.items ?? (Array.isArray(j) ? j : []); +const sync = readFileSync(SYNC, "utf8").trim(); +const secrets = [sync]; +async function board() { + const view = list(must(await api("v2", "GET", `/projects/${P}/views?per_page=50`, undefined, sync), "views")).find((v) => v.id === V); + if (!view) throw new Error("view 76 not listed"); + const buckets = list(must(await api("v2", "GET", `/projects/${P}/views/${V}/buckets?per_page=50`, undefined, sync), "buckets")); + const tasks = Object.fromEntries(buckets.map((b) => [b.id, []])); + for (let page = 1; page < 100; page++) { + let more = false; + for (const b of list(must(await api("v2", "GET", `/projects/${P}/views/${V}/buckets/tasks?per_page=50&page=${page}`, undefined, sync), "board"))) { + (tasks[b.id] ??= []).push(...(b.tasks ?? []).map((t) => t.id)); + if (Number.isSafeInteger(b.count) ? b.count > page * 50 : (b.tasks ?? []).length >= 50) more = true; + } + if (!more) break; + } + for (const id of Object.keys(tasks)) tasks[id].sort((a, b) => a - b); + return { + view: { default: view.default_bucket_id, done: view.done_bucket_id, mode: view.bucket_configuration_mode }, + buckets: buckets.map((b) => ({ id: b.id, title: b.title, tasks: tasks[b.id].length })), + tasks, + }; +} +const rec = { at: new Date().toISOString(), project: P, view: V, steps: [], problems: [] }; +const save = () => { + 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); +}; +const step = (s) => { rec.steps.push({ at: new Date().toISOString(), ...s }); save(); }; + +// 1. Precondition, before any key exists. +const before = await board(); +writeFileSync(OUT, "", { flag: "wx" }); +step({ op: "read board", view: before.view, buckets: before.buckets }); +for (const id of DROP) { + const b = before.buckets.find((x) => x.id === id); + if (!b) throw new Error(`bucket ${id} not on view 76; refusing`); + if (b.tasks !== 0) { rec.problems.push(`bucket ${id} holds ${b.tasks} task(s): ${before.tasks[id].join(", ")}; stopped before the mint`); save(); process.exit(3); } +} +for (const [id, title] of Object.entries(KEEP)) if (before.buckets.find((x) => x.id === Number(id))?.title !== title) throw new Error(`bucket ${id} is not ${title}; refusing`); +if (before.view.default !== 159 || before.view.done !== 57) throw new Error("view 76 default or done moved; refusing"); + +// 2. Mint. +const password = readFileSync(PW, "utf8").trim(); +const svc = must(await api("v2", "POST", "/login", { username: "svc-mosaic-stack", password }), "login svc").token; +if (!svc) throw new Error("login svc: no token field"); +secrets.push(password, svc); +const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); +const title = `mosaic-stack-pm-q80-bucket-delete-once-${rec.at.slice(0, 16).replace(/[-:]/g, "")}`; +const m = await api("v2", "POST", "/tokens", { title, owner_id: PM_USER, expires_at: expires, permissions: SCOPE }, svc); +if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`); +const once = m.json.token; +secrets.push(once); +const keyId = m.json.id; +step({ op: "mint", id: keyId, title, owner: PM_USER, expires: m.json.expires_at, permissions: m.json.permissions ?? null }); +const scopeOk = JSON.stringify(m.json.permissions) === JSON.stringify(SCOPE); +// A GET with the live key, for comparison with the same GET after the revoke. +const liveControl = await api("v2", "GET", `/tasks/${MISSING}`, undefined, once); + +let revoked = false; +const revoke = async () => { + if (revoked) return; + const r = await api("v2", "DELETE", `/tokens/${keyId}`, undefined, svc); + revoked = r.status < 300; + step({ op: "revoke", id: keyId, status: r.status }); +}; +try { + if (!scopeOk) throw new Error(`minted scope ${JSON.stringify(m.json.permissions)} is not exactly ${JSON.stringify(SCOPE)}`); + // 3. Delete, each logged before it runs. + for (const id of DROP) { + step({ op: "delete bucket", id, title: before.buckets.find((x) => x.id === id).title, inverse: `PUT /api/v1/projects/${P}/views/${V}/buckets title ${JSON.stringify(before.buckets.find((x) => x.id === id).title)} (pm key; new id)` }); + const r = await api("v1", "DELETE", `/projects/${P}/views/${V}/buckets/${id}`, undefined, once); + rec.steps.at(-1).status = r.status; + if (r.status >= 300) { rec.steps.at(-1).error = `${r.json?.code ?? ""} ${r.json?.message ?? ""}`; save(); throw new Error(`delete ${id}: ${r.status}`); } + save(); + } +} catch (e) { + rec.problems.push(`stopped: ${e.message}`); +} finally { + // 4. Revoke at once, whatever happened. + await revoke(); +} +const after401 = await api("v2", "GET", `/tasks/${MISSING}`, undefined, once); +const keysNow = list(must(await api("v2", "GET", `/tokens?owner_id=${PM_USER}&per_page=50`, undefined, svc), "keys pm")).map((k) => k.id); +rec.revokeCheck = { liveKeyGet: [liveControl.status, liveControl.json?.code ?? null], revokedKeyGet: [after401.status, after401.json?.code ?? null], pmKeysNow: keysNow }; +if (!revoked) rec.problems.push("revoke failed; revoke key by hand"); +if (after401.status !== 401) rec.problems.push(`revoked key answers ${after401.status}`); +if (keysNow.includes(keyId)) rec.problems.push(`key ${keyId} still listed for the pm user`); + +// 5. Read back. +const after = await board(); +rec.after = { view: after.view, buckets: after.buckets }; +const ids = after.buckets.map((b) => b.id).sort((a, b) => a - b).join(); +if (ids !== Object.keys(KEEP).map(Number).sort((a, b) => a - b).join()) rec.problems.push(`view 76 buckets are ${ids}`); +for (const [id, title] of Object.entries(KEEP)) { + if (after.buckets.find((x) => x.id === Number(id))?.title !== title) rec.problems.push(`bucket ${id} is not ${title}`); + if (JSON.stringify(after.tasks[id] ?? []) !== JSON.stringify(before.tasks[id])) rec.problems.push(`bucket ${id} tasks changed`); +} +if (after.view.default !== 159 || after.view.done !== 57) rec.problems.push("default or done moved"); +save(); +process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); +if (rec.problems.length) process.exit(2);