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 <[email protected]>
This commit is contained in:
2026-10-11 14:47:29 -05:00
co-authored by Claude Opus 5.5
parent 122b122374
commit 02da0929c4
2 changed files with 290 additions and 0 deletions
@@ -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
}
]
}
}
@@ -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);