ADR-0006: First-time setup flow for an empty install
Metadata
- Status: Accepted
- Date: 2026-07-24 (implemented 2026-07-25)
- Deciders: EagraΓ Clainne Team
- Context: Covers the web experience for a fresh install with an empty database
- Related: ADR-0002 (public-endpoint bypass in the auth interceptor); the
user intake rules in
internal/domain/user.go
Implementation notes (2026-07-25)
Implemented as decided: proto/api/core/v1/system.proto,
internal/services/system, the public-endpoint entry in
internal/server/server.go, the welcome flow in web/src/pages/Welcome.tsx
gated from App.tsx, and the operator notes in deploy/README.md and
LOCAL_DEVELOPMENT.md. Deviations from the letter of point 6, in its spirit:
- A permission-denied Create while the token field is showing is treated as a mistyped token and reported inline on the form, not as a fall-back-to-sign-in β the person has no account to sign in to yet. Permission denied without a token field, or an already-exists collision, falls back to sign-in with the plain explanation as decided.
- Because a lost race does not make Create fail (a second Create quietly
succeeds as a member account), the page re-checks
SetupStatuson submit as well as on load, and degrades to sign-in when setup is no longer needed rather than creating a non-admin account.
Context
A fresh deployment can have the database server and the app both running while
the database holds zero users. Today an unauthenticated visitor lands on the
sign-in page (web/src/pages/SignIn.tsx), which is a dead end in that state:
the profile picker has no profiles to show, and login can never succeed because
there is no account to log into. Bootstrapping the first user currently
requires calling the Create RPC by hand (grpcurl, curl, Bruno) β fine for a
developer, hostile to anyone else.
The backend already carries most of the first-user machinery:
domain.InitialRoles(internal/domain/user.go) makes the first user in the system an admin regardless of requested roles; later users default to member.UserService/Createis a public endpoint (internal/server/server.go), and the handler (internal/services/user/create.go) gates the first creation behind anX-Initial-Admin-Tokenheader β checked only whenEAG_INITIAL_ADMIN_TOKENis configured, skipped when it is unset.
What is missing is a decided, recorded flow for how the web app detects the empty state and walks a person through creating the first admin. Prior art: installer flows like phpBB's, which greet the operator, explain the system briefly, and create the founding admin account β referenced for the flow shape, not imitated visually.
Decision
-
Detection is a new public RPC:
SystemService/SetupStatus.A new
SystemServiceis added inproto/api/core/v1/system.proto(packageapi.core.v1) with a single method:// SystemService reports install-level state service SystemService { // SetupStatus reports whether first-time setup is needed rpc SetupStatus(SetupStatusRequest) returns (SetupStatusResponse) {} } // SetupStatusRequest is empty; setup state is global message SetupStatusRequest {} // SetupStatusResponse describes the install's setup state message SetupStatusResponse { // True when no users exist yet bool needs_setup = 1; // True when EAG_INITIAL_ADMIN_TOKEN is configured on the server bool setup_token_required = 2; }needs_setupis derived from the user count;setup_token_requiredfrom whether the server was configured with an initial admin token. The procedure joins the public-endpoint bypass list ininternal/server/server.goalongsideUserService/CreateandUserService/Login. The information leak is moot: emptiness is already probeable through the public Create endpoint.SystemServiceis the future home for install-level state (version, migration status), but it ships with only this method β no speculative endpoints. -
The setup token stays env-only; the open window is accepted and documented. The server does not generate or print a token. A token exists only when the operator sets
EAG_INITIAL_ADMIN_TOKEN. The consequence is stated plainly: with no token configured on a deployment reachable by others, the first visitor to complete the form claims the admin account. Deploy documentation (deploy/README.md,LOCAL_DEVELOPMENT.md) must tell operators to set the token for anything beyond a trusted local network. Backend behaviour is unchanged by this ADR. -
The token field is hidden unless required. When
setup_token_requiredis true, the form shows a mandatory setup-token field with helper text pointing at the real source: "Enter the setup token from your server configuration (EAG_INITIAL_ADMIN_TOKEN)." When false, the field does not appear at all. The value is sent as theX-Initial-Admin-Tokenheader on the Create call (connect-web per-callheadersoption). -
The flow is a single welcome page. When a visitor is unauthenticated and
needs_setupis true,App.tsxrenders a Welcome page in place of SignIn. One screen: a greeting, four short "how it works" points (profiles for every family member; jobs that earn points; points that buy rewards; one shared calendar), and the first-admin form inline β name, email, password, plus the token field when required. Remembering (deceased family members) is deliberately left out of the welcome explainer, to be discovered later in the Family page. The copy states outright that the first account becomes the admin and can invite everyone else. -
After creation, auto sign-in and land on Family. The page signs the new admin in with the credentials just entered (existing
signIn()inweb/src/auth/auth.tsx, which also remembers the profile on the device), then navigates to/family, whereAddMemberFormmakes adding the household the natural next act. -
Races and failures degrade to sign-in.
SetupStatusis re-checked on page load. If Create fails because someone else completed setup first (permission denied / no longer the first user), the page falls back to sign-in with a plain explanation. If Create succeeds but the automatic sign-in fails, the page falls back to sign-in with the email prefilled rather than surfacing an error about an account that was in fact created.
Design direction
This is a user-facing flow, so the visual intent is part of the decision.
- The welcome page stays inside the existing design system: default Hearth
theme tokens, Fredoka for display text, Atkinson Hyperlegible for body, the
centered
.signin-card-family layout. No new palette, no new fonts β the first screen must feel like the same house the family will live in. - Signature element: a row of the eight Okabe-Ito member marks (from
web/src/marks.ts) above the headline, like place-settings at a table. The same marks later identify each member on the sign-in picker, so the signature foreshadows real UI rather than decorating. - Voice and copy (sentence case, plain verbs, same register as "Who's home?"):
- Headline: "Welcome home."
- Explainer, one short line per point.
- Form intro: "Create the first account. It runs the household β this account becomes the admin and can invite everyone else."
- Submit: "Create account".
- Quality floor: visible keyboard focus, usable at mobile widths, reduced motion respected. No entrance animation is required; restraint is fine here.
Consequences
Positive
- An empty install is self-serving: no grpcurl/curl bootstrap, no developer in the loop to create the founding admin.
- The token gate is preserved exactly as it exists today; deployments that
configure
EAG_INITIAL_ADMIN_TOKENkeep their proof-of-server-access check. - The steady-state sign-in experience is untouched; the welcome page exists
only while
needs_setupis true.
Negative / trade-offs
- A new service and public RPC to maintain, for what is today one boolean pair.
SetupStatusis queried on unauthenticated page loads β one cheap user count per visit until someone signs in.- The first-run window stays open when no token is configured; the defense is documentation, not code.
- The welcome page is dead code after minute one of an install's life.
Alternatives considered
- Infer emptiness from a Login sentinel error. No API change, but couples the frontend's routing decision to error strings and makes the sign-in page responsible for detecting a state it should never render in.
- CLI-only bootstrap (status quo). Works for developers, hostile to everyone else; the project's audience is families.
- Auto-generate and log a token when the env var is unset. Closes the open window and mirrors phpBB's install key, but adds backend machinery and a log-scraping step to the happy path. Rejected for now; revisit if exposed zero-config deployments become common.
SetupStatusonUserService. Smaller change, but "is this install set up" is install-level state, not a user operation; it would be the wrong home the moment a second system-level need appears.- A multi-step wizard. Ceremony without payoff for a flow that collects one form.
- Dropping the token gate when the DB is empty. The unset-env behaviour is already effectively this; removing the gate even when a token is configured would discard the only defense on exposed deployments.