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]>
129 lines
8.0 KiB
JavaScript
129 lines
8.0 KiB
JavaScript
// 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);
|