// Package setup implements SPEC-005: the `/setup` wizard that brings a fresh // instance to life. The gate is the **config toggle `setup`** (D-045): // // - `setup: true` → `/setup` serves the wizard at the public domain; // - `setup: false` + no admin → the instance is LOCKED: `GET /` and // `GET /setup` render a boilerplate telling the operator to enable setup, // and every other route is 404 (this state is not publicly claimable); // - completion → the wizard writes `setup: false` and creates the admin. // // The setup CODE (`setup_code`) is an OPTIONAL second factor (D-044 · D-045): // when set, `GET /setup` first shows the code form and `POST /setup?do=setup& // action=unlock` with the correct code grants the unlock (a signed cookie, not // a session — no user exists yet); when empty, the toggle alone admits. // // The wizard walks three steps — (1) the domain / base URL, (2) the top admin // with TOTP enrollment, (3) mail · locale · timezone — and finishes by // persisting the settings to `config.yaml` with `setup: false` (D-035) and // signalling completion (`Actions.Done`), after which `/setup` 404s (delete, // don't redirect). // // Step 2 enrolls TOTP ONLY (D-041): a passkey registered over plain-HTTP // loopback binds RP ID 127.0.0.1 and is unusable at the public domain. The // admin adds a passkey at first login on the real origin, via the passkey // nudge (SPEC-006 §8). It reuses SPEC-006's TOTP ceremony (flow.Actions) — // no duplication. // // The wizard holds no routing of its own — the door (SPEC-003/005) gates it to // setup mode and verifies the pre-session CSRF token (A15) before invoking any // action here. // // Spec: docs/specs/spec-005-setup-wizard.md. package setup import ( "context" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "os" "strconv" "strings" "sync" "sync/atomic" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) // adminUserID is the top admin — the first user (D-013, D-021). const adminUserID int64 = 1 // Users is the narrow slice of the user service the wizard needs. type Users interface { // Count reports how many users exist — the fail-safe setup-mode check // (SPEC-005 §3) and the "is the admin already created?" question. Count(ctx context.Context) (int, error) // Create creates the top admin via the bootstrap path (creatorID == 0). Create(ctx context.Context, creatorID int64, email, displayName string, role users.Role) (users.UserID, error) // Get loads a user. Get(ctx context.Context, id int64) (users.User, error) } // Enroller is the SPEC-006 TOTP ceremony, reused so the wizard never // duplicates the work (SPEC-005 §4). Step 2 enrolls TOTP only (D-041) — the // wizard never calls the WebAuthn registration actions. // // The seam carries no JSON-writing method: the wizard is server-rendered and // no-JS, so it renders the issued secret and the verify form itself rather than // delegating to the enroll flow's JSON API (`webauthn.js`'s contract) — the // exact coupling that stalled the wizard's TOTP step (SPEC-005 §11). type Enroller interface { // IssueTOTP issues (and persists) the TOTP secret and returns the otpauth // URL, writing NO response — the caller owns the reply. IssueTOTP(ctx context.Context, guid string) (string, error) // TOTPVerify verifies a code: it completes the admin's enrollment (the // wizard's TOTP-only mode, D-041) and mints the session. TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error } // Deps assembles the wizard's dependencies. type Deps struct { // Config is the startup configuration; the wizard persists a copy of it // (with `setup: false` and the new settings) to ConfigPath. Config *config.Config // ConfigPath is where the settings are persisted to (usually config.yaml, // never the committed example). ConfigPath string // Code is the OPTIONAL setup code (D-044 · D-045). When blank the toggle // alone admits and the wizard is served directly; when set, the wizard // requires it (a second factor). The startup banner prints it only when set // (`Actions.Code`). Code string // Users creates and inspects the top admin. Users Users // Identities mints and resolves the admin's identity. Identities auth.Identities // Enroll is the SPEC-006 ceremony (flow.Actions). Enroll Enroller // Audit receives the bootstrap events (A11). Audit audit.Audit // CSRF issues and verifies the anonymous double-submit token (A15). CSRF *httpx.PreSessionCSRF // Logger receives structured logs. Defaults to slog.Default(). Logger *slog.Logger // Printout receives the rendered config when the file is not writable. // Defaults to os.Stdout. Printout io.Writer // WriteFile writes the config file (tests inject a failing writer to // exercise the "print the values" path). Defaults to os.WriteFile 0600. WriteFile func(path string, data []byte) error } // Actions is the /setup wizard. type Actions struct { d Deps gate *Gate mu sync.Mutex draft draft // done signals that the bootstrap is complete. The live config is treated // as an immutable startup snapshot (D-035: the FILE is the source of // truth), so completion is a concurrency-safe flag here rather than a // mutation of config.Setup — the door reads it on every request. done atomic.Bool } // Done reports whether the wizard has completed — the door's "setup is over" // signal (SPEC-005 §5). It is safe for concurrent use. func (a *Actions) Done() bool { return a.done.Load() } // draft is the small amount of wizard state that is not yet in the database or // the config: the base URL captured in step 1 (empty = step 1 unanswered). type draft struct { domain string } // New validates and builds the wizard. func New(d Deps) (*Actions, error) { switch { case d.Config == nil: return nil, errors.New("setup: Config is required") case d.Users == nil: return nil, errors.New("setup: Users is required") case d.Identities == nil: return nil, errors.New("setup: Identities is required") case d.Enroll == nil: return nil, errors.New("setup: Enroll is required") case d.CSRF == nil: return nil, errors.New("setup: CSRF is required") } if strings.TrimSpace(d.ConfigPath) == "" { return nil, errors.New("setup: ConfigPath is required") } if d.Logger == nil { d.Logger = slog.Default() } if d.Printout == nil { d.Printout = os.Stdout } if d.WriteFile == nil { d.WriteFile = writeFile0600 } // D-044: the first-run gate — the OPTIONAL setup code + the signed unlock // cookie. A blank code means no code is required (D-045). gate, err := NewGate(d.Code, d.CSRF, d.Logger) if err != nil { return nil, err } return &Actions{d: d, gate: gate}, nil } // Code returns the setup code to print in the startup banner, or "" when no // code is required (D-045) — there is nothing to print in that case. func (a *Actions) Code() string { return a.gate.Code() } // CodeRequired reports whether the wizard requires a code (D-045: `setup_code` // is set). The startup banner prints the code only when it does. func (a *Actions) CodeRequired() bool { return a.gate.Required() } // Gate exposes the first-run gate so the door can ask whether a request is // unlocked and render the first-run pages (D-044/D-045). The wizard itself // never parses a path — the door decides. func (a *Actions) Gate() *Gate { return a.gate } // ── the first-run gate (D-044) ────────────────────────────────────────────── // Unlock is the D-044 code gate: `POST /setup?do=setup&action=unlock` carrying // the setup code. The door has already verified the pre-session CSRF token // (A15) and rate-limited the request (A3); here we check the code, grant the // unlock for this browser, and send the operator on to the wizard // (post/redirect/get). // // When no code is required (D-045) the action is a no-op that simply lands on // the wizard. A wrong or absent code gets a GENERIC answer — the same page and // message either way, so the form is no oracle — and is audited (no code value // logged). func (a *Actions) Unlock(w http.ResponseWriter, r *http.Request) error { if a.done.Load() { writeError(w, http.StatusNotFound, "not found") return nil } if !a.gate.Required() { redirect(w, r, "/setup") return nil } if !a.gate.Accept(r.FormValue("code")) { a.recordSetup(r.Context(), "setup.unlock", "refused") return a.gate.codeFormError(w, r) } a.gate.SetUnlockCookie(w, r) a.recordSetup(r.Context(), "setup.unlock", "unlocked") redirect(w, r, "/setup") return nil } // ── the wizard page ───────────────────────────────────────────────────────── // Page renders the wizard at its current step (GET /setup). func (a *Actions) Page(w http.ResponseWriter, r *http.Request) error { st, err := a.step(r.Context()) if err != nil { return err } token, err := a.d.CSRF.Ensure(w, r) if err != nil { return err } return renderHTML(w, http.StatusOK, wizardPage(st, token, a.draftDomain())) } // ── step 1 · the domain ───────────────────────────────────────────────────── // DomainSave records the base URL (step 1) and advances to step 2. func (a *Actions) DomainSave(w http.ResponseWriter, r *http.Request) error { if a.done.Load() { writeError(w, http.StatusNotFound, "not found") return nil } base := strings.TrimSpace(r.FormValue("base_url")) if base == "" { writeError(w, http.StatusBadRequest, "a base URL is required") return nil } if u, err := url.Parse(base); err != nil || u.Scheme == "" || u.Host == "" { writeError(w, http.StatusBadRequest, "invalid base URL") return nil } a.mu.Lock() a.draft.domain = base a.mu.Unlock() redirect(w, r, "/setup") return nil } // ── step 2 · the top admin ────────────────────────────────────────────────── // AdminCreate creates `user_id = 1` (role admin) and mints its identity. Only // one admin may ever exist (SPEC-004 AC-6) — a second attempt is refused. func (a *Actions) AdminCreate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() if a.done.Load() { writeError(w, http.StatusNotFound, "not found") return nil } n, err := a.d.Users.Count(ctx) if err != nil { return err } if n > 0 { // The admin may already exist. Refuse a SECOND admin (AC-5), but repair // a first one left without an identity (a crash after Create, before // Mint) — otherwise the wizard could never be resumed. repaired, err := a.ensureAdminIdentity(ctx) if err != nil { return err } if !repaired { writeError(w, http.StatusConflict, "an admin already exists") return nil } redirect(w, r, "/setup") return nil } name := strings.TrimSpace(r.FormValue("display_name")) email := strings.TrimSpace(r.FormValue("email")) if name == "" || email == "" { writeError(w, http.StatusBadRequest, "a display name and an email are required") return nil } userID, err := a.d.Users.Create(ctx, 0, email, name, users.RoleAdmin) if err != nil { switch { case errors.Is(err, users.ErrBootstrapClosed), errors.Is(err, users.ErrOneAdmin), errors.Is(err, users.ErrDuplicateEmail): writeError(w, http.StatusConflict, "cannot create the admin") case errors.Is(err, users.ErrEmailRequired), errors.Is(err, users.ErrNameRequired), errors.Is(err, users.ErrInvalidRole), errors.Is(err, users.ErrBootstrapAdmin): writeError(w, http.StatusBadRequest, "bad request") default: return err } return nil } if _, _, err := a.d.Identities.Mint(ctx, userID); err != nil { return err } a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", userID)) redirect(w, r, "/setup") return nil } // ensureAdminIdentity mints the top admin's identity when the user exists but // has none (a crash between Create and Mint). It reports repaired=false when an // identity already exists — the caller then refuses a second admin (AC-5). func (a *Actions) ensureAdminIdentity(ctx context.Context) (repaired bool, err error) { if _, err := a.d.Identities.ByUserID(ctx, adminUserID); err == nil { return false, nil } else if !errors.Is(err, auth.ErrIdentityNotFound) { return false, err } u, err := a.d.Users.Get(ctx, adminUserID) if err != nil { return false, err } if u.Role != users.RoleAdmin { return false, nil } if _, _, err := a.d.Identities.Mint(ctx, adminUserID); err != nil { return false, err } a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", adminUserID)) return true, nil } // TOTPBegin issues the admin's TOTP secret and renders the wizard's TOTP page: // the otpauth URL with its human-readable secret and the verify form (SPEC-005 // §4). The wizard is server-rendered and works with no JavaScript, so it does // NOT answer with the enroll flow's JSON (`webauthn.js`'s contract) — it issues // the secret through the write-free IssueTOTP seam and renders the page itself. func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() guid, ok, err := a.pendingGUID(ctx) if err != nil { return err } if !ok { writeError(w, http.StatusConflict, "no admin is awaiting enrollment") return nil } url, err := a.d.Enroll.IssueTOTP(ctx, guid) if err != nil { return err } token, err := a.d.CSRF.Ensure(w, r) if err != nil { return err } return renderHTML(w, http.StatusOK, totpPage(token, url)) } // TOTPVerify verifies the code, activating the identity and minting a session // (the ceremony lives in SPEC-006). func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request) error { guid, ok, err := a.pendingGUID(r.Context()) if err != nil { return err } if !ok { writeError(w, http.StatusConflict, "no admin is awaiting enrollment") return nil } return a.d.Enroll.TOTPVerify(w, r, guid) } // ── step 3 · settings & finish ────────────────────────────────────────────── // Finish persists the mail/locale/timezone settings (a copy of the startup // config with `setup: false`), signals completion (`Done`) and lands the freshly // enrolled admin on their dashboard. // // If `config.yaml` is not writable the values are PRINTED for the operator // (SPEC-005 §4) and setup still completes. func (a *Actions) Finish(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() if a.done.Load() { writeError(w, http.StatusNotFound, "not found") return nil } st, err := a.step(ctx) if err != nil { return err } if st != stepSettings { writeError(w, http.StatusConflict, "the admin is not enrolled yet") return nil } port := 0 if v := strings.TrimSpace(r.FormValue("mail.port")); v != "" { p, err := strconv.Atoi(v) if err != nil || p < 0 || p > 65535 { writeError(w, http.StatusBadRequest, "invalid mail port") return nil } port = p } c := *a.d.Config // a copy: the live config stays the startup snapshot (D-035) c.Setup = false c.Mail = config.Mail{ Host: strings.TrimSpace(r.FormValue("mail.host")), Port: port, Username: strings.TrimSpace(r.FormValue("mail.username")), Password: r.FormValue("mail.password"), From: strings.TrimSpace(r.FormValue("mail.from")), } c.App = config.App{ Locale: strings.TrimSpace(r.FormValue("app.locale")), Timezone: strings.TrimSpace(r.FormValue("app.timezone")), } if d := a.draftDomain(); d != "" { c.Server.BaseURL = d } // R-6: a first boot from config.example.yaml must persist a VIABLE config. // Any secret that is still empty or a placeholder is replaced with a // strong, freshly generated value before it is written. if _, err := a.generateSecrets(&c); err != nil { return err } if err := a.persist(&c); err != nil { return err } a.done.Store(true) a.gate.Clear() // retire the setup code + every outstanding unlock (D-044) a.record(ctx, "setup.complete", "bootstrap finished") target := "/home" if guid, err := a.adminGUID(ctx); err == nil && guid != "" { target = "/" + guid + "/home" } redirect(w, r, target) return nil } // generateSecrets replaces any empty or placeholder secret with a strong, // cryptographically-random value (R-6), so a copy of config.example.yaml boots // straight into productive mode after the wizard. It reports whether it changed // anything. // // The TOTP encryption key feeds AES-256-GCM, whose key must be EXACTLY 32 // bytes (SPEC-003 D-2), so it is base64url of 24 random bytes = 32 printable // characters. The other three are signing keys and only need >= 32 bytes. func (a *Actions) generateSecrets(c *config.Config) (bool, error) { changed := false for _, s := range []struct { name string dst *string n int }{ {"secrets.cookie_secret", &c.Secrets.CookieSecret, 32}, {"secrets.csrf_key", &c.Secrets.CSRFKey, 32}, } { if !config.SecretIsPlaceholder(*s.dst) { continue } v, err := randomString(s.n) if err != nil { return false, err } *s.dst = v changed = true a.d.Logger.Info("setup: generated a strong secret", "name", s.name) } if config.SecretIsPlaceholder(c.Secrets.TOTPEncryptionKey) { v, err := randomString(24) // 24 bytes -> 32 base64url chars if err != nil { return false, err } c.Secrets.TOTPEncryptionKey = v changed = true a.d.Logger.Info("setup: generated a strong secret", "name", "secrets.totp_encryption_key") } return changed, nil } // randomString returns n cryptographically-random bytes, base64url-encoded. // It delegates to config.GenerateSecret so the wizard and the startup path (the // TOTP key, R-6) mint secrets the same way. func randomString(n int) (string, error) { return config.GenerateSecret(n) } // persist writes the config to disk; when it cannot, it prints the values and // instructs the operator (SPEC-005 §4). It is complete either way. func (a *Actions) persist(c *config.Config) error { data, err := config.Marshal(c) if err != nil { return err } if err := a.d.WriteFile(a.d.ConfigPath, data); err != nil { a.printValues(data, err) return nil } a.d.Logger.Info("setup: settings persisted", "path", a.d.ConfigPath) return nil } // printValues is the operator fallback when config.yaml is not writable: the // values go to stdout with an instruction, and setup still completes. func (a *Actions) printValues(data []byte, cause error) { a.d.Logger.Warn("setup: config file is not writable — printing the values for the operator", "path", a.d.ConfigPath, "error", cause) fmt.Fprintf(a.d.Printout, "\n# MyDay setup could not write %s: %v\n"+ "# Write the following to that path by hand, then restart MyDay:\n\n%s\n", a.d.ConfigPath, cause, data) } // ── state ─────────────────────────────────────────────────────────────────── // step is the wizard's position. type step int const ( stepDomain step = iota // 1 · the domain / base URL stepAdmin // 2a · the admin's details stepEnroll // 2b · TOTP enrollment (D-041: no passkey) stepSettings // 3 · mail · locale · timezone ) // step derives the current step from the database and the in-memory draft. func (a *Actions) step(ctx context.Context) (step, error) { n, err := a.d.Users.Count(ctx) if err != nil { return 0, err } if n == 0 { if a.draftDomain() != "" { return stepAdmin, nil } return stepDomain, nil } idn, err := a.d.Identities.ByUserID(ctx, adminUserID) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { // A user exists but has no live identity — restart step 2. return stepAdmin, nil } return 0, err } if idn.Status == auth.StatusPendingEnrollment { return stepEnroll, nil } return stepSettings, nil } // draftDomain returns the base URL captured in step 1, if any. func (a *Actions) draftDomain() string { a.mu.Lock() defer a.mu.Unlock() return a.draft.domain } // adminGUID returns the admin's current GUID. func (a *Actions) adminGUID(ctx context.Context) (string, error) { idn, err := a.d.Identities.ByUserID(ctx, adminUserID) if err != nil { return "", err } return idn.GUID, nil } // pendingGUID returns the admin's GUID when its identity still awaits // enrollment — pending AND inside its bounded enrollment window (SPEC-004 §4, // A1). ok=false means there is nothing to enroll: no identity, another status, // or the window has closed. func (a *Actions) pendingGUID(ctx context.Context) (guid string, ok bool, err error) { idn, err := a.d.Identities.ByUserID(ctx, adminUserID) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { return "", false, nil } return "", false, err } if idn.Status != auth.StatusPendingEnrollment { return "", false, nil } inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, adminUserID) if err != nil { return "", false, err } if !inWindow { return "", false, nil } return idn.GUID, true, nil } // record writes one bootstrap audit row (A11). func (a *Actions) record(ctx context.Context, action, detail string) { if a.d.Audit == nil { return } a.d.Audit.Record(ctx, audit.Event{ Action: action, Subject: fmt.Sprintf("user:%d", adminUserID), Detail: detail, }) } // recordSetup writes an audit row for an event that happens BEFORE the admin // exists (the D-044 unlock). The subject is anonymous — there is no user yet. func (a *Actions) recordSetup(ctx context.Context, action, detail string) { if a.d.Audit == nil { return } a.d.Audit.Record(ctx, audit.Event{ Action: action, Subject: "anonymous", Detail: detail, }) } // writeFile0600 is the default persister: the config holds secrets, so it is // written owner-only. func writeFile0600(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }