Files
stack/docs/guides/slice-1-identities.md
T
jason.woltjeandClaude Opus 5.5 7780ef1e54 docs: runbook token listing reads the items envelope (sage)
Darkwing's correction, checked against the 2.7.0 OpenAPI: GET /tokens
returns PaginatedAPIToken, with items possibly null.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:17:18 -05:00

16 KiB

Slice 1 identities: Gitea bots, Vikunja bots and their tokens

Jason runs this guide once per business, by hand (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.

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 an agent.

0. Set up the shell

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 a password, so this part uses the web UI.

  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-<role>-<date> 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/<branch>, 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:

    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 the estate instance (lead decision 66). Set VK to its HTTPS base URL. Don't use tasks.setspark.io.

Check that curl -s "$VK/api/v1/info" reports v2.7.0 or later. Use your existing account as the owner.

The estate instance also holds Launchpad, personal and system projects. 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:

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" <<EOF
VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/
VIKUNJA_DATABASE_TYPE=sqlite
VIKUNJA_DATABASE_PATH=/db/vikunja.db
VIKUNJA_WEBHOOKS_ENABLED=false
VIKUNJA_SERVICE_ENABLEREGISTRATION=false
EOF
docker run -d --name mosaic-vikunja --restart unless-stopped --user "$(id -u):$(id -g)" \
  -p 127.0.0.1:3456:3456 -v "$VD/db:/db" -v "$VD/files:/app/vikunja/files" \
  --env-file "$VD/env" \
  vikunja/vikunja@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc
docker exec -it mosaic-vikunja /app/vikunja/vikunja user create -u jason -e [email protected]
VK=http://127.0.0.1:3456

user create asks for the password when you leave out -p. Keep -it, because without a terminal the command exits instead of prompting. Never put the password on the command line.

Both mounts are required. The container runs as your uid, and /app/vikunja isn't writable to it, so Vikunja needs the files mount for its startup write check and the db mount for its database.

The service account

Create the bots from svc-$BIZ, never from your own account. In Vikunja 2.7.0 a bot can read and attach every label its owning account created, in any project (upstream test pkg/models/label_test.go, #3592). Row S3's probe showed it on the pinned image. A bot created by the owner and shared only into mosaic-stack listed the owner's Launchpad, personal and unattached labels, and attached two of them to a mosaic-stack task. Owned by an account with no labels, the same bot saw only the labels on mosaic-stack tasks and got 403 on the rest (lead decision 68).

  • svc-$BIZ owns this business's five bots and nothing else: no labels, no projects, no other bots. Never use it in the web UI.
  • Nobody shares a project with it. Section 3 shares the bots, not the account.
  • Its password is a high-value secret, like the owner's, and is used only in this guide and in rotation.

On Path B, create it the same way as the owner:

docker exec -it mosaic-vikunja /app/vikunja/vikunja user create -u "svc-$BIZ" -e "svc-$BIZ@example.invalid"

On Path A, you create it yourself, because its password is yours to keep. The instance's ops doc gives the exact vikunja user create command for its container, with the password entered at a prompt.

Logins for this guide

vklogin writes one account's session header to a file. The password comes from a prompt, and the token never reaches the terminal. You need both headers: the owner's for the project and the shares, svc-$BIZ's for the bots and their tokens.

vklogin() {  # vklogin USER HEADER_FILE
python3 - "$VK" "$1" "$2" <<'PY'
import getpass, json, sys, urllib.request
base, user, out = sys.argv[1:4]
pw = getpass.getpass("Vikunja password for %s: " % user)
req = urllib.request.Request(base + "/api/v2/login",
    data=json.dumps({"username": user, "password": pw}).encode(),
    headers={"Content-Type": "application/json"})
body = json.load(urllib.request.urlopen(req))
if "token" not in body:
    sys.exit("login answered without a token field; stop and report the keys: %s" % sorted(body))
open(out, "w").write("Authorization: Bearer " + body["token"] + "\n")
print("header written for %s" % user)
PY
}
vklogin jason      "$S/vikunja-owner.hdr"
vklogin "svc-$BIZ" "$S/vikunja-svc.hdr"

The token field is confirmed in the v2.7.0 source (auth_login.go). The script still stops if it's missing. Section 4 deletes both header files.

3. Vikunja: the project, the board and the bots

The project and its board

In the web UI, as the owner:

  1. Create the project mosaic-stack, and note its id (in the URL).
  2. Open its Kanban view and rename the buckets: To-Do becomes todo, Doing becomes in-progress, Done becomes done. Add in-review and blocked. Each title appears once.
  3. In the view's settings, check that the done bucket is done and the default bucket is todo. Leave bucket configuration on manual.
  4. Slice 1 needs no labels, since the requirement id lives in the task, not in a label. If you create any, note each id. A bot owned by svc-$BIZ sees only the labels on mosaic-stack tasks, and the broker attaches only the label ids the business file lists.
P=<project id>

The five bots

api calls as the owner. svc calls as svc-$BIZ, which creates the bots.

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-<business>-<role> 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.

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.

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.

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       <pm id>       "$S/scopes-pm.json"
mint cto      <cto id>      "$S/scopes-worker.json"
mint coder    <coder id>    "$S/scopes-worker.json"
mint reviewer <reviewer id> "$S/scopes-worker.json"
mint sync     <sync id>     "$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

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.

"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/<old id>". 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=<bot 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.
  • 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. The broker's next call gets a 401 or 403 and refuses.