App reference

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 SetupStatus on 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/Create is a public endpoint (internal/server/server.go), and the handler (internal/services/user/create.go) gates the first creation behind an X-Initial-Admin-Token header β€” checked only when EAG_INITIAL_ADMIN_TOKEN is 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

  1. Detection is a new public RPC: SystemService/SetupStatus.

    A new SystemService is added in proto/api/core/v1/system.proto (package api.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_setup is derived from the user count; setup_token_required from whether the server was configured with an initial admin token. The procedure joins the public-endpoint bypass list in internal/server/server.go alongside UserService/Create and UserService/Login. The information leak is moot: emptiness is already probeable through the public Create endpoint.

    SystemService is the future home for install-level state (version, migration status), but it ships with only this method β€” no speculative endpoints.

  2. 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.

  3. The token field is hidden unless required. When setup_token_required is 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 the X-Initial-Admin-Token header on the Create call (connect-web per-call headers option).

  4. The flow is a single welcome page. When a visitor is unauthenticated and needs_setup is true, App.tsx renders 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.

  5. After creation, auto sign-in and land on Family. The page signs the new admin in with the credentials just entered (existing signIn() in web/src/auth/auth.tsx, which also remembers the profile on the device), then navigates to /family, where AddMemberForm makes adding the household the natural next act.

  6. Races and failures degrade to sign-in. SetupStatus is 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_TOKEN keep their proof-of-server-access check.
  • The steady-state sign-in experience is untouched; the welcome page exists only while needs_setup is true.

Negative / trade-offs

  • A new service and public RPC to maintain, for what is today one boolean pair.
  • SetupStatus is 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.
  • SetupStatus on UserService. 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.