| 1 | # 0002 — Claim the instance on first run, don't bootstrap the owner from config |
| 2 | |
| 3 | **Status:** accepted · **Date:** 2026-07-31 |
| 4 | |
| 5 | ## Context |
| 6 | |
| 7 | The previous attempt created the owner account from environment variables — |
| 8 | `STEID_OWNER_EMAIL`, `STEID_OWNER_PASSWORD`, `STEID_OWNER_USERNAME` — read on every |
| 9 | boot and applied when no users existed. This build started by reproducing that. |
| 10 | |
| 11 | Two problems surfaced while writing it. |
| 12 | |
| 13 | **The password is a long-lived secret held for a one-time operation.** An environment |
| 14 | variable is readable from the process environment, a `.env` file on disk, shell |
| 15 | history, `docker inspect`, systemd units, and CI logs. It stays there for the life of |
| 16 | the deployment even though it is needed exactly once. Attempt #2's `.env.dev` shows the |
| 17 | gravitational pull of the pattern: `STEID_OWNER_PASSWORD=changeme`. |
| 18 | |
| 19 | **Config describes something mutable, so it goes stale by design.** The tell was a test |
| 20 | written without registering what it meant: |
| 21 | |
| 22 | ```rust |
| 23 | async fn a_second_boot_does_not_overwrite_a_changed_password() |
| 24 | ``` |
| 25 | |
| 26 | Once the owner changes their password in the app, the config value is wrong, still |
| 27 | readable, and rotating it does nothing. Config that must be ignored to stay correct is |
| 28 | config holding the wrong thing. |
| 29 | |
| 30 | ## Decision |
| 31 | |
| 32 | An unclaimed installation serves a first-run claim flow. On boot with no owner, Steid |
| 33 | generates a **one-time setup token**, prints it to stdout, and serves `/setup`. |
| 34 | Presenting the token lets a visitor choose handle, email, and password; that creates the |
| 35 | owner and starts their session. |
| 36 | |
| 37 | The token lives in memory only, so restarting an unclaimed instance rotates it. The |
| 38 | owner password is gone from configuration entirely. |
| 39 | |
| 40 | ## Alternatives considered |
| 41 | |
| 42 | - **Keep config bootstrap.** Unattended, container-friendly, already written. Rejected |
| 43 | for the two problems above. It is not being kept as a parallel automation path |
| 44 | either — no such deployment exists yet, and a second bootstrap route is a second |
| 45 | thing that can create an owner. Easy to add back if demand is real. |
| 46 | - **Claim with no token.** Simpler, and what several forges do. Rejected because the |
| 47 | first visitor to an exposed instance becomes its owner, and the window lasts until |
| 48 | someone notices. |
| 49 | - **CLI subcommand** (`steid init-owner`, prompting on a TTY). No exposure window at |
| 50 | all and no web layer needed. Rejected because it needs shell access to the host, |
| 51 | which is awkward in a container, and it means building a CLI surface that does not |
| 52 | otherwise exist. Worth revisiting if the claim flow proves awkward to operate. |
| 53 | |
| 54 | ## Consequences |
| 55 | |
| 56 | - **No password in configuration.** The only secret at rest is the Argon2 hash. |
| 57 | - **The exposure window is closed by the token**, and rotated by a restart. |
| 58 | - **Milestone 1 reorders.** Config bootstrap needed no web layer and would have been |
| 59 | verifiable first; a claim flow needs pages and sessions. The first user-visible |
| 60 | behaviour becomes `/setup` rather than a row appearing in `steid.db`. |
| 61 | - **Operators must read stdout on first boot.** That is a real usability cost — a |
| 62 | crash-looping container reprints a new token each time. The log line should say |
| 63 | plainly what it is and that it rotates. |
| 64 | - **The `any_exist` check before writing is TOCTOU.** Two simultaneous claims could |
| 65 | both pass it. The `UNIQUE` constraints on email and organisation name are what |
| 66 | actually serialise it, so the second claim fails at the database rather than |
| 67 | silently creating a second owner. Worth an integration test once SQLite lands. |
| 68 | - Unattended deployment is unsupported until someone needs it. |