- Status: done
- Date: 2026-06-20
- Specs touched:
FIRST_RUN.md(Phase 2 per-profile step set; corrected the hosted-trim summary to include telemetry),CLAUDE.md(standard structured fields — addedzone)
Part of the cloud-VM track in #196. This is C4 (#208), the web-ui half that consumes the gate built in C3a (#206): the first-run wizard, trimmed for the hosted profile, plus the three brain endpoints its non-account steps need. C3a's closing paragraph named this work ("the wizard drops the network / storage / enrollment steps and adds the bootstrap-secret field to the first-admin step") and locked the contract that C4 reuses C3a's bootstrap_secret body field rather than inventing its own one-time URL — honored here.
The wizard's step set is data-driven on purpose: this slice ships the four steps ENVIRONMENT.md # Provisioning keeps for a hosted box — admin (incl. recovery), time zone, telemetry, done — and the same shell serves the appliance. The appliance's network/storage steps (FIRST_RUN.md # Step 1) and the enrollment step (# Step 5) are spliced into the same list by a later change (call it B4) per profile, without touching the shell.
internal/protocol/host.go+internal/hostclient/— newSetTimezoneRequest{Zone}andClient.SetTimezone(ctx, zone)overPOST /v1/system/set-timezone. The brain holds no time-zone state;timedatectlon the host is the source of truth, so the brain endpoint is a validated pass-through.internal/hostagent/(the host seam) —TimezoneSetterconsumer-side interface + an optionalTimezonefield onAgent, and thePOST /v1/system/set-timezonehandler. When the setter is nil (thecmd/host-agentfake / dev loop) set-timezone is an accepted no-op 200 so the wizard works end-to-end without a host; when wired it delegates and maps a setter error to 500. Empty zone → 400.internal/hostagent/timezone/(new provider) —Setter.SetTimezoneshells out totimedatectl set-timezone <zone>, guarded by a zone-shape regex (area[/location...], safe chars) at the privileged boundary. Isolated in its own package so the sharedinternal/hostagentcarries notimedatectldependency; wired bycmd/host-agent-realin both build profiles (wiring_hosted.go+wiring_appliance.go) — an appliance and a hosted VM both run in a time zone.internal/store/(box_meta) — two new keys on C3a'sbox_metaKV table (no migration):BoxMetaTelemetryConsent("true"/"false"; unset ⇒ off) andBoxMetaFirstRunComplete("true"once the wizard's Done step runs).internal/api/firstrun.go(new) — three admin-gated endpoints the wizard's non-account steps call, also reused by Settings later:POST /system/timezone(validates the IANA shape → 422, then pass-through to host → 502 on host failure),POST /system/telemetry(persists consent),POST /system/first-run-complete(writes the marker, idempotent). HelpersprofileName()(unset ⇒appliance) andboxMetaBool().internal/api/auth.go—GET /auth/statenow returns{has_users, first_run_complete, profile}(washas_usersonly): the dashboard gates the wizard onfirst_run_complete, nothas_users, so a half-finished wizard resumes instead of dropping the user onto the dashboard;profileselects the wizard's step set and whether the admin step shows the bootstrap-secret field.POST /setupgained an optionalrecoveryflag (FIRST_RUN.md# Step 2a) —nil/truegenerate the code as before (back-compat for the headless M1c/setup); explicitfalseis the wizard's "off" path: the admin row is created with no recovery hash and the response carries an emptyrecovery_code, so the public recover flow can never be redeemed for that account.web-ui/src/—Setup.vuerewritten as a profile-aware wizard shell rendering one step at a time from a data-driven list; new step components underweb-ui/src/setup/(AdminStep,TimezoneStep,TelemetryStep,DoneStep).auth.tsexposesfirstRunComplete+profile, asetup(u, p, {recovery, bootstrapSecret}), andsetSystemTimezone/setTelemetryConsent/completeFirstRun.App.vuerenders the wizard while!firstRunComplete, with a!firstRunComplete && hasUsers && !currentUser → Loginbranch so an interrupted wizard re-authenticates before resuming (the remaining steps are admin-gated). Shared wizard controls (.check,.warn,select,button.link,.details) added tostyle.css.- Codegen —
make openapi+npm run gen:apiregenerated: the deltas are the three new operations,recoveryon the setup request, and the two new/auth/statefields. - Tests —
internal/api/firstrun_test.go(timezone 200/trim/422/comma-422-regression/host-502/403/401, telemetry persist-on+persist-off/403/401, first-run-complete persist + reflected in/auth/state+ idempotent + 403/401, profile in/auth/statefor appliance + hosted, setuprecovery:true→code,recovery:false→no-code + empty hash + recover() 401); a fakeset-timezoneroute added tonewHarnesswith a 500-sentinel for the host-502 path;internal/hostagent/settimezone_test.go(nil no-op, empty-zone 400, wired-setter applies, wired-setter error 500);internal/hostagent/timezone/timezone_test.go(regex accept/reject + rejection-before-exec).make check+make check-webgreen.
- Realizes
FIRST_RUN.md# Phase 2 for the hosted profile andENVIRONMENT.md# Provisioning ("Setup wizard, trimmed: … what survives is the first admin account (including the recovery code) and time zone. Telemetry consent stays as specced").FIRST_RUN.md's one-line hosted summary (line 5) previously said "trimmed to admin account + time zone", omitting telemetry; corrected to match ENVIRONMENT.md, and the Phase 2 step headings were marked per-profile (which steps the appliance vs hosted vs both show). - Telemetry copy is taken verbatim from
TELEMETRY.md# Locked (the checkbox sentence and the PostHog third-party disclosure inside "What does this collect?"). - The admin step's recovery toggle follows
FIRST_RUN.md# Step 2a (on by default; off surfaces the tradeoff copy; the code is shown once on a save-it screen with a Copy button — clipboard API on hosted HTTPS, a hidden-textareaexecCommandfallback for the appliance's non-securehttp://*.localcontext — and an "I have saved this recovery code" checkbox that gates Continue). The bootstrap-secret field followsENVIRONMENT.md# Admin bootstrap and reuses C3a'sbootstrap_secret.
- Time-zone detection uses the browser's
Intlzone, not IP geolocation.FIRST_RUN.md# Step 3 specs IP-geolocation auto-detect with a list fallback. We default the picker toIntl.DateTimeFormat().resolvedOptions().timeZoneand always show the fullIntl.supportedValuesOf('timeZone')list — no server-side geolocation dependency, and for a hosted box configured "from my own laptop" the operator's browser zone is the better signal. The list is always shown (overridable here, not only in Settings). - The admin step collects a Linux username, not a display name.
FIRST_RUN.md# Identity & display names specs a display-name field slugified to a stable Linux user id (transliterate, reserved-slug list,--/xn--guards). That slugification is deferred; the wizard collects the username directly, matching the brain's currentPOST /setupcontract (the walking-skeleton hardcoded"admin"). When the slug mapping lands, the admin step swaps the field with no shell change. timezone.Setter'stimedatectlexec is not unit-tested. Only the zone-shape regex guard is (the testable branch). Running the real shell-out would mutate the host clock config and need root — exercised in the host-integration lane, the same convention asusermgr(//go:build usermgrtest, root, nspawn) andclockhealth(parse-logic unit tests, realchronycin the lane). Brain-side handlers are 85–100% covered; the remaining gaps are defensive store-500 branches the real-store/no-mock harness can't easily reach.- No VM exercise. Like C3a, the hosted path isn't booted end-to-end here (QEMU outer loop blocked on this box by #189); the wizard is verified on the appliance dev loop (
make dev) and by the Go/web gates.
- B4 — appliance/full wizard steps. Splice the network + storage steps (
FIRST_RUN.md# Step 1) and the secure-URL/enrollment step (# Step 5) into the data-driven step list, per profile, reusing this shell. - Display-name → slug. Replace the admin step's username field with the display-name input + brain-side slugification (
FIRST_RUN.md# Identity & display names). - Settings reuse. The three endpoints are admin-callable any time; Settings → System → Time and Settings → Privacy can drive
POST /system/timezoneand/system/telemetrydirectly. - End-to-end hosted boot. Assert the trimmed wizard (bootstrap-secret field → C3a gate → time zone → telemetry → done → dashboard) in QEMU once the seed-delivery path and #189 land.