// 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);