Working spec for what happens between "user downloads the ISO" and "user is on the dashboard." Companion to
SPEC.md,CONTROL_PLANE.md,APP_MANIFEST.md,SERVICE_PROVISIONING.md,APP_ISOLATION.md,STORAGE.md.
Environment profiles. This doc describes the
applianceprofile (BYO box, USB install). In the hosted profile (malmo-operated cloud VM) there is no installer — the box is provisioned from a cloud image and configured at first boot, and the wizard is trimmed to the first admin account (including the recovery code), time zone, and telemetry consent — the network, storage, and enrollment steps are gone (NIC from cloud metadata, no disk selection, enrollment done at provision). SeeENVIRONMENT.md# Provisioning & first-boot (hosted).
First-run is three distinct surfaces:
- Installer (boots from USB, runs once) — hardware checks, disk selection, recovery passphrase, install, reboot.
- Setup wizard (first boot of the installed system) — network, first admin account, optional cloud mention, telemetry, time zone, done.
- Steady state — user opens browser at
malmo.local, lands on dashboard.
Phase 0 (download ISO, flash to USB) is a website concern, not malmo's runtime.
Published as "will it run malmo?" at the install page. Hard requirements, checked by the installer before any disk action:
- CPU: x86-64, 2 cores
- RAM: 4 GB minimum, 8 GB recommended
- Disk: 64 GB minimum on the OS drive
- UEFI boot (legacy BIOS not supported)
- TPM 2.0 present and enabled
The TPM requirement is non-negotiable — it backs encryption-at-rest auto-unlock (see STORAGE.md). Most x86 hardware from ~2016+ has TPM2 (Intel PTT / AMD fTPM, often firmware-based, free, but sometimes disabled in BIOS). The installer's hardware-check screen surfaces this clearly with a "check your BIOS for fTPM/PTT" link.
If any hard requirement fails the installer stops with a clear message. No partial installs.
Linear, mostly mechanical:
- Hardware check. Pass/fail per item from the list above.
- Disk selection.
- User picks the OS drive (default: smallest non-removable disk, typically the NVMe).
- If a second non-removable disk is present, user is offered "use this to expand your storage" — yes by default, picks the largest remaining disk. Single-drive boxes skip the question.
- Confirm wipe. "These disks will be erased: /dev/nvme0n1, /dev/sda. Continue?"
- Install runs. OS image copied, one LUKS recovery passphrase generated and silently stored at
/etc/malmo/secrets/luks-recovery.key, both drives LUKS-formatted with that passphrase, both TPM2-enrolled withsystemd-cryptenrollagainst PCR 7, bootloader installed. The passphrase is not displayed — it lives on the box and is surfaced later from Settings → Storage → Advanced if needed. SeeSTORAGE.md# Encryption posture for the reasoning. - Reboot. USB removed, box boots from disk, auto-unlocks via TPM.
No language picker (English-only v1). No license screen (TBD with legal but not blocking). No "create user" step — the wizard does that.
First boot lands on a local web UI at malmo.local (mDNS) or the box's LAN IP. The wizard is one-shot, a few steps, and disappears once done.
Per-profile step set. The steps below are tagged by where they run. Appliance: all of them. Hosted: only the profile-independent ones — Step 2 (first admin, incl. 2a recovery), Step 3 (time zone), Step 4 (telemetry), Step 6 (done); Step 1 (network) and Step 5 (secure URLs / enrollment) are gone (ENVIRONMENT.md # Provisioning). The wizard renders from a data-driven step list keyed on the profile, so the shared shell serves both. The completion marker that ends the wizard is brain-side (box_meta.first_run_complete), set by Step 6 and read by the dashboard via GET /auth/state — the wizard runs until that marker is set, so an interrupted wizard resumes rather than dropping the user onto the dashboard.
The wizard branches on what the box has:
- Ethernet link detected and DHCP succeeded: the step is silent — connection name, IP, and gateway are shown for confirmation, "Continue" advances. No prompt; the common case is a no-op.
- No ethernet carrier (cable unplugged or WiFi-only box): the wizard runs a WiFi scan and presents an SSID picker. The user taps a network and enters the password. Hidden networks are reachable from a "Join other network…" affordance that takes SSID + password + security type. Enterprise WPA (802.1X) is deferred — out of scope for v1.
- Ethernet present but the user wants WiFi anyway (laptop near the router but they'd rather not run a cable): a secondary "Use WiFi instead" link from the ethernet-detected screen reveals the same SSID picker.
After WiFi password entry, the wizard tests the connection and waits for DHCP. On failure it surfaces a clear retry inline (wrong password, couldn't reach DHCP, no signal). On success the WiFi connection is saved (NM's connection store) and pinned as the primary connection (BOOT.md # NetworkManager).
Ethernet is recommended, not required. Discovery-based smart-home apps (lan: true, macvlan-attached — APP_ISOLATION.md) depend on reliable multicast, which most consumer APs rate-limit or filter; guest-network "client isolation" blocks peer discovery entirely; macvlan on WiFi is unreliable across drivers. The wizard surfaces this once, on the WiFi-confirmation screen: "You're on WiFi. Most apps work fine — but smart-home apps that find devices on your network (Home Assistant, camera apps) work much better over an Ethernet cable. You can switch later from Settings → Network." Not a blocker; just honest.
Other Step 1 details:
- DHCP by default; static-IP option for power users, available on both ethernet and WiFi connections (Settings → Network later, or expanded "Advanced" in the wizard).
- The detected primary interface is what
lan: trueapps will macvlan-attach to. On a WiFi-primary box,lan: trueapps install but are flagged with adiscovery-on-wifi-degradedwarning in the dashboard (HEALTH.md-shaped, but per-app rather than global). - IPv6 opportunistic — if the network provides it, NetworkManager picks it up; we don't ask.
- Captive-portal networks (hotels, dorms) are explicitly out of scope. The wizard can't navigate a captive portal page; the failure mode is a stuck "couldn't reach DHCP" with a hint to use a real home network.
Cross-device note. If the user is running the wizard from another device on a wired network and changes the box to a different WiFi, the wizard warns that the dashboard URL may move to a new IP, finishes the WiFi switch last, and surfaces the new LAN IP on the "done" screen so the user knows where to reconnect.
Hosted. On the appliance, the trust that lets this person create the founding admin is physical presence at the box during first boot. A hosted cloud VM has no such gate, so the first admin's creation is gated on a one-time admin-bootstrap secret delivered in the provisioning seed (
ENVIRONMENT.md# Provisioning & first-boot, "Admin bootstrap"). The operator receives the secret out-of-band (the cloud console, like a VPS root password) and enters it alongside the first admin's name + password; the brain constant-time-compares it against the seeded secret's hash before creating the account. Until a seed lands the box reports "not provisioned" and/setupis closed; once the first admin exists the gate is spent. The brain stores only the secret's hash, never the plaintext, and never serves it. Everything else in this step (the account itself, the recovery code) is identical to the appliance.
- Two fields: first name + password. That's it.
- The first user created is automatically an admin. Admins can create more users (admins or members) later in Settings. Admins are added to the Linux
sudogroup (rescue path when the dashboard is broken); members are unprivileged. SeeUSERS_AND_GROUPS.md. - No username field — the display name is what the user types and what the UI shows everywhere. See "Identity & display names" below for how this maps to the underlying OS user.
- The password set here is the user's malmo password — used for the dashboard, and for SSH and SMB file shares if/when those are enabled per-account from Settings. One credential across all surfaces; what changes per-protocol is whether the account is allowed to use it. See
AUTH.md# Device access (SSH + SMB).
Immediately after the password is set, the wizard surfaces a recovery-code step. The toggle is on by default.
☑ Save a recovery code (Recommended)
If you forget your dashboard password, this code is the only way back in. Without it, you'd need to reinstall and restore from backup. Take a photo of the code with your phone — it'll back up automatically to your photos, and you'll have it when you need it.
If the user proceeds: the brain generates a 16-character code formatted as XXXX-XXXX-XXXX-XXXX, shows it once full-screen with a copy button, and requires an "I have saved this" checkbox before moving on. The code is hashed (argon2id) in the brain's SQLite and is single-use; plaintext is never persisted.
If the user toggles off: an explicit confirmation appears — "You won't be able to recover your account if you forget your password. Continue without a recovery code?" — forcing acknowledgment of the tradeoff.
The recovery code is separate from the LUKS recovery passphrase (STORAGE.md # Encryption posture). The LUKS passphrase is generated silently and stored on the box; the dashboard recovery code is the only thing the user is asked to save at first-run. Don't combine — see AUTH.md for the threat-model reasoning.
Redemption flow (when the user later logs in via the recovery code): the brain validates the code, then forces a set-new-password screen (no skip), then generates and shows a fresh recovery code once (single-use semantics — the old code is consumed). Full mechanics in AUTH.md # Using the recovery code.
- Auto-detected via IP geolocation. No prompt if successful.
- If the box has no internet at first-run, the wizard falls back to asking the user to pick from a list.
- Always overridable from Settings later.
Full time / NTP model in TIME.md. NTP itself (chrony with NTS sources) is up by the time the wizard runs — no user-facing NTP configuration at first-run.
- One unchecked checkbox: "Send anonymous usage statistics and crash reports to help improve malmo."
- Off by default. Plain language. No dark patterns.
- Inline "What does this collect?" disclosure expands to show the field allowlist.
- Toggleable from Settings → Privacy later — an admin-only Box panel, since the toggle is box-wide (
SETTINGS.md# panel inventory). The first-run prompt is the founding admin making that box-level choice once.
Full spec: TELEMETRY.md. The one toggle covers both the usage stream and the crash stream; both go to telemetry.malmo.network (a malmo-controlled endpoint, not a third-party SaaS).
This step is two coupled choices presented as one. Turning on "Use secure (HTTPS) URLs for my apps" requires enrolling the box with malmo.network (to get the subdomain + Let's Encrypt cert). They're the same decision for a new user, so the wizard frames them together.
Framing on the screen:
"Use secure (HTTPS) URLs for your apps?"
Some apps need HTTPS to work fully — cameras, password managers, app-like installs on your phone. We'll give your malmo a name like
cindy-zx9.malmo.networkand a real certificate, so your apps are reachable at HTTPS URLs on your home network. Your data never leaves your box; only DNS lookups go through our servers.Tip: if anyone in your household uses an Android phone, you'll want this on. Android can't open the default
.localURLs from a browser; the secure URLs work everywhere.You can also do this later from Settings.
The Android line is not decorative — it's the load-bearing reason this toggle exists for many households. See DISCOVERY.md for the full client-compatibility picture; the short version is that Android does not wire mDNS into the system resolver and there is no workaround at malmo's layer.
Two buttons: "Yes, set it up" and "Skip for now".
If the user proceeds:
- Wizard shows a "Name your malmo" field with a suggested name (e.g.
cindy-zx9). Editable; user can accept the suggestion or type their own (e.g.the-perez-family). - Availability is checked live against the enrollment API. On collision, the wizard offers alternatives or invites the user to try again. Reserved names (single dictionary words, malmo-internal slugs) are rejected with a clear message.
- On confirm: box is enrolled, the API token is persisted, cert issuance starts in the background, and the "Use secure URLs" toggle is set to ON. The wizard moves on.
The name is frozen at this step for the life of the install — changing it later requires re-enrollment, which decommissions the old subdomain. See MALMO_NETWORK.md for the rationale ("Locked: pick the name at enrollment, no rename afterward").
If the user skips:
- mDNS publishing for
.localstill happens; dashboard, all apps, and SSH work normally over HTTP. - The "Use secure URLs" toggle in Settings → Network appears disabled, with a one-click "Enroll your box to enable" affordance next to it.
- Apps that declare
needs_secure_context: true(camera/mic/PWA-dependent apps; seeAPP_MANIFEST.md) can still be installed but show a warning that some features may not work without HTTPS. - The user can run this same flow later from Settings → Network at any time.
This step replaces the older "cloud features mention" placeholder. It is still not a malmo account sign-in — there is no malmo cloud account at v1. The enrollment is per-box, anonymous beyond the box-id, and revocable.
History note: an earlier version of this wizard step presented
.malmo.networkas an always-on "secure URL channel" exposed per-app via arequires_httpsmanifest flag. Replaced 2026-05-14 by the global-toggle model. SeeDECISIONS.md.
Land on dashboard. Wizard is over.
The user types a first name and a password. The first name is shown everywhere — login screen, dashboard, "Cindy installed Photos," sharing UIs, audit log. There is no "username" concept exposed.
The display name is slugified to a stable Linux user ID:
- Transliterate to ASCII (
José→jose,李→li). - Lowercase, strip to
[a-z0-9], collapse runs. - Empty result falls back to
user. - Check against a reserved-slug list:
root,admin,daemon,postgres,redis,mysql,nobody,www-data,sshd,systemd*,malmo, plus standard system. On hit, append1,2, ... until free. - Display-name uniqueness is enforced at creation, so collisions in step 4 are the rare path.
The [a-z0-9]-strip-and-collapse in step 2 already guarantees the two reservations the <slug>--<user> personal-instance scheme depends on (DASHBOARD.md # instance naming): a username slug can never contain -- (runs collapse to a single -) nor start with xn--. The brain also enforces both as an explicit guard at the user-creation boundary.
UID assigned from the malmo-reserved range (3000+). Home directory: /home/<slug>/. Use-case folders created at account setup: Photos/, Documents/, Movies/, Music/, Notes/, Downloads/ (STORAGE.md # What apps and users actually see).
- No prefix on the slug.
cindy, notmalmo-cindy. SSH (ssh cindy@box.local), paths (/home/cindy/Photos/), and Samba usernames stay clean. Collision risk is real but small, and the reserved list + UID-range filtering covers it without per-day verbosity cost. - Display name is mutable, slug is stable. A user renames "Cindy" → "Cynthia" — display flips, slug stays
cindy, paths and ownership untouched. Renaming a Linux user is a destructive operation we don't expose. - Display-name uniqueness enforced. Two "Cindys" on one box is rejected at admin-create time with a "use Cindy K. or Cindy 2" prompt. Edge case for the 1–2-user installs we expect.
Two roles in v1:
| Admin | Member | |
|---|---|---|
| Manage users | ✅ create, promote, demote, delete | ❌ |
| Configure box | ✅ network, storage, telemetry, etc. | ❌ |
| Install Tier-2 apps (Tailscale, SMB, DLNA) | ✅ | ❌ |
| Install Tier-3 apps | ✅ | ✅ — for themselves |
| Use Tier-3 apps | ✅ | ✅ — their own per-user instance |
The Tier-3 install permission is per-user: when a member installs Photos, their per-user Photos instance spins up against their ~/Photos/. Other users are unaffected. They can also install Photos for themselves (separate instance, separate data) or not.
Kid-account restrictions (limit which apps a member can install, content filters, time-of-day rules) are explicitly deferred — useful for the family-box scenario, not v1.
Once the wizard is done the box is fully usable. The dashboard is the user's primary surface; the wizard never reappears.
What's on the dashboard at first arrival is an open question (see below) — empty, suggested apps, or a starter bundle.
malmo.local resolves out of the box on macOS, iOS, and Linux (with nss-mdns, almost universal). Two cases need help:
- Windows clients need Apple's Bonjour service. Most Windows 10/11 installs do not have it. The "Add another device" / share-link surface in the dashboard detects a Windows User-Agent visiting for the first time and links to the Bonjour Print Services installer with a one-line explanation. If the household is using the secure-URL path, this is moot —
cindy-zx9.malmo.networkresolves via public DNS on every OS. - Android browsers do not resolve
.localat all (see Step 5 above andDISCOVERY.md). The only path that works is the secure-URL scheme; the same share surface surfaces this for Android visitors when secure URLs are off.
- Cloud account / sign-in. Box is local-only at v1; cloud features mentioned but not installed.
- Multi-user role hierarchy beyond admin/member. Kid accounts, app-level visibility, scheduled access — all deferred.
- App-level cross-user sharing primitives. v1 sharing is "drop it in
shared/" only. - Email on file for users. No "forgot password" flow at v1; admins reset member passwords manually.
- Avatars / profile pictures. Cosmetic; deferred.
- Multi-language UI. English only.
- License acceptance screen. Pending legal, not blocking the design.
Tracked centrally in NEXT.md. Resolutions land back here (or in DECISIONS.md if they flip a position).