Files
stack/agents/sage/work/vikunja-setup/setup.mjs
T
jason.woltjeandClaude Opus 5.5 a6382f7bdf docs(slice1): tasks.mosaicstack.dev on Vikunja 2.7.0, runbook sections 2-3 by Sage (row 35, #1517, lead decision 75)
Jason asked for tasks.mosaicstack.dev operational with agents configured in it.
Sage backed it up, tested the restore, pinned 2.7.0 (infra PR #325) and ran
sections 2 and 3 through the API: owner id 4, svc-mosaic-stack id 5, project 32,
bots 6-10, scoped tokens to 2027-01-07. Probes pass. Scope is Mosaic Stack only
(Mos relaying Jason, his Q9 open). OIDC has been broken since a 2026-04-27
NetworkPolicy; infra PR #326 is with ops-01. BUILD-LOG also records the cert
renewal fix and the last-applied annotation slip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:49:40 -05:00

65 lines
5.1 KiB
JavaScript

// 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);