# Slice 1 identities: Gitea bots, Vikunja bots and their tokens An operator runs this guide once per business (PRD REQ-CRED-1, round 3, 1A and 2A). It creates every service identity slice 1 uses and writes each token to a 0600 file the broker reads. Nothing in v1 mints these tokens for you. Brief: `docs/plans/2026-10-04_slice-1.md`, row SR. Who the operator is depends on the credential. On 2026-10-09 Jason gave the jarvis Gitea token site admin rights and ruled that agents run the steps it covers, so Sage ran section 1 for `mosaic-stack` through the API (lead decision 74). The same day Jason asked for agents configured in tasks.mosaicstack.dev, so Sage upgraded it to 2.7.0 and ran sections 2 and 3 through the API as well (lead decision 75). Sage holds the owner and `svc-$BIZ` passwords, in 0600 files under `~/.config/mosaic-dev/secrets/vikunja-admin/`, apart from the token directory the broker reads. Plan on about 20 minutes with an existing Vikunja, and 30 if you start the bundled one. ## Rules for the whole guide - A token never appears on a command line, in a URL, in shell history, in a chat or in the repository. The commands below read secrets from files or a password prompt, and write tokens straight to files. - Run every step in one shell with `umask 077` set, so every file you create is 0600 and every directory 0700. - To check a token file, use `stat`, never `cat`. - The Vikunja owner and `svc-$BIZ` passwords and the Gitea admin login are the high-value secrets. They are used only in this guide, and never reach the broker or a worker. The one exception is the jarvis Gitea admin token, which Jason granted to the lead seat for section 1 (decision 74). It stays in its fleet file, and the broker never reads it. The Vikunja passwords for Mosaic Stack sit in `vikunja-admin/`, never in the token directory (decision 75). ## 0. Set up the shell ```sh umask 077 BIZ=mosaic-stack S="$HOME/.config/mosaic-dev/secrets/$BIZ" # token directory, outside the repo mkdir -p "$S" && chmod 700 "$S" VK=https://vikunja.example # your Vikunja base URL, no trailing slash GITEA=https://git.example # your Gitea base URL EXP=$(date -u -d '+90 days' +%Y-%m-%dT00:00:00Z) # Vikunja token expiry ``` Write down the date part of `EXP`. It goes in the business file as each Vikunja token's `expires`, in `YYYY-MM-DD` form. The broker treats 00:00Z on that date as the expiry, the same instant `EXP` names. The commands need curl 7.76 or later, for `--fail-with-body`. ## 1. Gitea: four bot users and their tokens Gitea tokens don't expire, and the HTTP route for creating one needs the bot's password. The steps below use the web UI. With a site admin token there's an API route that does the same five steps: `agents/sage/work/gitea-setup/setup.mjs`, which ran for `mosaic-stack` on 2026-10-09 (receipt `2026-10-09_run.txt` beside it). It creates each bot with a random password that exists only in its memory, adds the collaborator, mints the token with basic auth as the bot, and writes the file with `O_EXCL` at 0600. The password is never written down, so nobody can log in as a bot. It also creates each bot as `restricted` with `private` visibility, which the steps below don't ask for. A restricted user sees only repositories it collaborates on. The bots' addresses are `@noreply.mosaicstack.dev`, which receive no mail. `verify.mjs` checks each token's login, repository permission and refusals, and prints no secret. 1. As a site admin, create the users `mosaic-stack-pm-bot`, `mosaic-stack-cto-bot`, `mosaic-stack-coder-bot` and `mosaic-stack-reviewer-bot` (Site Administration, User Accounts, Create). Gitea usernames are global to the instance, so the business id in the name keeps one business's bots, and revoking them, apart from another's. Give each a long random password, which you won't need again after step 3. None of them is an admin. 2. In `mosaicstack/stack`, add each bot as a collaborator: | Bot | Repository access | |---|---| | mosaic-stack-pm-bot | Write (labels, assignees and closing need it) | | mosaic-stack-cto-bot | Write | | mosaic-stack-coder-bot | Write | | mosaic-stack-reviewer-bot | Read | No bot is on a protected branch's push, merge or approvals allowlist. Pushing to a protected branch is a gated action, and merges stay Jason's. Reviewer-bot's approvals in Gitea are advisory and don't count toward required approvals. The review verdict that gates a row lives in the queue and the bus. 3. Log in as each bot, go to Settings, Applications, and generate one token named `mosaic-stack--` with these scopes: | Bot | Scopes | |---|---| | mosaic-stack-pm-bot | `write:issue`, `read:repository`, `read:user` | | mosaic-stack-cto-bot | `write:issue`, `write:repository`, `read:user` | | mosaic-stack-coder-bot | `write:issue`, `write:repository`, `read:user` | | mosaic-stack-reviewer-bot | `write:issue`, `write:repository`, `read:user` | Leave every other scope unset, and set none to "public only". Reviewer-bot's `write:repository` is there because Gitea checks pull request reviews against the repository scope. Its Read collaborator access stops pushes to branches and tags. It does not stop an AGit push to `refs/for/`, which opens a pull request. That's acceptable only because the broker holds the token and offers the reviewer no push action. 4. Write each token to its file from a silent prompt, in the same `umask 077` shell. Paste the token at the prompt and press Enter: ```sh for r in pm cto coder reviewer; do printf '%s token: ' "$r"; read -rs t; echo printf %s "$t" > "$S/$r-gitea.token"; unset t done ``` For one role, as in a rotation, name only that role: `for r in coder; do ...`. `read` and `printf` are shell builtins, so the value never reaches `ps` or the history. Don't use an editor, which can leave a swap or backup copy behind. 5. Log out of each bot account. Record today's date as each Gitea token's `rotateBy` base. Section 5 has the rotation schedule. ## 2. Vikunja: the instance and the owner account You need Vikunja 2.7.0 or later, with local login on, an owner account that is yours, and a service account `svc-$BIZ` that owns the bots. ### Path A, an existing instance Mosaic Stack uses this path on tasks.mosaicstack.dev (lead decisions 66 and 75). Set `VK` to its HTTPS base URL. Don't use tasks.setspark.io or tasks.uscllc.com. Check that `curl -s "$VK/api/v1/info"` reports `v2.7.0` or later. Use your existing account as the owner. tasks.mosaicstack.dev also holds older Launchpad, personal and system projects. It serves Mosaic Stack only, and no other business moves onto it without Jason's ruling. A bot sees only the projects shared with it, so section 3 shares the `mosaic-stack` project and nothing else. Never share another project with a `bot-mosaic-stack-*` user. ### Path B, the bundled instance Other installs can use this path. Mosaic Stack's own install doesn't. Row S3 ships `packages/tasks/deploy/vikunja/` for this. Until it lands, these commands start the same pinned image the probes used, bound to 127.0.0.1: ```sh VD="$HOME/.local/share/mosaic-dev/vikunja"; mkdir -p "$VD/db" "$VD/files" printf 'VIKUNJA_SERVICE_SECRET=%s\n' "$(openssl rand -hex 32)" > "$VD/env" cat >> "$VD/env" < ``` ### The five bots `api` calls as the owner. `svc` calls as `svc-$BIZ`, which creates the bots. ```sh api() { curl -sf -H @"$S/vikunja-owner.hdr" -H 'Content-Type: application/json' "$@"; } svc() { curl -sf -H @"$S/vikunja-svc.hdr" -H 'Content-Type: application/json' "$@"; } for r in pm cto coder reviewer sync; do svc -X POST "$VK/api/v2/user/bots" \ -d "{\"username\":\"bot-$BIZ-$r\",\"name\":\"$BIZ $r\"}" | jq -c '{id, username}' done ``` Note the five ids. Each call prints one line. A missing line means the call failed, because `api` uses `curl -sf`, which prints nothing on an HTTP error. Bot usernames must start with `bot-`, and this guide uses `bot--` so two businesses on one instance don't collide. ### Shares The owner shares the project with each bot. The role bots get write (1). The sync bot gets read (0), so a bug in the poll path can't write. ```sh for r in pm cto coder reviewer; do api -X POST "$VK/api/v2/projects/$P/users" -d "{\"username\":\"bot-$BIZ-$r\",\"permission\":1}" | jq -c '{username, permission}' done api -X POST "$VK/api/v2/projects/$P/users" -d "{\"username\":\"bot-$BIZ-sync\",\"permission\":0}" | jq -c '{username, permission}' ``` ### Scopes From addendum B section 2 (lead decision 52). Every group or verb not listed stays off. ```sh cat > "$S/scopes-sync.json" <<'EOF' {"projects":["read_one","views_buckets","views_buckets_tasks_get"],"projects_views":["read_all"],"tasks":["read_all","read_one"],"tasks_comments":["read_all"]} EOF cat > "$S/scopes-pm.json" <<'EOF' {"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"]} EOF cat > "$S/scopes-worker.json" <<'EOF' {"tasks":["read_one","update"],"tasks_comments":["create"],"projects":["views_buckets_tasks"]} EOF ``` cto, coder and reviewer all use `scopes-worker.json`. ### Tokens `mint` sends the request as `svc-$BIZ`, writes the token straight to its file, and prints only the metadata. Vikunja shows a token once. Only the account that owns a bot can mint its token. The owner's account gets 404, code 1005. ```sh mint() { # mint ROLE BOT_ID SCOPES_FILE local r=$1 id=$2 sc=$3 resp="$S/.mint-$1.json" jq -n --arg t "$BIZ-$r-$(date +%F)" --argjson o "$id" --arg e "$EXP" --slurpfile p "$sc" \ '{title:$t, owner_id:$o, expires_at:$e, permissions:$p[0]}' | curl -s --fail-with-body -H @"$S/vikunja-svc.hdr" -H 'Content-Type: application/json' \ -X POST "$VK/api/v2/tokens" --data-binary @- -o "$resp" || { jq -c '{code, message}' "$resp"; rm -f "$resp"; return 1; } jq -je '.token | strings' "$resp" > "$S/$r-vikunja.token.new" && mv "$S/$r-vikunja.token.new" "$S/$r-vikunja.token" && jq -c '{id, owner_id, expires_at, starts_tk: (.token | startswith("tk_"))}' "$resp" rm -f "$resp" "$S/$r-vikunja.token.new" } mint pm "$S/scopes-pm.json" mint cto "$S/scopes-worker.json" mint coder "$S/scopes-worker.json" mint reviewer "$S/scopes-worker.json" mint sync "$S/scopes-sync.json" ``` Check that each line shows `starts_tk: true` and the `expires_at` you set. Vikunja accepts a past expiry without complaint, so read it. On an error, `mint` prints the response's `code` and `message`, which carry no token. Code 14002 means a scope name is wrong. Fix the scope file and mint again. If the response has no `token` field, `mint` stops and leaves any existing token file as it was, so a bad mint during rotation doesn't empty the live file. ## 4. Check and clean up ```sh rm -f "$S/vikunja-owner.hdr" "$S/vikunja-svc.hdr" stat -c '%a %s %n' "$S"/*.token ``` Each of the nine token files must show `600` and a nonzero size. Once row S3 lands, `mosaic` runs the broker's startup probe for every identity. It makes one control call and one call that must be refused, on a task id that doesn't exist, and it refuses to start on any surprise. That probe, passing for all nine, closes row SR's gate. The business file (`~/.config/mosaic-dev/businesses/mosaic-stack.json`, row S1) references each file by absolute path, and never holds a value. One role's entry looks like this. Row S1's validator has the final shape. `botId` 0 is a placeholder for the id you noted in section 3, and S1 refuses 0. ```json "coder": { "definition": "coder", "tracker": { "bot": "bot-mosaic-stack-coder", "botId": 0 }, "credentials": { "gitea": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-gitea.token", "rotateBy": "YYYY-MM-DD" }, "vikunja": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token", "expires": "YYYY-MM-DD" } } } ``` The sync bot has its own entry under the tracker settings, not under a role. The stack never writes this file. ## 5. Rotation - **Vikunja**, before `expires`: in a new shell, rerun section 0, `vklogin` for `svc-$BIZ` only (section 2), and the `svc` and `mint` definitions in section 3. Mint a new token for the same bot, update `expires` in the business file, and restart the broker. Revoke the old token while the svc header still exists: `svc -X DELETE "$VK/api/v2/tokens/"`. A revoke hits that one token, and it gets 401 on its next request. `mint` printed the old id. If you didn't keep it, list the bot's tokens as `svc-$BIZ`: `svc "$VK/api/v2/tokens?owner_id=" | jq -c '.items[]? | {id, title, expires_at}'`. A plain `GET /tokens` lists only svc's own tokens, which is none, and the owner's account gets 403 on the revoke. Then finish with section 4's `rm -f`, which deletes the header. The broker refuses to start with a token past its `expires`, and it treats any 401 as a refusal, never a retry. - **Gitea**, every 90 days or at once if a token may have leaked: log in as the bot, generate the replacement, write it to the file with the `read -rs` loop from section 1, restart the broker, then delete the old token in the same settings page. Update `rotateBy`. Each rotation gets one line in `docs/SESSIONS.md`: date, who, which identities, and no values. The `mosaic-stack` bots from `setup.mjs` have no known password, so nobody can log in to rotate. Rerunning the script for one role after moving its old file aside resets the password and mints a new token. It doesn't yet delete the old token, which needs the same basic auth. Adding that is a follow-up due before the first `rotateBy`, 2027-01-07. - **Revoking a role at once:** delete its token, the Gitea one in the bot's settings and the Vikunja one with the `DELETE` above as `svc-$BIZ`, or remove the bot's collaborator access or, as the owner, its project share. For a bot with no known password, a Gitea admin removes the collaborator (`DELETE /repos/{owner}/{repo}/collaborators/{user}`) or sets `prohibit_login` on the user, which also stops its tokens. The broker's next call gets a 401 or 403 and refuses.