sessions, vikunja-setup: #198 launchpad-pm bot key minted, team 1 add via ops-01

mint-bot.mjs generalizes mint-mos.mjs (user, name and key title as
arguments; slot written without a trailing newline). bot-launchpad-pm is
user 12, key 12, scopes identical to key 11 (Q61a cut). The key reads no
project until ops-01 adds user 12 to team 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 12:46:46 -05:00
co-authored by Claude Opus 5.5
parent c8a4d46341
commit e22e0d73f3
3 changed files with 187 additions and 0 deletions
@@ -0,0 +1,103 @@
{
"at": "2026-10-12T17:45:23.564Z",
"user": "bot-launchpad-pm",
"problems": [],
"permissions": {
"projects": [
"background",
"background_get",
"backgrounds_upload",
"create",
"projectusers",
"read_all",
"read_one",
"tasks_by_index",
"update",
"users_search",
"views_buckets",
"views_buckets_post",
"views_buckets_put",
"views_buckets_tasks",
"views_buckets_tasks_get"
],
"projects_views": [
"create",
"read_all",
"read_one",
"update"
],
"projects_views_tasks": [
"read_all"
],
"tasks": [
"create",
"create_bulk",
"position",
"read",
"read_all",
"read_one",
"update",
"update_bulk"
],
"tasks_assignees": [
"create",
"delete",
"read_all",
"update_bulk"
],
"tasks_attachments": [
"create",
"read_all",
"read_one"
],
"tasks_comments": [
"create",
"read_all",
"read_one",
"update"
],
"tasks_labels": [
"create",
"delete",
"read_all",
"update_bulk"
],
"tasks_relations": [
"create",
"delete"
],
"labels": [
"create",
"read_all",
"read_one",
"update"
],
"filters": [
"create",
"read_one",
"update"
],
"reactions": [
"create",
"delete",
"read_all"
]
},
"botCreate": 201,
"botId": 12,
"tokenCreate": 201,
"tokenId": 12,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true,
"file": "/home/jwoltje/.config/mosaic-dev/secrets/mosaic-stack/launchpad-pm-vikunja.token",
"mode": "600",
"inverse": "svc: DELETE /api/v1/tokens/12",
"keysNow": [
12
],
"newKeyProjects": {
"status": 200,
"count": 0
},
"newKeyTask198": 403
}
@@ -0,0 +1,83 @@
// Sage, 2026-10-12: Vikunja #198, keys for seats that have none (Mos sage-0085), generalized from
// mint-mos.mjs (#151). svc-mosaic-stack mints one bot user and one API key for it. The key goes
// straight to a 0600 file (flag wx, never overwritten, no trailing newline, like the other slots)
// and never reaches stdout. Scopes are Mos's catalogue (projects*, tasks*, labels,
// filters, reactions) cut to Q61a's limit: no delete or duplicate on tasks, projects, views, buckets,
// labels or filters, and no share, team, user, token, webhook, migration, other or notification group.
// The deletes kept are join rows the pm already holds (assignees, labels, relations) and own reactions.
// svc left both teams on 2026-10-11 (decision 85), so ops-01 adds the bot to team 1 (ops01-0096
// pattern); until then the key reaches no project. Inverse: svc revokes the key (DELETE /api/v1/tokens/:id).
// Usage: node mint-bot.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_FILE OUT_JSON USER NAME TITLE
import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
const [ORIGIN, PW, TOKEN_FILE, OUT, USER, NAME, TITLE] = 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");
if (!TOKEN_FILE || existsSync(TOKEN_FILE)) throw new Error("TOKEN_FILE missing or exists; refusing");
if (!/^bot-[a-z0-9-]{3,40}$/.test(USER ?? "") || !NAME || !/^[a-z0-9-]{8,72}$/.test(TITLE ?? "")) throw new Error("USER, NAME or TITLE missing or malformed");
const EXP = "2027-01-07T00:00:00Z";
const GROUPS = ["projects", "projects_views", "projects_views_tasks", "tasks", "tasks_assignees", "tasks_attachments",
"tasks_comments", "tasks_labels", "tasks_relations", "labels", "filters", "reactions"];
const JOIN_DELETES = ["tasks_assignees", "tasks_labels", "tasks_relations", "reactions"];
const forbidden = (g, p) => !GROUPS.includes(g) || (/delete|duplicate/.test(p) && !JOIN_DELETES.includes(g));
async function api(method, path, body, auth) {
const headers = { "Content-Type": "application/json" };
if (auth) headers.Authorization = `Bearer ${auth}`;
const r = await fetch(ORIGIN + 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 password = readFileSync(PW, "utf8").trim();
const tok = must(await api("POST", "/api/v2/login", { username: "svc-mosaic-stack", password }), "login svc").token;
if (!tok) throw new Error("login svc: no token field");
const secrets = [password, tok];
const rec = { at: new Date().toISOString(), user: USER, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (secrets.some((s) => s && out.includes(s))) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
// Preconditions before any write: the bot doesn't exist, and the scope set is clean.
const bots = must(await api("GET", "/api/v1/user/bots", undefined, tok), "list bots");
const botList = bots?.items ?? bots;
if (!Array.isArray(botList)) throw new Error("list bots: no array");
if (botList.some((b) => b.username === USER)) throw new Error(`${USER} exists; refusing`);
const routes = must(await api("GET", "/api/v2/routes", undefined, tok), "routes");
const permissions = {};
for (const g of GROUPS) {
const ps = Object.keys(routes[g] ?? {}).filter((p) => !forbidden(g, p)).sort();
if (ps.length) permissions[g] = ps;
}
for (const [g, ps] of Object.entries(permissions)) for (const p of ps) if (forbidden(g, p)) throw new Error(`forbidden ${g}.${p}`);
rec.permissions = permissions;
writeFileSync(OUT, "", { flag: "wx" });
const b = await api("PUT", "/api/v1/user/bots", { username: USER, name: NAME }, tok);
rec.botCreate = b.status;
if (b.status >= 300 || !Number.isSafeInteger(b.json?.id)) { rec.problems.push(`bot create: ${b.status} ${b.json?.message ?? ""}`); save(); process.exit(2); }
rec.botId = b.json.id;
save();
const t = await api("PUT", "/api/v1/tokens", { title: TITLE, owner_id: rec.botId, expires_at: EXP, permissions }, tok);
rec.tokenCreate = t.status;
if (t.status >= 300 || !Number.isSafeInteger(t.json?.id) || typeof t.json?.token !== "string") { rec.problems.push(`token create: ${t.status} ${t.json?.message ?? ""}`); save(); process.exit(2); }
const key = t.json.token;
secrets.push(key);
writeFileSync(TOKEN_FILE, key, { flag: "wx", mode: 0o600 });
Object.assign(rec, { tokenId: t.json.id, expires: t.json.expires_at, startsTk: key.startsWith("tk_"), file: TOKEN_FILE, mode: (statSync(TOKEN_FILE).mode & 0o777).toString(8),
inverse: `svc: DELETE /api/v1/tokens/${t.json.id}` });
// Read back as svc: the bot holds exactly this key with these scopes.
const keys = must(await api("GET", `/api/v2/tokens?owner_id=${rec.botId}&per_page=50`, undefined, tok), "bot keys").items ?? [];
rec.keysNow = keys.map((k) => k.id);
const back = keys.find((k) => k.id === rec.tokenId);
const norm = (p) => JSON.stringify(Object.fromEntries(Object.entries(p ?? {}).map(([g, l]) => [g, [...l].sort()]).sort(([a], [c]) => a.localeCompare(c))));
if (!back || norm(back.permissions) !== norm(permissions)) rec.problems.push("key read-back scopes differ");
if (rec.keysNow.length !== 1) rec.problems.push("bot keys are not exactly the new one");
// Reads only with the new key. Until ops-01 adds the bot to team 1 it should reach no project.
const k = await api("GET", "/api/v2/projects?per_page=50", undefined, key);
rec.newKeyProjects = { status: k.status, count: (k.json?.items ?? []).length };
const t198 = await api("GET", "/api/v2/tasks/198", undefined, key);
rec.newKeyTask198 = t198.status;
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);