// Sage, 2026-10-09: runbook sections 2 and 3 on tasks.mosaicstack.dev through the v2 API (lead decision 75). // Accounts: mosaic-stack-owner owns the project, svc-mosaic-stack owns the five bots and nothing else. // Passwords and tokens go straight to 0600 files (flag wx, never overwritten) and never reach stdout. // Usage: node setup.mjs https://tasks.mosaicstack.dev SECRETS_DIR OUT_JSON import { randomBytes } from "node:crypto"; import { writeFileSync, existsSync } from "node:fs"; const [ORIGIN, S, OUT] = process.argv.slice(2); if (!/^https:\/\/tasks\.mosaicstack\.dev$/.test(ORIGIN)) throw new Error("tasks.mosaicstack.dev only"); const BASE = ORIGIN + "/api/v2"; const BIZ = "mosaic-stack", EXP = "2027-01-07T00:00:00Z", TODAY = new Date().toISOString().slice(0, 10); const secrets = []; 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) }); 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 keep = (file, value) => { writeFileSync(`${S}/${file}`, value, { flag: "wx", mode: 0o600 }); secrets.push(value); }; for (const f of ["vikunja-owner.password", "svc-vikunja.password"]) if (existsSync(`${S}/${f}`)) throw new Error(`${f} exists; refusing to rerun`); async function account(username, file) { const password = randomBytes(24).toString("base64url"); keep(file, password); const u = must(await api("POST", "/register", { username, email: `${username}@noreply.mosaicstack.dev`, password }), `register ${username}`); const tok = must(await api("POST", "/login", { username, password }), `login ${username}`).token; if (!tok) throw new Error(`login ${username}: no token field`); secrets.push(tok); return { id: u.id, tok }; } const owner = await account(`${BIZ}-owner`, "vikunja-owner.password"); const svc = await account(`svc-${BIZ}`, "svc-vikunja.password"); const rec = { at: new Date().toISOString(), origin: ORIGIN, owner: { username: `${BIZ}-owner`, id: owner.id }, svc: { username: `svc-${BIZ}`, id: svc.id } }; const P = must(await api("POST", "/projects", { title: BIZ }, owner.tok), "project").id; const K = must(await api("GET", `/projects/${P}/views`, undefined, owner.tok), "views").items.find((v) => v.view_kind === "kanban").id; const rename = { "To-Do": "todo", Doing: "in-progress", Done: "done" }; for (const b of must(await api("GET", `/projects/${P}/views/${K}/buckets`, undefined, owner.tok), "buckets").items) must(await api("PUT", `/projects/${P}/views/${K}/buckets/${b.id}`, { title: rename[b.title] ?? b.title }, owner.tok), `rename ${b.title}`); for (const title of ["in-review", "blocked"]) must(await api("POST", `/projects/${P}/views/${K}/buckets`, { title }, owner.tok), title); const buckets = Object.fromEntries(must(await api("GET", `/projects/${P}/views/${K}/buckets`, undefined, owner.tok), "buckets").items.map((b) => [b.title, b.id])); const view = must(await api("GET", `/projects/${P}/views/${K}`, undefined, owner.tok), "view"); rec.project = { id: P, kanbanView: K, buckets, doneBucket: view.done_bucket_id, defaultBucket: view.default_bucket_id, bucketConfigMode: view.bucket_configuration_mode }; must(await api("POST", `/projects/${P}/users`, { username: "jason.woltje", permission: 2 }, owner.tok), "share jason.woltje"); const SCOPES = { sync: { projects: ["read_one", "views_buckets", "views_buckets_tasks_get"], projects_views: ["read_all"], tasks: ["read_all", "read_one"], tasks_comments: ["read_all"] }, pm: { tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"], tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"] }, worker: { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] }, }; rec.bots = {}; for (const r of ["pm", "cto", "coder", "reviewer", "sync"]) { const b = must(await api("POST", "/user/bots", { username: `bot-${BIZ}-${r}`, name: `${BIZ} ${r}` }, svc.tok), `bot ${r}`); const sh = must(await api("POST", `/projects/${P}/users`, { username: b.username, permission: r === "sync" ? 0 : 1 }, owner.tok), `share ${r}`); const t = await api("POST", "/tokens", { title: `${BIZ}-${r}-${TODAY}`, owner_id: b.id, expires_at: EXP, permissions: SCOPES[r] ?? SCOPES.worker }, svc.tok); if (t.status !== 201 || typeof t.json?.token !== "string") throw new Error(`mint ${r}: ${t.status} ${t.json?.code ?? ""}`); keep(`${r}-vikunja.token`, t.json.token); rec.bots[r] = { id: b.id, username: b.username, permission: sh.permission, tokenId: t.json.id, expires: t.json.expires_at, startsTk: t.json.token.startsWith("tk_") }; } 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);