diff --git a/internal/core/setup/render.go b/internal/core/setup/render.go
new file mode 100644
index 0000000..ab5736e
--- /dev/null
+++ b/internal/core/setup/render.go
@@ -0,0 +1,95 @@
+package setup
+
+import (
+ "html"
+ "net/http"
+
+ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
+)
+
+// renderHTML writes the wizard page inside the core shell (SPEC-009 §4). The
+// door renders no dynamic HTML itself (SPEC-003 §4); the wizard is the hand-off
+// target for the `setup` workflow.
+//
+// F-2: the bootstrap renders secrets and ceremony state — never cache it.
+func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
+ w.Header().Set("Cache-Control", "no-store")
+ return web.Render(w, status, page)
+}
+
+// writeError writes a terse plain-text error.
+func writeError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(msg + "\n"))
+}
+
+// redirect sends the operator back to the wizard (post/redirect/get).
+func redirect(w http.ResponseWriter, r *http.Request, path string) {
+ http.Redirect(w, r, path, http.StatusSeeOther)
+}
+
+// wizardPage renders the current step inside the shell. The wizard runs before
+// any identity exists, so the page is a GUEST screen: no nav (SPEC-009 §4).
+// Every form carries the signed pre-session CSRF token (A15).
+func wizardPage(st step, token, domain string) web.Page {
+ c := html.EscapeString(token)
+ return web.Page{
+ Title: "MyDay setup",
+ Guest: true,
+ CSRF: token,
+ Content: web.Markup(`
MyDay setup
+Bring this instance to life: the domain, the top admin, then mail and locale.
` +
+ stepBody(st, c, domain)),
+ }
+}
+
+// stepBody renders the section for one step.
+func stepBody(st step, c, domain string) string {
+ switch st {
+ case stepDomain:
+ return ``
+
+ case stepAdmin:
+ return ``
+
+ case stepEnroll:
+ // Step 2 enrolls TOTP only (D-041): a passkey registered over loopback
+ // would bind RP ID 127.0.0.1 and be dead at the public domain. The
+ // admin adds a passkey at first login, via the nudge (SPEC-006 §8).
+ return `2. Enroll the admin
` +
+ `Add an authenticator app. A passkey can be added later, at first sign-in.
` +
+ `Authenticator app
` +
+ `` +
+ ``
+
+ default: // stepSettings
+ return `3. Mail · locale · timezone
` +
+ ``
+ }
+}
diff --git a/internal/core/setup/setup.go b/internal/core/setup/setup.go
new file mode 100644
index 0000000..ce235d0
--- /dev/null
+++ b/internal/core/setup/setup.go
@@ -0,0 +1,524 @@
+// Package setup implements SPEC-005: the loopback-only `/setup` wizard that
+// brings a fresh instance to life.
+//
+// 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 loopback, 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.
+type Enroller interface {
+ TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error
+ 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
+ // 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
+ 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
+ }
+ return &Actions{d: d}, 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.
+func (a *Actions) TOTPBegin(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.TOTPBegin(w, r, guid)
+}
+
+// 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.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.session_key", &c.Secrets.SessionKey, 32},
+ {"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; ok=false means there is nothing to enroll.
+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
+ }
+ 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,
+ })
+}
+
+// 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)
+}