# Operator runbook — Cloudron tokens How the co-op's Cloudron credentials are organized, and how to rotate them without breaking the stack. ## Cloudron 10 follow-ups (tracked, Sept 2026) Two items surfaced by the Cloudron 10 release. Neither is urgent — both apps still work — but both are scheduled deliberately so they're fixed *before* the membership grows. 1. **Migrate webmail off SnappyMail → Cloudron "Mail".** SnappyMail (RainLoop's fork) is abandoned; Cloudron built its own replacement, "Mail," but it is still catching up on features (their words). Keep SnappyMail for now; migrate `mail.inference.coop` once "Mail" is feature-complete. Until then SnappyMail is a known unmaintained dependency. 2. **Migrate `dashboard` + `panel` from `proxyAuth` → OIDC.** Cloudron 10 is phasing out proxyAuth/LDAP in favor of OIDC (93 packages on OIDC vs 11 on proxyAuth). Our two custom apps (`member-dashboard`, `admin-panel`) still use the `proxyAuth` addon; the portal and all App Store apps are already on OIDC. See [SSO migration plan](sso-migration.md). ## The two-token split There are **two separate** Cloudron API tokens, deliberately decoupled so rotating one doesn't break the other: | Token | Purpose | Where it lives | |-------|---------|----------------| | **Agent token** | Inferencebot's own Cloudron access | The agent's private `.env` (gitignored) | | **Portal token** | The member portal's Cloudron access (create users, set groups, invite links) | Cloudron's env store, under the portal app's `CLOUDRON_TOKEN` | The portal needs only a handful of Cloudron capabilities (user/group management, invite links). It is the most exposed app (public webhook + broker endpoints), so it must **not** hold the agent's token — a portal compromise shouldn't reach the agent's wider Cloudron access. ## Rotating a token ### Portal token 1. In `my.inference.coop` → **Settings → API Tokens**, create a **"Read and Write"** token. 2. Set it as the portal's `CLOUDRON_TOKEN` env var (custom apps have no env-var UI — use the Cloudron CLI or the API; see the next section). 3. Verify: the portal can reach `GET /api/v1/users` with the new token (HTTP 200). ### Agent token 1. Create a fresh token in `Settings → API Tokens`. 2. Put it in the agent's `.env` as `CLOUDRON_TOKEN`. 3. Verify against `/api/v1/cloudron/status`. **The trap this split exists to prevent:** the portal keeps its *own copy* of a Cloudron token. If the agent's token is rotated without also updating the portal copy, the portal's Cloudron calls start returning `401` — which breaks provisioning (new members can't be created) and the member dashboard (its usage/keys data is resolved through the portal's Cloudron calls). ## Custom apps have no env-var UI The member portal is a *custom* app (packaged by us), not an App Store app. Custom apps do **not** expose environment variables in the Cloudron dashboard. Env vars for custom apps are set via: - **Cloudron CLI** (on your workstation, *not* the server): `cloudron env set --app portal.inference.coop CLOUDRON_TOKEN=...` - **Cloudron API**: `POST /apps/:appId/configure/env` ## `configure/env` is a full replace `POST /apps/:appId/configure/env` with body `{"env": {KEY: value, ...}}` **replaces the entire environment** — it does not merge. Sending a partial map (e.g. a single test variable) wipes every other variable. - To change **one** variable, read the current env first (`GET /api/v1/apps/:id` → `.env`), merge in memory, and POST the full map back. - Never probe the endpoint with a throwaway body against a live app. - If the env is wiped, it is recoverable from a Cloudron app backup: `GET /api/v1/apps/:appId/backups` → newest entry → `GET .../backups/:backupId/download` (a `.tar.gz` containing `config.json`, whose `.env` field holds the original map). ## Reference - Cloudron CLI: `npm install -g cloudron` (install on your workstation, not the server), then `cloudron login my.inference.coop`. - Cloudron API docs: `docs.cloudron.io/api/`. ## Open Collective: the bot token needs the `email` scope (Sept 2026) The portal identifies members by email, read via the bot's OC personal token (`OC_PERSONAL_TOKEN`) as `collective.members.nodes[].account.email` with the `... on Individual { email }` inline fragment. **The `email` scope is load-bearing.** The bot's personal token must be minted with the `email` scope. Without it, the GraphQL `email` field returns `null` for *every* member — including the bot's own `me { email }` — even though: - the bot still authenticates and reports `isAdmin: true`, and - the OC **admin CSV export still shows emails** (it's a separate, non-GraphQL path; its `contributorEmail` column is not a GraphQL field). **Symptoms when the scope is missing:** `fetch_members()` filters out empty emails and returns `[]`, so the webhook can't provision anyone by email and the nightly sweep goes into its empty-list fail-safe (skips, doesn't deactivate). A new member's webhook fires cleanly but no "Sent email to …" log line follows, and no Cloudron user is created. (This is easy to misread as a "guest with no email" problem — it is not; verify the scope first.) **Diagnosis:** run `me { email }` with the bot token. `null` here while `isAdmin: true` → the token is missing the `email` scope (or the scope was revoked). Fix: re-add the `email` scope to the token on Open Collective (Settings → Developers → Personal Tokens). No token rotation, no re-granting of admin — just the scope.