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]>
This commit is contained in:
2026-10-09 17:49:40 -05:00
co-authored by Claude Opus 5.5
parent ecbf59e555
commit a6382f7bdf
10 changed files with 314 additions and 17 deletions
@@ -0,0 +1,68 @@
{
"at": "2026-10-09T22:45:09.920Z",
"origin": "https://tasks.mosaicstack.dev",
"owner": {
"username": "mosaic-stack-owner",
"id": 4
},
"svc": {
"username": "svc-mosaic-stack",
"id": 5
},
"project": {
"id": 32,
"kanbanView": 152,
"buckets": {
"in-progress": 238,
"todo": 237,
"done": 239,
"in-review": 240,
"blocked": 241
},
"doneBucket": 239,
"defaultBucket": 237,
"bucketConfigMode": "manual"
},
"bots": {
"pm": {
"id": 6,
"username": "bot-mosaic-stack-pm",
"permission": 1,
"tokenId": 2,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true
},
"cto": {
"id": 7,
"username": "bot-mosaic-stack-cto",
"permission": 1,
"tokenId": 3,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true
},
"coder": {
"id": 8,
"username": "bot-mosaic-stack-coder",
"permission": 1,
"tokenId": 4,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true
},
"reviewer": {
"id": 9,
"username": "bot-mosaic-stack-reviewer",
"permission": 1,
"tokenId": 5,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true
},
"sync": {
"id": 10,
"username": "bot-mosaic-stack-sync",
"permission": 0,
"tokenId": 6,
"expires": "2027-01-07T00:00:00Z",
"startsTk": true
}
}
}
@@ -0,0 +1,42 @@
Sage, 2026-10-09 (UTC). tasks.mosaicstack.dev: upgrade to Vikunja 2.7.0, then runbook sections 2 and 3 (lead decision 75).
Statuses and ids only. No password or token appears here.
Backup, 22:34Z, workstation: /mnt/storage/backups/tasks-mosaicstack-dev/20261009T223450Z-pre-2.7.0/ (dirs 0700, files 0600)
a2173459fca535202b2851dd6bf32dde9e9485aa897406f405b02483de5b2c5f vikunja.pgdump (pg_dump -Fc, 154770 bytes, 34 TABLE DATA entries)
c9a2f6d850a29a4154a8302ed5cb6287275cee9b575a1dcdaaf90c5ec9c62bc4 files.tar (vikunja-files PVC through a read-only busybox pod, 2 entries)
Restore test: pg_restore into scratch DB sage_restoretest in postgres-0, exit 0.
tasks live=142 restored=142; projects live=29 restored=29; users live=3 restored=3. Scratch DB dropped.
Upgrade: infra PR #325 (mosaicstack/infrastructure), one line in k8s/applications/vikunja/base/vikunja.yaml,
vikunja/vikunja:latest@sha256:f13103b0... (2.1.0) -> vikunja/vikunja:2.7.0@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc
Merged by Sage as author; ArgoCD (selfHeal) rolled it out. Mos accepted it after the fact and set the rule that infra PRs get independent review.
22:40:06Z "Running migrations"; 22:40:12Z "Ran all migrations successfully."
/api/v1/info: version v2.7.0, local auth on, registration on, OIDC on with providers [].
Counts after: tasks 142, projects 29, users 3. TLS verify 0. Rollback: revert #325, then pg_restore vikunja.pgdump.
OIDC finding (predates the upgrade): NetworkPolicy vikunja (created 2026-04-27) allows 443 only outside RFC 1918;
auth.diversecanvas.com resolves to 10.1.1.222; discovery times out. Last users.updated 2026-03-05.
Infra PR #326 adds 10.1.1.222/32:443. Not merged by Sage; Mos routed it to ops-01 for review.
Sections 2 and 3, 22:45Z, setup.mjs (output in 2026-10-09_ids.json):
mosaic-stack-owner id 4 (owns project 32); svc-mosaic-stack id 5 (owns bots 6-10, no labels, no projects)
project mosaic-stack id 32, kanban view 152, buckets todo 237, in-progress 238, done 239, in-review 240, blocked 241
done bucket 239, default bucket 237, bucket configuration manual
shared with jason.woltje, permission 2 (admin)
bots pm 6, cto 7, coder 8, reviewer 9 at permission 1; sync 10 at permission 0
tokens ids 2-6, expires 2027-01-07T00:00:00Z, all start tk_, 43 bytes, 0600
Passwords moved afterwards to ~/.config/mosaic-dev/secrets/vikunja-admin/ (0700 dir, 0600 files, 32 bytes each).
Instance label count: 0, so the label leg of decision 68's probe has nothing to leak yet.
probe.mjs (32 vs project 1):
pm own=401 other=401 projects=401 labels=200:[] otherViews=401
cto/coder/reviewer own=401 other=401 projects=401 labels=401 otherViews=401
sync own=200 other=403 projects=401 labels=401 otherViews=403
401 is a route outside the token's scopes; 403 is a project the bot isn't shared into.
roundtrip.mjs (project 32, foreign task 1):
pm create 201 (task 143)
cto/coder/reviewer read=200 comment=201 foreignTask=403 delete=401
sync read=200 comment=401 foreignTask=403
pm foreignTask=403
owner cleanup delete 204
+21
View File
@@ -0,0 +1,21 @@
// Sage, 2026-10-09: isolation probe for the mosaic-stack bots on tasks.mosaicstack.dev (decisions 66 and 68).
// Reads each token file in-process and prints statuses and ids only.
// Usage: node probe.mjs SECRETS_DIR PROJECT_ID OTHER_PROJECT_ID
import { readFileSync } from "node:fs";
const [S, P, OTHER] = process.argv.slice(2);
const BASE = "https://tasks.mosaicstack.dev/api/v2";
async function get(path, tok) {
const r = await fetch(BASE + path, { headers: { Authorization: `Bearer ${tok}` } });
let j = null; try { j = await r.json(); } catch {}
return { s: r.status, j };
}
const ids = (j) => (j?.items ?? (Array.isArray(j) ? j : [])).map((x) => x.id);
for (const r of ["pm", "cto", "coder", "reviewer", "sync"]) {
const tok = readFileSync(`${S}/${r}-vikunja.token`, "utf8").trim();
const own = await get(`/projects/${P}`, tok);
const other = await get(`/projects/${OTHER}`, tok);
const list = await get(`/projects`, tok);
const labels = await get(`/labels`, tok);
const tasks = await get(`/projects/${OTHER}/views`, tok);
console.log(r, `own=${own.s}`, `other=${other.s}`, `projects=${list.s}:${JSON.stringify(ids(list.j))}`, `labels=${labels.s}:${JSON.stringify(ids(labels.j))}`, `otherViews=${tasks.s}`);
}
@@ -0,0 +1,35 @@
// Sage, 2026-10-09: task round trip for the mosaic-stack bots on tasks.mosaicstack.dev, then cleanup as the owner.
// Prints statuses and ids only. Usage: node roundtrip.mjs SECRETS_DIR PROJECT_ID FOREIGN_TASK_ID
import { readFileSync } from "node:fs";
const [S, P, FOREIGN] = process.argv.slice(2);
const BASE = "https://tasks.mosaicstack.dev/api/v2";
const rd = (f) => readFileSync(`${S}/${f}`, "utf8").trim();
async function call(method, path, tok, body) {
const headers = { Authorization: `Bearer ${tok}`, "Content-Type": "application/json" };
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
let j = null; try { j = await r.json(); } catch {}
return { s: r.status, j };
}
const login = await fetch(BASE + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "mosaic-stack-owner", password: rd("vikunja-owner.password") }) });
const owner = (await login.json()).token;
const pm = rd("pm-vikunja.token");
const made = await call("POST", `/projects/${P}/tasks`, pm, { title: "probe: delete me (sage 2026-10-09)" });
console.log("pm create", made.s, made.j?.id, "bucket", made.j?.bucket_id);
const T = made.j?.id;
for (const r of ["cto", "coder", "reviewer"]) {
const tok = rd(`${r}-vikunja.token`);
const read = await call("GET", `/tasks/${T}`, tok);
const cmt = await call("POST", `/tasks/${T}/comments`, tok, { comment: `probe comment from ${r}` });
const foreign = await call("GET", `/tasks/${FOREIGN}`, tok);
const del = await call("DELETE", `/tasks/${T}`, tok);
console.log(r, `read=${read.s}`, `comment=${cmt.s}`, `foreignTask=${foreign.s}`, `delete=${del.s}`);
}
const sync = rd("sync-vikunja.token");
const sread = await call("GET", `/tasks/${T}`, sync);
const swrite = await call("POST", `/tasks/${T}/comments`, sync, { comment: "sync should not write" });
const sforeign = await call("GET", `/tasks/${FOREIGN}`, sync);
console.log("sync", `read=${sread.s}`, `comment=${swrite.s}`, `foreignTask=${sforeign.s}`);
const pforeign = await call("GET", `/tasks/${FOREIGN}`, pm);
console.log("pm", `foreignTask=${pforeign.s}`);
const gone = await call("DELETE", `/tasks/${T}`, owner);
console.log("owner cleanup delete", gone.s);
+64
View File
@@ -0,0 +1,64 @@
// 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);