vikunja: Q61a re-mint, pm key 8 and sync key 9; rows A and B brief; decision 86 (sage)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 13:12:39 -05:00
co-authored by Claude Opus 5.5
parent b644f3c230
commit 8060426035
4 changed files with 413 additions and 0 deletions
@@ -0,0 +1,145 @@
{
"at": "2026-10-11T18:11:17.758Z",
"origin": "https://tasks.woltje.com",
"by": "svc-mosaic-stack",
"keys": {
"pm": {
"id": 8,
"title": "mosaic-stack-pm-2026-10-11-q61a",
"expires": "2027-01-07T00:00:00Z",
"before": {
"labels": [
"read_all"
],
"projects": [
"read_all",
"read_one",
"update",
"views_buckets_tasks"
],
"tasks": [
"create",
"read_one",
"update"
],
"tasks_assignees": [
"create",
"delete"
],
"tasks_comments": [
"create"
],
"tasks_labels": [
"create",
"delete"
],
"tasks_relations": [
"create",
"delete"
]
},
"permissions": {
"labels": [
"read_all"
],
"projects": [
"create",
"read_all",
"read_one",
"update",
"views_buckets",
"views_buckets_post",
"views_buckets_put",
"views_buckets_tasks"
],
"projects_views": [
"read_all",
"update"
],
"tasks": [
"create",
"read_one",
"update"
],
"tasks_assignees": [
"create",
"delete"
],
"tasks_comments": [
"create"
],
"tasks_labels": [
"create",
"delete"
],
"tasks_relations": [
"create",
"delete"
]
},
"control": [
404,
4002
],
"projectsList": 200,
"fileMode": "600",
"revoked": 7,
"oldKeyAfterRevoke": 401,
"keysNow": [
8
]
},
"sync": {
"id": 9,
"title": "mosaic-stack-sync-2026-10-11-q61a",
"expires": "2027-01-07T00:00:00Z",
"before": {
"projects": [
"read_one",
"views_buckets",
"views_buckets_tasks_get"
],
"projects_views": [
"read_all"
],
"tasks": [
"read_all",
"read_one"
],
"tasks_comments": [
"read_all"
]
},
"permissions": {
"projects": [
"read_all",
"read_one",
"views_buckets",
"views_buckets_tasks_get"
],
"projects_views": [
"read_all"
],
"tasks": [
"read_all",
"read_one"
],
"tasks_comments": [
"read_all"
]
},
"control": [
404,
4002
],
"projectsList": 200,
"fileMode": "600",
"revoked": 6,
"oldKeyAfterRevoke": 401,
"keysNow": [
9
]
}
},
"problems": []
}
+87
View File
@@ -0,0 +1,87 @@
// Sage, 2026-10-11: Q61a (Jason, relayed by Mos: "full agentic access", no task or project delete,
// no share route). svc-mosaic-stack re-mints the pm and sync keys with added scopes: the pm gets
// projects create plus bucket and view writes so the agents install #19's buckets, and the sync bot
// gets projects read_all to find child projects. Each new key goes to a .new file (wx, 0600), is
// checked with reads only, renamed over the old file, and the old key is revoked. No delete or write
// is tried as a test; the bus startup probes run on the next start. Refuses if a bot's keys aren't
// exactly the expected old key, or if a scope set holds a forbidden group or permission.
// Usage: node remint.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_DIR OUT_JSON
import { readFileSync, writeFileSync, existsSync, renameSync, statSync } from "node:fs";
const [ORIGIN, PW, DIR, 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 BASE = ORIGIN + "/api/v2";
const EXP = "2027-01-07T00:00:00Z", MISSING = 2147483647;
const KEYS = {
pm: { owner: 6, old: 7, add: { projects: ["create", "views_buckets", "views_buckets_put", "views_buckets_post"], projects_views: ["read_all", "update"] } },
sync: { owner: 10, old: 6, add: { projects: ["read_all"] } },
};
const FORBIDDEN_GROUPS = ["projects_shares", "projects_teams", "projects_users", "projects_webhooks", "teams", "users", "tokens"];
// Delete on tasks and projects (and their views and buckets) is Jason's alone. Unassign, unlink and
// unlabel are deletes of a join row, not of a task, and the pm already holds them.
const forbidden = (g, p) => FORBIDDEN_GROUPS.includes(g) || ((g === "tasks" || g.startsWith("projects")) && /delete|duplicate/.test(p));
async function api(method, path, body, auth) {
const headers = { "Content-Type": "application/json" };
if (auth) headers.Authorization = `Bearer ${auth}`;
const r = await fetch(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 items = (j, l) => { if (!Array.isArray(j?.items)) throw new Error(`${l}: no items array`); return j.items; };
const norm = (p) => Object.fromEntries(Object.entries(p).map(([g, l]) => [g, [...new Set(l)].sort()]).sort(([a], [b]) => a.localeCompare(b)));
const password = readFileSync(PW, "utf8").trim();
const tok = must(await api("POST", "/login", { username: "svc-mosaic-stack", password }), "login svc").token;
if (!tok) throw new Error("login svc: no token field");
const secrets = [password, tok];
const keysOf = (owner) => api("GET", `/tokens?owner_id=${owner}&per_page=50`, undefined, tok).then((r) => items(must(r, `keys ${owner}`), `keys ${owner}`));
// Preconditions for both keys before any write.
const plan = {};
for (const [role, k] of Object.entries(KEYS)) {
const file = `${DIR}/${role}-vikunja.token`;
if (existsSync(`${file}.new`)) throw new Error(`${role}-vikunja.token.new exists; refusing`);
const l = await keysOf(k.owner);
if (l.map((x) => x.id).join() !== String(k.old)) throw new Error(`${role} bot keys are not exactly [${k.old}]; refusing`);
const perms = structuredClone(l[0].permissions);
for (const [g, ps] of Object.entries(k.add)) perms[g] = [...(perms[g] ?? []), ...ps];
for (const [g, ps] of Object.entries(perms)) for (const p of ps) if (forbidden(g, p)) throw new Error(`${role}: forbidden scope ${g}.${p}; refusing`);
const oldValue = readFileSync(file, "utf8").trim();
secrets.push(oldValue);
plan[role] = { file, oldValue, before: norm(l[0].permissions), perms: norm(perms) };
}
const rec = { at: new Date().toISOString(), origin: ORIGIN, by: "svc-mosaic-stack", keys: {}, problems: [] };
for (const [role, k] of Object.entries(KEYS)) {
const { file, oldValue, before, perms } = plan[role];
const title = `mosaic-stack-${role}-${new Date().toISOString().slice(0, 10)}-q61a`;
const m = await api("POST", "/tokens", { title, owner_id: k.owner, expires_at: EXP, permissions: perms }, tok);
if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint ${role}: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`);
const value = m.json.token;
secrets.push(value);
writeFileSync(`${file}.new`, value, { flag: "wx", mode: 0o600 });
const ctl = await api("GET", `/tasks/${MISSING}`, undefined, value);
const list = await api("GET", "/projects?per_page=1", undefined, value);
const r = { id: m.json.id, title, expires: m.json.expires_at, before, permissions: norm(m.json.permissions ?? {}), control: [ctl.status, ctl.json?.code], projectsList: list.status };
rec.keys[role] = r;
if (ctl.status !== 404 || ctl.json?.code !== 4002 || list.status !== 200) throw new Error(`new ${role} key check failed: ${JSON.stringify(r)}; .new kept, old key ${k.old} untouched`);
if (JSON.stringify(r.permissions) !== JSON.stringify(perms)) rec.problems.push(`${role} permissions differ from the request`);
renameSync(`${file}.new`, file);
r.fileMode = (statSync(file).mode & 0o777).toString(8);
if (readFileSync(file, "utf8").trim() !== value) rec.problems.push(`${role} file does not hold the new key`);
must(await api("DELETE", `/tokens/${k.old}`, undefined, tok), `revoke old ${role} key`);
r.revoked = k.old;
r.oldKeyAfterRevoke = (await api("GET", `/tasks/${MISSING}`, undefined, oldValue)).status;
r.keysNow = (await keysOf(k.owner)).map((x) => x.id);
if (r.expires !== EXP) rec.problems.push(`${role} expiry differs`);
if (r.oldKeyAfterRevoke !== 401) rec.problems.push(`old ${role} key still answers`);
if (r.keysNow.join() !== String(r.id)) rec.problems.push(`${role} bot keys are not exactly the new one`);
if (r.fileMode !== "600") rec.problems.push(`${role} file mode not 600`);
}
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, { flag: "wx" });
process.stdout.write(out);
if (rec.problems.length) process.exit(2);