@jpgilldev / steid

steid/plans/decisions/0002-first-run-claim-not-config-bootstrap.md
3.5 KBRaw
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
7The previous attempt created the owner account from environment variables —
8`STEID_OWNER_EMAIL`, `STEID_OWNER_PASSWORD`, `STEID_OWNER_USERNAME` — read on every
9boot and applied when no users existed. This build started by reproducing that.
10
11Two problems surfaced while writing it.
12
13**The password is a long-lived secret held for a one-time operation.** An environment
14variable is readable from the process environment, a `.env` file on disk, shell
15history, `docker inspect`, systemd units, and CI logs. It stays there for the life of
16the deployment even though it is needed exactly once. Attempt #2's `.env.dev` shows the
17gravitational 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
20written without registering what it meant:
21
22```rust
23async fn a_second_boot_does_not_overwrite_a_changed_password()
24```
25
26Once the owner changes their password in the app, the config value is wrong, still
27readable, and rotating it does nothing. Config that must be ignored to stay correct is
28config holding the wrong thing.
29
30## Decision
31
32An unclaimed installation serves a first-run claim flow. On boot with no owner, Steid
33generates a **one-time setup token**, prints it to stdout, and serves `/setup`.
34Presenting the token lets a visitor choose handle, email, and password; that creates the
35owner and starts their session.
36
37The token lives in memory only, so restarting an unclaimed instance rotates it. The
38owner 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.