docs: update the plan, specs and deploy guide for the setup toggle (D-045)

- PROJECT_PLAN.md: rewrite §5.1 for the toggle gate, mark D-025 superseded,
  refresh D-026 / O-8 / A13 / §12 and add the D-045 change-log entry.
- SPEC-005: §1/§3/§5/§6 (AC-1/AC-2/AC-7 + AC-8) reflect the three states and
  the optional code; §9 marks the D-044 tunnel bits superseded; new §10.
- SPEC-003: §4/§7 pipeline + AC-7/AC-10/AC-14; new §13 (D-045); D-025 ref
  removed from the governing decisions.
- docs/deploy-zoraxy.md: §5 rewritten around the toggle + optional code;
  §1/§2/§3/§7 de-tunnelled; see-also updated.
- README: first-run + deployment sections rewritten (enable setup: true,
  open /setup, complete, the app flips it off).
- config.example.yaml: document the toggle and the optional code.
This commit is contained in:
Claus Lohmar 2026-10-15 06:47:36 +00:00
parent c7229e23ff
commit 9fdc9eded6
6 changed files with 268 additions and 166 deletions

View file

@ -98,7 +98,7 @@ DOOR (top level)
/{domain}/ → 302 → /home (the ONE canonical redirect)
/{domain}/home → neutral landing page (public, no user)
/{domain}/access → password / access recovery
/{domain}/setup → the bootstrap wizard (setup mode · loopback only)
/{domain}/setup → the bootstrap wizard (the `setup` toggle, D-045)
/{domain}/api | static | media | ws | dev → reserved infrastructure
/{domain}/holy-shit → 404 (unknown · not reserved · not a GUID)
@ -287,7 +287,7 @@ the highest role, the **root of the chain**. When the `users` table is empty, th
### 5.1 First run — the `/setup` wizard
```
fresh deploy → config.setup: true → setup mode: `/` and `/setup` show a first-run page
fresh deploy → config.setup: true → `/setup` serves the WIZARD
Step 1 the domain / base URL
Step 2 the top admin — name, email, **TOTP** enrollment (the passkey is added at first
sign-in, D-041) (user_id = 1, role: admin)
@ -297,18 +297,20 @@ fresh deploy → config.setup: true → setup mode: `/` and `/setup` show a firs
```
- `setup` is a **reserved `PARAM1`** (always — so no user can ever be named `setup`).
- **The bootstrap is gated — never first-visitor-wins (D-025 · D-044).** In setup mode the **wizard**
is served to **either** **(a)** a **direct tunnel** — a loopback TCP peer with **no**
`X-Forwarded-For` — **or** **(b)** a request **unlocked with the printed SETUP CODE** (D-044).
Before it is unlocked, `GET /` and `GET /setup` show a friendly **first-run page** (never a bare
404, so the domain does not look "down"); every other route is 404.
- **The setup code (D-044).** A **128-bit** code is generated at first run and printed in a
**startup banner** (`setup_code` in config overrides it); it is **single-use** and
**rate-limited**. This is the general gate for the **LXC/Proxmox** topology, where an SSH tunnel
is impractical (the tunnel would land on the container *gateway*, not loopback).
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator unlocks it (the
code, or an SSH tunnel to `127.0.0.1:8080`) and completes it, then exposes the instance.
- *README: do NOT expose MyDay publicly before the first admin exists.*
- **The gate is the config toggle `setup` (D-045).** Three states:
- **`setup: true`** → `/setup` serves the **wizard** (the operator's deliberate switch); `GET /`
points at it; every other route is 404.
- **`setup: false` and no admin** → the app is **LOCKED**: `GET /` and `GET /setup` show a
**boilerplate** telling the operator to set `setup: true` and restart; every other route is 404.
**This state is not publicly claimable.**
- **completion** → the app writes **`setup: false`** and creates the admin → productive.
- **The setup code is OPTIONAL (D-044 · D-045).** When `setup_code` is set it is a **second factor**
(the code form comes first; single-use · rate-limited · redacted — A8). Empty ⇒ the toggle alone
admits. *(D-044's generated-code banner, the peer/tunnel gate D-025, and the bare 404 are retired.)*
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator enables setup and
completes it, then exposes the instance.
- *README: do NOT expose MyDay publicly before the first admin exists; set `setup_code` (or complete
setup immediately) because while `setup: true` first-visitor-wins is possible (D-045 residual).*
### 5.2 Admin recovery — a config-gated enrolment link
@ -466,8 +468,8 @@ the top admin (first user)
| **D-022** | 2026-10-13 | **Bootstrap:** `users` empty → **setup mode**; the top admin is created at a **gated** path; setup ends at the first user. | **Gate it** (loopback and/or setup token) or "first user" = "first visitor". |
| **D-023** | 2026-10-13 | **Admin recovery = `reset_admin` config toggle** — rotate the top admin's identity to `pending_enrollment`, log the new GUID; audited; auto-clears. | The config is the offline-held root of trust. |
| **D-024** | 2026-10-13 | **Roles (KISS): `admin > director > manager > team`.** You may create only roles **below** your own; the new user's manager defaults to the creator. | Answers "who may create a user?" |
| **D-025** *(rev. 2026-10-15b)* | 2026-10-13 | **The bootstrap is gated — never first-visitor-wins.** In **setup mode**, `/setup` is served to a request that is **either** **(a)** a **direct tunnel** — **TCP peer loopback *and* no `X-Forwarded-For`** — **or** **(b)** **unlocked with the setup code** (D-044). Anything else → the **boilerplate**/404. The app may bind `0.0.0.0` (D-043). | **Re-revised**: a nested **LXC** tunnel lands on the container **gateway**, not loopback — so gate (a) alone is unusable there. The **setup code** (D-044) is the general gate. |
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **setup-mode only · the setup gate (D-025/D-044)** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
| ~~D-025~~ *(rev. 2026-10-15b)* | 2026-10-13 | ~~**The bootstrap is gated — never first-visitor-wins.** In **setup mode**, `/setup` is served to a request that is **either** **(a)** a **direct tunnel** — **TCP peer loopback *and* no `X-Forwarded-For`** — **or** **(b)** **unlocked with the setup code** (D-044).~~ | **SUPERSEDED by D-045** — the peer/tunnel gate is retired; the gate is the `setup` toggle (the **setup code** stays as an optional second factor). |
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **only while the `setup` toggle is on (D-045)** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
| **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. |
| **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. |
| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). |
@ -485,7 +487,8 @@ the top admin (first user)
| **D-041** | 2026-10-14 | **The setup wizard enrolls TOTP only.** Any **TOTP login with no passkey** is **nudged** to enroll one, with *not now* / ***don't ask again*** (`users.passkey_opt_out`). The nudge is **suppressed while the origin is loopback** (or in setup mode). | Resolves **O-13**: a passkey enrolled over loopback binds RP ID `127.0.0.1` and is unusable at the public domain. Passkeys are encouraged, not forced — TOTP stays a valid factor (D-005). |
| **D-042** | 2026-10-14 | **TOTP-only enrollment is specific to the bootstrap admin (`/setup`).** **Invited users enroll both factors** (Passkey + TOTP) at the real origin; the passkey **nudge** remains the safety net for any identity without a passkey. | Resolves **O-14** (owner, Option A): keep the **strongest default** where the origin permits it. |
| **D-043** | 2026-10-15 | **Deployment: behind Zoraxy.** The app listens on **`0.0.0.0:8080` over HTTP**; **TLS terminates at Zoraxy** at **`10.1.0.10`**; the public origin is **`https://myday.sechpoint.app`** (`base_url`). `trusted_proxies` = `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from the proxy to set `Secure` / `__Host-` cookies and HSTS. | The app is **not** the TLS endpoint, so it must honour the proxy's scheme header. **Firewall `:8080` to Zoraxy only.** |
| **D-044** | 2026-10-15 | **First-run access = a printed SETUP CODE + a boilerplate page.** On first run the app generates a **128-bit setup code** and prints it in a **startup banner** (overridable via `setup.code` in config). `GET /setup` shows a **first-run boilerplate** with a **code-entry form** — *never a bare 404*, so the domain does not look "down"; the **correct code unlocks the wizard**. The code is **single-use**, **rate-limited**, and **redacted from the logs** (A8). | The topology (**LXC/Proxmox + Zoraxy**) makes an SSH tunnel impractical — so the gate is a **code**, not a peer. Grafana/Jupyter-style: it still closes first-visitor-wins. |
| **D-044** | 2026-10-15 | **First-run access = a printed SETUP CODE + a boilerplate page.** On first run the app generates a **128-bit setup code** and prints it in a **startup banner** (overridable via `setup_code` in config). `GET /setup` shows a **first-run boilerplate** with a **code-entry form** — *never a bare 404*, so the domain does not look "down"; the **correct code unlocks the wizard**. The code is **single-use**, **rate-limited**, and **redacted from the logs** (A8). | The topology (**LXC/Proxmox + Zoraxy**) makes an SSH tunnel impractical — so the gate is a **code**, not a peer. Grafana/Jupyter-style: it still closes first-visitor-wins. **Amended by D-045 — the code is now OPTIONAL.** |
| **D-045** | 2026-10-15 | **The setup gate is the config toggle `setup`.** `setup: true` ⇒ `/setup` serves the wizard **at the public domain**; completing it writes **`setup: false`**. `setup: false` **and no admin** ⇒ a **boilerplate** telling the operator to enable setup (that state is **not** publicly claimable). The **setup code** (`setup_code`) becomes **OPTIONAL** — a second factor **when set**. The **peer/tunnel gate is retired** (D-025 superseded). | Owner's model — an LXC+Zoraxy deployment can't tunnel, and the toggle is the operator's deliberate switch. **Residual:** while `setup: true`, whoever reaches `/setup` first can claim the admin — so **set `setup_code`** or complete setup immediately. |
---
@ -499,7 +502,7 @@ the top admin (first user)
| O-5 | `reset_admin` semantics | **RESOLVED** (D-033): rotate the identity · **config approval flag** · email the enrolment link to user 1's address on record · flag cleared. |
| O-6 | GUID placement for actions | **RESOLVED** (D-032): the GUID **rides the path**; `do` / `action` carry the module/function, never identity. |
| O-7 | One top admin or several | **RESOLVED** — **exactly one** ("the Highlander"). The root of the chain; recovered via D-033. |
| O-8 | Bootstrap gate | **RESOLVED** (D-025, re-revised 2026-10-15b): the **tunnel OR the setup code** (D-044), enforced **per request**; the boilerplate replaces the bare 404. |
| O-8 | Bootstrap gate | **RESOLVED** (D-045): the gate is the **`setup` config toggle**; the **setup code** (D-044) is an **optional** second factor. The peer/tunnel rule (D-025) is **superseded**; the boilerplate replaces the bare 404. **Residual:** while `setup: true`, first-visitor-wins unless `setup_code` is set. |
| O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. |
| O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. |
| O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** |
@ -532,7 +535,7 @@ the top admin (first user)
| A10 | GUID **tombstoned, never recycled** | HIGH | designed (D-008) |
| A11 | Every auth event audited (bootstrap, enroll, login, recovery request/approve/deny, revoke, `reset_admin`) | HIGH | open (L4c: `Sessions.GC` · `UpdateWebAuthnSignCount` unaudited) |
| A12 | Changing the **email on record** is a privileged, audited operation | HIGH | open |
| A13 | **Setup window hardened** — `/setup` serves the **wizard** only in setup mode and only to **either** the **direct tunnel** (a loopback TCP peer **with no `X-Forwarded-For`**) **or** a request **unlocked with the printed setup code** (D-025 re-revised · D-044); `GET /` and `/setup` show the **first-run boilerplate** (never a bare 404), every other route 404; the `setup` module **pinned at the door (M1c/L3s)**; the unlock **rate-limited** with the `/setup` class (A3) and the code **redacted** (A8); `/setup` → 404 once productive | HIGH | **enforced** |
| A13 | **Setup window hardened** — `/setup` serves the **wizard** only while the **`setup` toggle** is on (D-045); `setup: false` **and no admin** ⇒ a **locked boilerplate** at `/` and `/setup` (never a bare 404), every other route 404; when **`setup_code` is set** the code form comes first (D-044) — the code **single-use** and **rate-limited** (A3), the unlock **redacted** (A8); the `setup` module **pinned at the door (M1c/L3s)**; `/setup` → 404 once productive. **Residual:** while `setup: true`, first-visitor-wins unless `setup_code` is set. | HIGH | **enforced** |
| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open |
| A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | **enforced** — signing keys generated at startup in setup mode (M1s) |
@ -559,7 +562,7 @@ the top admin (first user)
|---|---|
| **git repo + `.gitignore`** | `git init`; the real `config.yaml` is excluded **from the very first commit** (D-010); `config.example.yaml` is committed. |
| **`AGENT.md`** | the repo's **operating instructions for AI agents**: what MyDay is, the doctrine (§3–§4), the **rule of the door**, the stack, and a pointer to **this plan as the source of truth**. |
| **`README.md`** | human quickstart — build, run, config, **and the first-run rule: do NOT expose MyDay publicly before the first admin exists; unlock the wizard with the printed setup code (or an SSH tunnel to `127.0.0.1:8080`).** |
| **`README.md`** | human quickstart — build, run, config, **and the first-run rule: do NOT expose MyDay publicly before the first admin exists; enable `setup: true` and (optionally) set `setup_code` to claim the wizard.** |
| **`docs/`** | this plan at the root; specs later in `docs/specs/`. |
| **CI (later)** | build + test on push. |
@ -656,3 +659,5 @@ shell/layout lives in `core/web`; a module's views live with the module.
| 2026-10-15 | **D-025 (revised) + D-043 ✅ IMPLEMENTED.** **Bind refusal removed** — `server.CheckSetupBind` / `ErrNonLoopbackSetup` / `server.IsLoopback` deleted; `Serve` binds any address (the deployment binds `0.0.0.0:8080`). **Setup gate is per request and strict** — `httpx.SetupRequestAllowed` requires a **loopback TCP peer AND no `X-Forwarded-For`**, evaluated on the RAW request (captured before `ClientIP` SETS the header); anything else → **404 before dispatch**; an unparseable peer fails closed (AC-7/AC-10). **Scheme is per request (D-043)** — `httpx.RequestIsHTTPS` (direct TLS, or a **trusted** proxy's `X-Forwarded-Proto`), carried on the request context (`httpx.WithSecure`), so the session cookie, the **`__Host-`** prefix, the pre-session/ceremony cookies and **HSTS** all agree; the plain-http tunnel keeps the **bare, non-Secure** cookie — which also fixes the latent `config.example.yaml` bootstrap (an `https://` `base_url` no longer force-marks the cookie Secure over the tunnel). `config.IsSecure` **removed** (dead). `config.example.yaml` documents the Zoraxy topology + the **firewall-`:8080`-to-Zoraxy-only** rule; the README gains a **Deployment** section and the tunnel-only bootstrap. Syncs **SPEC-003 §4/§6/§7/§9** and **SPEC-005 §3/§5/§6**. Gate green (`build` · `vet` · `test -race` — 18 pkgs) · **static CGO-free** binary · `routing` purity intact. Live smoke (real binary): `/setup` via the tunnel → **200**, `+ X-Forwarded-For` → **404**, `/home` → **404**, `X-Forwarded-Proto: https` → **HSTS + Secure** cookie, plain http → **neither**. |
| 2026-10-15 | **Deployment docs for D-043 (Zoraxy).** Added **`docs/deploy-zoraxy.md`** (topology · the app `config.yaml` · the Zoraxy **ProxyHost** with `X-Forwarded-For` + `X-Forwarded-Proto` · the **firewall `:8080` from `10.1.0.10` only** · the **tunnel-only bootstrap** + the restart that adopts the persisted secrets · the **systemd** install · the **misconfig warnings**) and **`deploy/myday.service`** (a `Type=simple` · `Restart=on-failure` unit with `User=`/`WorkingDirectory=`/`ExecStart=`/`After=network-online.target`). `config.example.yaml` now sets **`trusted_proxies: ["10.1.0.10"]`** (Zoraxy) with the silent-failure + firewall caveats; the README Deployment section points at the guide. **Docs/deploy only — no code change**; gate green. |
| 2026-10-15 | **D-044 ✅ IMPLEMENTED — the setup code + the first-run boilerplate (D-025 re-revised).** The real topology is **LXC (11020) behind Proxmox + Zoraxy**, so the SSH tunnel is impractical (it lands on the container *gateway*, not loopback). `core/setup` gains a **`Gate`** — a **128-bit setup code** (generated at first run, printed in an unmistakable **startup banner**; `setup_code` overrides it) plus a **signed unlock cookie** (`myday_setup`, carrying a keyed one-way **fingerprint**, never the code). The door (`Deps.SetupGate`) now serves the **wizard** to **either** the tunnel **(a)** or an **unlocked** request **(b)**; `GET /` and an un-unlocked `GET /setup` render a friendly **first-run page** with the code form — **never a bare 404**, so the domain stops looking "down" — and every other route stays 404. The unlock is **single-use**, **rate-limited** (the `/setup` A3 class), **CSRF-protected (A15)** and **redacted** (A8); a wrong/absent code gets a **generic 403**. New: `httpx.Signer.Fingerprint` · `config.setup_code` · `setup.Gate` (`gate.go`) · the `unlock` action · `server.SetupGate`/`serveBoilerplate`. Docs: `deploy-zoraxy.md` §5 rewritten for the LXC/Proxmox + code flow (tunnel kept as gate a), README first-run/deploy sections, SPEC-003 §7/§9 (AC-7 · AC-10 · AC-14) and SPEC-005 §3/§4/§6/§9. Gate green (`build` · `vet` · `test -race`) · **static CGO-free** binary · `routing` purity intact. **Live smoke** (real binary, proxy-style `X-Forwarded-For`): the banner prints the code · `/` and `/setup` → **200 boilerplate** · `/home` → **404** · the tunnel → the wizard · wrong code → **403 generic** · correct code → **303** + unlock cookie → the wizard · a spent code → **403** · the code absent from the access log and redacted when hand-typed into the query. |
| 2026-10-15 | **Setup gate simplified to the config toggle (D-045).** `setup: true` enables `/setup` at the **public domain**; **completion writes `setup: false`**; `setup: false` + no admin → a **locked boilerplate** (not claimable); the **setup code becomes OPTIONAL**; the **peer/tunnel gate is retired** (D-025 superseded). **Residual:** while `setup: true`, whoever reaches `/setup` first can claim the admin — set `setup_code` or finish immediately. SPEC-005 §3/§4 revised. |
| 2026-10-15 | **D-045 ✅ IMPLEMENTED — the toggle gate, the optional code, the retired tunnel.** The gate is `config.setup`: **`setup: true`** → `/setup` serves the **wizard** (or the **code form** first when `setup_code` is set, D-044); **`setup: false` + no admin** → the **LOCKED** state (`/` and `/setup` show an "enable setup" boilerplate, every other route 404, **not claimable**); **completion** writes **`setup: false`**. `setup.New` **no longer generates a code** — blank ⇒ `Gate.Required()` false ⇒ the toggle alone admits. **Retired:** `httpx.SetupRequestAllowed` + `httpx.IsLoopbackIP` (deleted) and the `!bootstrapped()` fail-safe (now the locked predicate, latched at startup). **Kept:** the XFF/XFP handling for the **client IP** and the `Secure`/`__Host-` cookies + HSTS (A6/A11/D-043). New door seams: `Deps.LockedMode`, `SetupGate.{Required,CodeForm,Landing,Locked}`; `Gate` renders three first-run pages. The **startup banner** states the mode and prints the code **only when required**; the locked state prints *"set `setup: true` and restart"*. Docs: SPEC-003 §7/§9 (AC-7 · AC-10 · AC-14), SPEC-005 §1/§3/§5/§6/§9/§10, `deploy-zoraxy.md` §1/§2/§3/§5/§7, README first-run/deploy, `config.example.yaml`, A13 · O-8 · §5.1 · D-025 superseded. Gate green (`go build` · `go vet` · `go test -race` — 18 pkgs) · **static CGO-free** binary · `routing` purity intact. **Live smoke** (real binary, all three states): locked → `/`·`/setup` **200 boilerplate**, `/home` **404**, unlock **404**; `setup: true` no code → `/setup` **wizard**, `/` **landing**, `/home` **404**; `setup: true` + code → **code form** → wrong code **403** → correct code **303** + unlock cookie → **wizard**; full bootstrap (`domain.save`→`admin.create`→`admin.totp.begin/verify`→`finish`) → `config.yaml` **`setup: false`** → `/setup` **404**, `/` **302**. |

View file

@ -38,75 +38,83 @@ go build -o myday ./cmd/myday
---
## ⚠️ First run — the setup code
## ⚠️ First run — enable setup, then complete the wizard
The **first user becomes the top admin** (the root of the chain). MyDay bootstraps through a
**`/setup` wizard** that is **gated — never first-visitor-wins** (D-025/D-044). On first start it
prints a **one-time 128-bit SETUP CODE** in its startup log:
The **first user becomes the top admin** (the root of the chain). Setup is gated by the **`setup`
config toggle** (D-045): enable it, start MyDay, complete the wizard — the app then writes
**`setup: false`** and the instance becomes productive.
```
======================================================================
MyDay is NOT set up yet — no admin exists.
======================================================================
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-…
```yaml
# config.yaml
setup: true # enable the /setup wizard
setup_code: "" # OPTIONAL second factor — set it if the domain is reachable by others
```
Open the domain (or `http://localhost:8080`) and enter that code at `/setup`. **Until then every
page is a friendly first-run page — never a bare 404** — and every other route answers 404. Read
the code again with:
1. **Start MyDay.** The startup banner states the mode and prints the setup URL.
2. **Open the wizard at `/setup`** (e.g. `https://myday.sechpoint.app/setup`).
- `setup_code` **empty** → the wizard is served directly;
- `setup_code` **set** → the code form comes first; enter the code printed in the startup banner.
3. **Complete the wizard** — domain → top admin (TOTP) → mail/locale. It creates the admin and
writes **`setup: false`** to `config.yaml`.
4. **Restart** so the app adopts the persisted secrets: `systemctl restart myday`.
While `setup: true` and setup is unfinished:
- `GET /` points at the wizard, and `GET /setup` serves it (or the code form);
- **every other route answers 404** — there is no app yet.
While `setup: false` and **no admin exists**, the instance is **locked**: `GET /` and `GET /setup`
show a page telling you to set `setup: true` and restart, and every other route 404. This state is
**not publicly claimable** — a fresh instance never exposes its bootstrap by accident.
> **Residual risk:** while `setup: true`, whoever reaches `/setup` first can claim the admin.
> **Set `setup_code`** (or complete setup immediately) if the domain is open to others. The code is
> **single-use** and the entry form is **rate-limited**.
Read the code again (when one is set):
```bash
journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host
```
> **Why a code, not a tunnel?** MyDay's reference deployment is an **LXC container behind
> Proxmox + Zoraxy**. An SSH tunnel is impractical there: `pct enter`/SSH lands on the container
> **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed. The setup code
> closes first-visitor-wins instead. A **direct SSH tunnel** to the app's loopback port still works
> as a second gate and needs **no code** (D-025 gate a).
> **Note:** the code is **single-use** and the entry form is **rate-limited**. Complete the wizard,
> then **restart** so the app adopts the `config.yaml` the wizard wrote.
> **Bonus:** to use the tunnel gate, reach the app over **`localhost` / `127.0.0.1`** — only a
> loopback origin counts as a secure context for the browser's **WebAuthn** dialogs. Over plain http
> the cookies are sent without `Secure` (required by the tunnel bootstrap); behind the proxy (https)
> they are hardened — see *Deployment*.
Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it.
> **Note:** the setup wizard enrolls **TOTP**; the admin adds a **passkey** at first sign-in on the
> real (https) origin, via the passkey nudge.
---
## Deployment — behind Zoraxy (TLS at the proxy)
> **Full walkthrough:** [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) — topology · config ·
> the Zoraxy ProxyHost · the firewall rule · the first-run setup code (no tunnel needed) · systemd
> · the misconfig warnings. This section is the summary.
> the Zoraxy ProxyHost · the firewall rule · the **`setup: true` bootstrap** · systemd ·
> the misconfig warnings. This section is the summary.
The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)** at
**`10.1.0.10`**; the app itself speaks **plain HTTP**. Config (`config.example.yaml` →
`config.yaml`, D-043):
- **App** — `server.listen: "0.0.0.0:8080"`; the app binds any address (there is **no** setup-mode
bind refusal, D-025 revised).
bind refusal).
- **Proxy** — Zoraxy terminates TLS and forwards to `http://<app-host>:8080`, setting
`X-Forwarded-For` and `X-Forwarded-Proto`.
- **`trusted_proxies`** — `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from a
trusted proxy to decide a request is `https`, and then sets the session cookie **`Secure` +
`__Host-`** and sends **HSTS**. Over plain http (the tunnel) it keeps the bare, non-`Secure` cookie
so the bootstrap works. A **wrong value fails silently** — no `Secure` cookie, no HSTS.
`__Host-`** and sends **HSTS**. Over plain http it keeps the bare, non-`Secure` cookie.
A **wrong value fails silently** — no `Secure` cookie, no HSTS.
- **`base_url`** — `https://myday.sechpoint.app`: the public origin, used in invite/recovery links.
- **Firewall** — **`:8080` MUST be reachable only from `10.1.0.10`.** Anything else that can reach
the port bypasses the proxy **and its TLS**; the app would then serve plain HTTP to that client.
```bash
# read the one-time setup code, then open the domain and enter it at /setup
# on a fresh instance: enable setup, start MyDay, then open the domain at /setup
# config.yaml: setup: true
# (optional) setup_code: "…" # a second factor; the printed code comes first
sudo systemctl restart myday
# read the mode / setup code in the log:
journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host
# (or, for the no-code tunnel gate: ssh -L 8080:127.0.0.1:8080 user@server)
```
---

View file

@ -1,10 +1,10 @@
# Deploying MyDay behind Zoraxy
> **Status:** the reference deployment for **D-043** / **D-044**. The app is never the TLS
> endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and
> **Status:** the reference deployment for **D-043** / **D-044** / **D-045**. The app is never the
> TLS endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and
> relays plain HTTP to the app. This document is operator-facing — it changes no
> code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-025 (re-revised 2026-10-15b)**;
> the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7** and
> code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-045** (the setup gate;
> **D-025 retired**); the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7** and
> `docs/specs/spec-005-setup-wizard.md` **§3/§4**.
---
@ -25,9 +25,9 @@ browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0
app is not the TLS endpoint it must **honour the proxy's `X-Forwarded-Proto`** to
know a request arrived over `https` (that is what turns on the `Secure` +
`__Host-` session cookie and HSTS).
- **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. This is why
the first-run gate is a **setup code** and not just an SSH tunnel: SSH/`pct enter` lands on the
container **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed (§5).
- **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. That is why the
first-run gate is the **`setup` config toggle** rather than an SSH tunnel: SSH/`pct enter` lands
on the container **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed (§5).
- **Firewall** — `:8080` is reachable **only from `10.1.0.10`** (see §4). The port
is the app's whole attack surface: anything that can reach it bypasses both
Zoraxy **and its TLS**.
@ -47,8 +47,8 @@ server:
- "10.1.0.10" # Zoraxy
```
- **`listen`** — any bind is allowed (there is **no** setup-mode bind refusal,
D-025 revised). Bind `0.0.0.0:8080`; the firewall narrows who can reach it.
- **`listen`** — any bind is allowed (there is **no** setup-mode bind refusal).
Bind `0.0.0.0:8080`; the firewall narrows who can reach it.
- **`base_url`** — the public origin. It is the host the browser actually visits,
so it must be `https://` (WebAuthn derives its Relying Party ID from it).
- **`trusted_proxies`** — the peers whose `X-Forwarded-For` / `X-Forwarded-Proto`
@ -79,9 +79,8 @@ The app deliberately **requires** both:
- `X-Forwarded-Proto: https` is what makes the session cookie `Secure`
(`__Host-myday_session`) and adds HSTS (AC-13).
- `X-Forwarded-For` is what keeps the setup gate's tunnel test (gate a, §5) honest:
a request with **no** `X-Forwarded-For` from a loopback peer is the direct tunnel,
not a proxy relay.
- `X-Forwarded-For` is what makes the **audit log** and rate limiting see the real
client (A6) instead of the proxy.
---
@ -109,36 +108,54 @@ table inet myday_guard {
> If you run a host firewall, this rule is the load-bearing one: without it the
> app's plain-HTTP port is directly reachable and its TLS hardening (cookie
> `Secure`, HSTS, the tunnel gate) is bypassed.
> `Secure`, HSTS) is bypassed. It also means the `setup: true` toggle is the only
> thing standing between a random client and the wizard — see §5.
---
## 5. First-run bootstrap — the setup code (no tunnel needed)
## 5. First-run bootstrap — the `setup` toggle
The **first user becomes the top admin**, so the initial `/setup` wizard is gated **per request**
— **never first-visitor-wins** (D-025 · D-044). There are **two** ways in:
The **first user becomes the top admin**, so the initial `/setup` wizard is gated by the **`setup`
config toggle** (D-045) — **never exposed by accident**. There are three states:
| Gate | Who | Needs a code? |
| `setup` | admin exists? | Behaviour |
|---|---|---|
| **(a) the direct SSH tunnel** — a loopback TCP peer **with no `X-Forwarded-For`** | a workstation that can tunnel to the app's loopback port | no |
| **(b) the printed SETUP CODE** | anyone who can read the server log, over the public domain | yes |
| **`false`** | **no** | **Locked.** `GET /` and `GET /setup` show *"MyDay is not set up — set `setup: true` in `config.yaml` and restart."* Every other route → **404**. **Not claimable.** |
| **`true`** | either | `/setup` serves the **wizard** (the operator's deliberate switch). `GET /` points at it. Every other route → **404**. |
| **`false`** | **yes** | **Productive** — the app serves normally. |
**The setup code is the general gate for this topology.** MyDay generates a **128-bit** code at
first start and prints it in an unmistakable **startup banner**:
**To bootstrap:**
```yaml
# config.yaml — on the container
setup: true
setup_code: "" # optional: a second factor (see below)
```
```bash
sudo systemctl restart myday
# read the mode / the code in the log:
journalctl -u myday | grep -i 'setup'
# then open the wizard:
# https://myday.sechpoint.app/setup
```
The startup banner states the mode and prints the setup URL:
```
======================================================================
MyDay is NOT set up yet — no admin exists.
MyDay is in SETUP MODE — no admin exists yet.
======================================================================
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-4d5e-6f70-8192-a3b4-c5d6-e7f8
Read the code again:
journalctl -u myday (in the container)
pct exec 11020 -- journalctl -u myday (from the Proxmox host)
======================================================================
```
**Read the code** (in the LXC container, or from Proxmox):
**The optional setup code (D-044 · D-045).** Set `setup_code` and the wizard requires it — the code
form comes first (read the code from the startup log). Leave it empty and the toggle alone admits,
so the wizard is served directly.
**Read the code again** (when one is set):
```bash
# inside the container
@ -148,17 +165,18 @@ journalctl -u myday | grep -i 'setup code'
pct exec 11020 -- journalctl -u myday | grep -i 'setup code'
```
Then **open `https://myday.sechpoint.app/setup`** and enter the code. Until the code is entered:
Until setup is finished: `GET /` points at the wizard, `GET /setup` serves it (or the code form),
and **every other route answers 404**. The code is **single-use** and the entry form is
**rate-limited** (wrong code → a generic 403).
- `GET /` and `GET /setup` show a **friendly first-run page** (with the code form) — **never a bare
404**, so the domain does not look "down";
- **every other route** answers **404**;
- the code is **single-use** and the entry form is **rate-limited** (wrong code → a generic 403).
> **Residual risk — `setup: true` is not claimable-proof.** While the toggle is on, whoever
> reaches `/setup` first can claim the admin. **Set `setup_code`** (or complete setup immediately)
> if the domain is reachable by anyone else. This is the owner's deliberate trade (D-045): the
> toggle is the operator's switch; the code is an optional lock.
> **No tunnel required.** The old guidance ("bootstrap over an SSH tunnel first") no longer
> applies: in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container
> gateway), which is exactly why D-044 replaced it with the code. The tunnel gate (a) still works
> and needs no code, if you have a route to the app's loopback port.
> **No tunnel required.** The old guidance ("bootstrap over an SSH tunnel first") no longer applies:
> in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container gateway), and the
> peer/tunnel gate (**D-025**) is **retired** by D-045.
The wizard creates the top admin (**TOTP**; the passkey is added at first sign-in on the real
origin — D-041) and writes `config.yaml` (`setup: false`, the base URL, mail/locale, and the
@ -176,12 +194,12 @@ sudo systemctl restart myday
The restart **adopts the written `config.yaml`** — the generated keys are now on
disk, so the admin's TOTP secret (sealed with that key) keeps working, and the
**setup code is gone** (`GET /setup` → 404). **Boot the productive instance from the written
`config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive mode
by design).
**wizard is off** (`setup: false`; `GET /setup` → 404). **Boot the productive instance from the
written `config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive
mode by design).
> **Pinning your own code.** Set `setup_code:` in `config.yaml` to override the generated one
> (then protect the file). Leave it blank to have MyDay generate and print a fresh code each boot.
> **Pinning your own code.** Set `setup_code:` in `config.yaml` to require a code for the wizard
> (then protect the file). Leave it blank to have the `setup: true` toggle admit on its own.
---
@ -223,17 +241,16 @@ curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security'
|---|---|---|
| Session cookie has **no `Secure`**, **no `__Host-`** prefix, and **no HSTS** | `trusted_proxies` is **empty or wrong**, so `X-Forwarded-Proto` is ignored (fails *silently*) | Set `server.trusted_proxies: ["10.1.0.10"]` and restart |
| Client IP in the audit log is the **proxy**, not the user | same — `X-Forwarded-For` is not trusted | as above |
| `GET /` and `GET /setup` show a **first-run page** with a code box | **by design** — MyDay is not set up yet (D-044) | read the setup code (§5) and enter it; complete the wizard |
| Any **other** route returns **404** while bootstrapping | **by design** — only `/` and `/setup` are served in setup mode (SPEC-005 §3) | complete the wizard; the app serves normally once productive |
| The setup code is **rejected** (403) | it is **single-use** (already spent) or mistyped | restart MyDay to print a fresh code, or pin `setup_code:` in `config.yaml` (§5) |
| `/setup` returns **404** after the wizard | **by design** — setup is complete | re-enter only via `setup: true` + restart |
| `GET /` points at the wizard and `GET /setup` serves it (or a code box) | **by design** — `setup: true`, the wizard is enabled (D-045) | complete the wizard; if a code is required, read it from the log (§5) |
| `GET /` and `GET /setup` show *"MyDay is not set up — set `setup: true`…"* | **by design** — `setup: false` and **no admin** (the LOCKED state) | set `setup: true` in `config.yaml` and restart (§5) |
| Any **other** route returns **404** while bootstrapping | **by design** — only `/` and `/setup` are served while the wizard is enabled (SPEC-005 §3) | complete the wizard; the app serves normally once productive |
| The setup code is **rejected** (403) | it is **single-use** (already spent), mistyped, or `setup_code` was changed | check the code in `config.yaml` / the log; restart to re-arm |
| `/setup` returns **404** after the wizard | **by design** — setup is complete (`setup: false`) | re-enter only via `setup: true` + restart |
> **The load-bearing deployment requirement:** Zoraxy **must set
> `X-Forwarded-For`**. It is what distinguishes a proxy relay from the direct
> tunnel (gate a): a proxied request with **no** `X-Forwarded-For` would look like
> the tunnel and open the wizard without a code. (The **setup code** still closes
> first-visitor-wins for anything the proxy *does* mark.)
> `X-Forwarded-Proto` is what keeps the cookie `Secure` over the proxy.
> **The load-bearing deployment requirement:** Zoraxy **must set `X-Forwarded-Proto`** (or the
> session cookie silently loses `Secure`/`__Host-` and HSTS) and **`X-Forwarded-For`** (or the audit
> log records the proxy, not the client). Neither is a setup gate any more (D-045) — the gate is the
> `setup` toggle; set `setup_code` if you want a lock on top of it.
---
@ -241,8 +258,8 @@ curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security'
| | |
|---|---|
| [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-025 (re-revised) · D-044 |
| [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-045 (the setup gate) · D-044 (the optional code) |
| [`docs/specs/spec-003-core-door.md`](specs/spec-003-core-door.md) | §7 the setup gate · §9 AC-7 / AC-10 / AC-13 / AC-14 |
| [`docs/specs/spec-005-setup-wizard.md`](specs/spec-005-setup-wizard.md) | §3/§4 the bootstrap + the setup code |
| [`docs/specs/spec-005-setup-wizard.md`](specs/spec-005-setup-wizard.md) | §3/§4 the bootstrap + the optional code |
| [`config.example.yaml`](../config.example.yaml) | the commented config template |
| [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |

View file

@ -6,7 +6,7 @@
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-002** · **D-003** · **D-025** · **D-031** · **D-039** · `PROJECT_PLAN.md` §4/§12 |
| **Governing decisions** | **D-002** · **D-003** · **D-031** · **D-039** · **D-043** · **D-045** · `PROJECT_PLAN.md` §4/§12 |
| **Related** | SPEC-002 (routing) · SPEC-004 (identity) |
---
@ -48,10 +48,11 @@ request
3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
· Permissions-Policy · COOP · CORP [L4s]
4. routing.Dispatch() — the pure model (SPEC-002)
5. setup-mode gate — in setup mode a request is served ONLY if it is the direct tunnel
(TCP peer loopback AND no XFF) OR unlocked with the setup code (D-044);
`GET /` and an un-unlocked `GET /setup` get the first-run boilerplate,
everything else 404 (§7)
5. setup gate — the gate is the config toggle `setup` (D-045). While `setup: true`
`/setup` serves the wizard (the code form first when `setup_code` is
set, D-044); `GET /` points at it; everything else 404. `setup: false`
with no admin is the LOCKED state: `/` and `/setup` get the "enable
setup" boilerplate, everything else 404 (§7)
6. switch on Kind:
Redirect → 302
NotFound → 404
@ -115,17 +116,18 @@ type RequestContext struct {
---
## 7. Setup gate (D-025 re-revised 2026-10-15 · D-044)
## 7. Setup gate (D-045)
- **No bind refusal.** The app **may bind any address** — `0.0.0.0:8080` behind **Zoraxy** (D-043).
- **While in setup mode**, `/setup` is served when **either**:
- **(a)** the request is a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`**
(an empty/unparseable peer fails closed). *A same-host proxy presents a loopback peer, so the
absence of XFF is the proof the request did not traverse one;* **or**
- **(b)** the request is **unlocked with the setup code** (D-044) — the boilerplate's code form.
- Anything else in setup mode → the **first-run boilerplate** (`/` · `/setup`) or **404**.
- **Once productive** (`setup: false`), normal service: the trusted proxy's **`X-Forwarded-Proto`**
drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS (§6 · D-043).
- **The gate is the config toggle `setup`** (D-045; the peer/tunnel rule of D-025 is **retired**).
- **`setup: true`** → `/setup` serves the **wizard**. If `setup_code` is set the **code form**
comes first (D-044) and the unlock is a signed cookie; otherwise the toggle alone admits.
`GET /` points at the wizard; **every other route is 404**.
- **`setup: false`, no admin** → the **LOCKED** state: `GET /` and `GET /setup` show an
"enable setup" boilerplate; every other route is 404. Not publicly claimable.
- **Once productive** (`setup: false` with an admin), normal service: the trusted proxy's
**`X-Forwarded-Proto`** drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS
(§6 · D-043). The XFF handling keeps the **client IP** honest (A6) — it is no longer a gate.
---
@ -148,14 +150,14 @@ type RequestContext struct {
| AC-4 | A session whose `user_id` ≠ the GUID's user is **rejected** |
| AC-5 | An **empty / missing session id** is rejected on **every** path (A2) |
| AC-6 | A `do`/`action` request without a valid CSRF token is **rejected** |
| AC-7 | In setup mode, a request **relayed by a proxy** (a loopback peer **with** `X-Forwarded-For`) never reaches the **wizard** — it gets the first-run boilerplate (`/` · `/setup`) or a 404; the bootstrap is never first-visitor-wins (D-025 · D-044) |
| AC-7 | While the wizard is enabled (`setup: true`) with a **required `setup_code`**, `GET /setup` shows the **code form** to every peer; only an **unlocked** request reaches the wizard. The peer/tunnel rule of D-025 is retired (D-045) |
| AC-8 | Every response carries a correlation id, echoed in the log; path credentials are redacted |
| AC-9 | The rate limiter returns **429** on the auth-sensitive routes (A3) |
| AC-10 | In setup mode a request whose **TCP peer is not loopback**, **or** which carries **any `X-Forwarded-For`**, never reaches the wizard unless it is **unlocked with the setup code** (D-025/H2/A13/D-044) |
| AC-10 | While the wizard is enabled with a **required code**, no peer reaches the **wizard** without the unlock; the code form is shown instead, and every non-wizard route is 404 (D-045, superseding D-025/H2) |
| AC-11 | The **`setup` module** is reachable only through `Workflow == "setup"`; any other resolution naming it is 404 (M1c/L3s) |
| AC-12 | `X-Forwarded-For` is resolved **right→left**; a forged leftmost entry is ignored (H1/A6) |
| AC-13 | Behind the proxy, `X-Forwarded-Proto: https` ⇒ the session cookie is **`Secure` + `__Host-`**, and HSTS is set (D-043) |
| AC-14 | In setup mode `GET /` and `GET /setup` show the **first-run boilerplate** (never a bare 404); the **correct setup code unlocks the wizard**; a wrong/absent code does not (D-044) |
| AC-14 | `setup: true` → `GET /setup` serves the wizard (or the code form when `setup_code` is set); `GET /` points at it; the **correct setup code unlocks** it, a wrong/absent one does not (D-044/D-045). `setup: false` with no admin → a locked boilerplate at `/` and `/setup` (never a bare 404) |
---
@ -211,6 +213,9 @@ The fixes touching this spec:
## 12. D-044 — the setup code gate & the first-run boilerplate (2026-10-15b)
> **Superseded in part by D-045 (§13).** The **code mechanics** below stand; the **tunnel gate (a)**
> and the **generated** code are retired, and `GET /` is no longer the code form.
The topology (**LXC behind Proxmox + Zoraxy**) makes the SSH tunnel impractical: `pct enter`/SSH
lands on the container **gateway**, so a tunnel to `127.0.0.1:8080` cannot be formed and gate (a)
alone would lock the operator out. **D-025 is re-revised** so `/setup` is served when **either**
@ -229,3 +234,21 @@ alone would lock the operator out. **D-025 is re-revised** so `/setup` is served
AC-7/AC-10 are reworded: "refused" now means "refused the **wizard**" — the boilerplate may still
answer `GET /`/`GET /setup` with 200 (AC-14).
---
## 13. D-045 — the toggle gate, the optional code, the retired tunnel (2026-10-15c)
The **peer/tunnel gate is retired**: `setup: true` is the gate. `dispatch` evaluates the config
toggle per request:
- **`setup: true`** — `setupAdmitted(r)` is true when no `setup_code` is required, else it needs a
valid `SetupGate.Unlocked(r)`. `/setup` with a required code un-entered → `SetupGate.CodeForm`;
`GET /` → `SetupGate.Landing`; the **unlock action** is the one carve-out; every other route → 404
(static assets are served so the first-run pages render inside the shell).
- **`setup: false`, no admin** — the **LOCKED** state (`Deps.LockedMode`): `GET /` and `GET /setup`
→ `SetupGate.Locked`; every other route → 404.
Removed: `httpx.SetupRequestAllowed`, `httpx.IsLoopbackIP` (and their tests). Kept: the XFF
right→left client-IP resolution and the `X-Forwarded-Proto`-driven `Secure`/`__Host-`/HSTS (A6/A11
· D-043). `SetupGate` is now `{ Required · Unlocked · CodeForm · Landing · Locked }`.

View file

@ -2,62 +2,69 @@
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): TOTP-only setup** · **rev 2026-10-15 (D-025/D-043): per-request tunnel gate** · **rev 2026-10-15b (D-044): the setup code + the first-run boilerplate** — see §9 |
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): TOTP-only setup** · **rev 2026-10-15 (D-025/D-043): per-request tunnel gate — superseded** · **rev 2026-10-15b (D-044): the setup code + the first-run boilerplate** · **rev 2026-10-15c (D-045): the gate is the `setup` toggle; the code is optional; the tunnel gate retired** — see §9/§10 |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-015** · **D-021** · **D-025** · **D-026** · **D-035** · `PROJECT_PLAN.md` §5 |
| **Governing decisions** | **D-021** · **D-026** · **D-035** · **D-044** · **D-045** · `PROJECT_PLAN.md` §5 |
| **Related** | SPEC-003 (door) · SPEC-004 (identity) |
---
## 1. Goal
Bring a fresh instance to life with a **`/setup` wizard** reachable **only over the direct SSH
tunnel** that creates the **first user = the top admin** (`user_id = 1`, role `admin`), then switches
the instance to productive mode.
Bring a fresh instance to life with a **`/setup` wizard** — enabled by the **`setup` config toggle**
(D-045) and optionally locked by a **setup code** (D-044) — that creates the **first user = the top
admin** (`user_id = 1`, role `admin`), then switches the instance to productive mode.
## 2. Non-goals
Module setup screens (each module owns its config). Multi-admin (there is **one**, D-034/§7.1).
## 3. Setup mode
## 3. Setup mode & the gate (D-045)
`setup mode = (config.setup == true) OR (no user exists)` — **fail-safe**.
The gate is the **config toggle `setup`**. The instance is in one of **three** states:
- The app **may bind any address** — including `0.0.0.0:8080` behind a reverse proxy (D-043); there is
**no** bind refusal.
- **Gated — never first-visitor-wins (D-025 · D-044).** `/setup` is served to a request that is
**either** **(a)** a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`** (an
empty/unparseable peer fails closed; a same-host proxy presents a loopback peer, so the *absence of
XFF* is what proves no proxy) — **or** **(b)** **unlocked with the setup code** (below).
- **The first-run boilerplate (D-044).** In setup mode:
- `GET /` and `GET /setup` show a **friendly first-run page** — *"MyDay is not set up yet"* — with a
**setup-code entry form** and the instruction to read the code from the **server log**.
**Never a bare 404** (the domain must not look "down").
- Every **other** route → **404**.
- The correct code **unlocks the wizard**; the code is **single-use**, **rate-limited**, and
**redacted from the logs** (A8).
```
setup: false · no admin → LOCKED (a "set setup: true and restart" boilerplate; not claimable)
setup: true → the WIZARD (or the code form when `setup_code` is set)
setup: false · admin → PRODUCTIVE
```
- The app **may bind any address** — `0.0.0.0:8080` behind the proxy (D-043); **no** bind refusal.
- **The gate is the config toggle `setup` (D-045).**
- **`setup: false`, no admin** → the app is **locked**: `GET /` and `GET /setup` show a
**boilerplate** — *"MyDay is not set up. Set `setup: true` in `config.yaml` and restart."*
Every other route → **404**. **This state is not publicly claimable.**
- **`setup: true`** → `/setup` serves the **wizard** (the operator's deliberate switch).
`GET /` → a friendly page pointing at `/setup`. Every other route → 404.
- **Completion** → the app writes **`setup: false`** and creates the admin → productive.
- **`setup_code` is OPTIONAL (D-044 · D-045).** If set in `config.yaml`, the wizard requires it (a
**second factor**) and the code form comes first; if empty, the toggle alone admits. Single-use;
rate-limited; **redacted** (A8).
- The **peer/tunnel gate (D-025) is retired** — `setup: true` is the gate; the `SetupRequestAllowed`
peer+`X-Forwarded-For` rule no longer decides setup access. The XFF/XFP handling is **kept** for
the **client IP** and the `Secure`/`__Host-` cookies + HSTS (A6/A11/D-043).
- The `setup` **module** is pinned at the door: the wizard's actions are reachable only through the
reserved `setup` workflow (M1c/L3s · SPEC-003 §4).
- **Residual (D-045):** while `setup: true`, whoever reaches `/setup` first can claim the admin —
set `setup_code`, or complete setup immediately.
## 4. The wizard
```
GET /setup → the first-run boilerplate + the setup-code form
POST /setup?do=setup&action=unlock → the code → unlocks the wizard (a session flag)
GET /setup → the wizard (once unlocked, or via the tunnel)
# setup: false · no admin → the boilerplate ("enable setup") ← locked, not claimable
# setup: true → the wizard
GET /setup → (if `setup_code` is set) the code form, else the wizard
POST /setup?do=setup&action=unlock → the code → unlocks (only when `setup_code` is set)
Step 1 domain / base_url
Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041)
Step 3 mail (SMTP) · locale · timezone
POST /setup?do=setup&action=… → each step (CSRF-protected)
Finish → persist settings → config.setup = false
Finish → persist settings → **setup: false**
→ user_id = 1 (role admin) · identity minted + activated · session minted
```
- **The setup code (D-044):** a **128-bit** value generated at first run and printed in a **startup
banner** (`setup.code` in config overrides it). A direct-tunnel request (gate **a**) needs no code.
- **Settings persist to `config.yaml`** (D-035). *Caveat:* if the file is not writable, the wizard
**prints the values** and instructs the operator to write them, then marks setup complete.
- **One admin only:** the wizard creates `user_id = 1`; later attempts to create another `admin`
@ -69,6 +76,9 @@ POST /setup?do=setup&action=… → each step (CSRF-protected)
## 5. After setup
- `/setup` → **404** (delete, don't redirect). Re-entering requires `config.setup: true` + restart.
- `config.yaml` now reads **`setup: false`**; with an admin present the instance is **productive**.
(Without an admin it would be the **locked** state — the toggle is off and there is nothing to
serve; set `setup: true` and restart to resume.)
- The instance is served on its configured bind (typically `0.0.0.0:8080` behind the reverse proxy,
D-043); the request scheme is taken from `X-Forwarded-Proto` (trusted proxy) or TLS.
- **Audit:** the bootstrap, the admin creation, and the enroll are all recorded (A11).
@ -77,13 +87,14 @@ POST /setup?do=setup&action=… → each step (CSRF-protected)
| # | Criterion |
|---|---|
| AC-1 | Fresh instance → `GET /` and `GET /setup` show the **first-run boilerplate** (200 — **never a bare 404**); every **other** route → 404 |
| AC-2 | In setup mode the **wizard** is served to **either** the direct tunnel (loopback TCP peer **and** no `X-Forwarded-For`) **or** a request **unlocked with the setup code**; anything else gets the boilerplate (`/` · `/setup`) or a 404. Any bind is allowed (D-025 revised) |
| AC-1 | `setup: true` → `GET /setup` serves the **wizard** directly when `setup_code` is empty; `GET /` points at it; every **other** route → 404 |
| AC-2 | With `setup_code` **set**, `GET /setup` shows the **code form** first (from any peer); only a request **unlocked** with the code reaches the wizard; a wrong/absent code gets a generic 403. The peer/tunnel rule (D-025) is retired (D-045) |
| AC-3 | Completing the wizard creates `user_id = 1` (role `admin`) with **TOTP** enrolled and **no passkey** (D-041) |
| AC-4 | After completion `config.setup = false` (or printed) and `/setup` → 404 |
| AC-5 | A second `admin` cannot be created |
| AC-6 | The full bootstrap is audited |
| AC-7 | The setup code is **single-use** and **rate-limited**; a wrong or absent code gets a **generic** answer that reveals nothing, and the code is **redacted from the logs** (A8). The direct tunnel needs no code (D-044) |
| AC-7 | The setup code (when set) is **single-use** and **rate-limited**; a wrong or absent code gets a **generic** answer that reveals nothing, and the code is **redacted from the logs** (A8) |
| AC-8 | `setup: false` with **no admin** → **LOCKED**: `GET /` and `GET /setup` render the "enable setup" boilerplate (200 — **never a bare 404**), every **other** route → 404, and the wizard is **not claimable** (D-045) |
---
@ -118,7 +129,7 @@ every GUID branch. *(Delete, don't redirect — a fresh instance has no app yet.
| D-1 | **One admin only** enforced by `users.Create`; the wizard returns **409** on a second attempt. | **Accepted** (AC-5). |
| D-2 | The wizard's step is **derived** from the DB (admin exists? identity pending/active?) plus an in-memory *draft* for step 1's domain. | **Accepted** as the smallest state model. |
| D-3 | A not-writable `config.yaml` → the YAML is **printed** to stdout with an instruction; setup still completes in memory. | **Accepted** (AC-4; §4 caveat). |
| D-4 | **Setup mode is LATCHED at startup** (`config.setup` OR "not yet bootstrapped" — no user, or no ACTIVE admin) and cleared by an atomic `Actions.Done()`; `config.Setup` is never mutated (the live `Config` is an immutable startup snapshot, D-035). | **Accepted** — avoids a data race on the shared config and a per-request DB query, and **holds setup mode through the whole wizard**: once the admin row exists a live "no user" check would end setup mode before enrollment/finish. |
| D-4 | **Setup mode is LATCHED at startup** (`config.Setup`, D-045) and cleared by an atomic `Actions.Done()`; `config.Setup` is never mutated (the live `Config` is an immutable startup snapshot, D-035). | **Accepted** — avoids a data race on the shared config and a per-request DB query, and **holds setup mode through the whole wizard**: once the admin row exists a live "no user" check would end setup mode before enrollment/finish. *(D-045): the "no user" clause moved to the LOCKED state; setup mode is now purely the toggle.* |
**Architect rulings on the flagged items:**
@ -155,11 +166,16 @@ The admin adds a passkey at first login on the real origin, via the **passkey nu
## 9. Implementation notes — the setup code & boilerplate (D-044, 2026-10-15b)
> **Superseded in part by D-045 (§10).** The D-044 *mechanics* (the signed unlock cookie, the
> single-use/rate-limited/redacted code, the generic 403) are unchanged; what changed is the
> **gate**: `setup: true` is now the gate, `setup_code` is **optional**, and the **peer/tunnel gate
> is retired**. The code-form page now lives only at `GET /setup` (not `/`), and the code is no
> longer generated when blank.
Built 2026-10-15. `build` · `vet` · `test -race` green; **static CGO-free** binary; live-smoked
against the real binary (banner + code, boilerplate at `/` and `/setup` behind a proxy, the tunnel
opens the wizard with no code, wrong code → **403**, correct code → **303** + unlock cookie, the
wizard follows, a spent code is refused, and neither the code nor a query-borne copy appears in
the access log).
against the real binary (banner + code, boilerplate at `/` and `/setup` behind a proxy, wrong code →
**403**, correct code → **303** + unlock cookie, the wizard follows, a spent code is refused, and
neither the code nor a query-borne copy appears in the access log).
**What landed**
@ -178,7 +194,7 @@ the access log).
| | |
|---|---|
| **The code** | 128-bit, generated at startup (`crypto/rand`), printed in the banner, or pinned by `setup_code`. Entry ignores case/separators. |
| **The code** | 128-bit, **pinned by `setup_code`** (D-045: no code is generated when it is blank — the toggle alone admits). Entry ignores case/separators. |
| **Single-use** | `Accept` retires the code on the **first success**; a failed attempt does not consume it. |
| **The unlock** | A signed cookie (`myday_setup`) carrying a **fingerprint** of the code — not the code — so it survives the code's retirement but dies on a restart (new code) or on completion (`Clear`). It is **not** the session cookie (no user exists). |
| **Generic** | A wrong and an absent code return the same **403** page/message. |
@ -189,7 +205,40 @@ the access log).
| # | Builder choice | Ruling |
|---|---|---|
| **D-1** | The config key is **`setup_code`** (top level), not the literal `setup.code`: `setup:` is already the boolean mode flag (D-026), and YAML cannot be both a scalar and a mapping. | **Accepted** — the smallest faithful encoding of "`setup.code` in config". |
| **D-2** | "Single-use" = the code retires on the first **successful** unlock; the browser keeps its signed unlock cookie (multi-tab works), a **second** visitor cannot re-use the printed value. A restart prints a **fresh** code. | **Accepted** — honours single-use without a lock-out when a tab is closed. |
| **D-3** | `GET /` shows the boilerplate **always** in setup mode (tunnel or not) — `/` is not the wizard. | **Accepted.** |
| **D-4** | With **no** gate wired (a door built by hand) setup mode keeps the strict 404 — the entrypoint always wires it. | **Accepted** — keeps the door honest without a setup package. |
| **D-2** | "Single-use" = the code retires on the first **successful** unlock; the browser keeps its signed unlock cookie (multi-tab works), a **second** visitor cannot re-use the printed value. A restart re-arms a pinned `setup_code`. | **Accepted** — honours single-use without a lock-out when a tab is closed. |
| **D-3** | `GET /` shows the first-run page **always** while the wizard is enabled — `/` is not the wizard. *(D-045): `/` is now a landing that points at `/setup`; the code form is at `/setup`.* | **Accepted.** |
| **D-4** | With **no** gate wired (a door built by hand) the first-run pages fall back to the strict 404 — the entrypoint always wires it. | **Accepted** — keeps the door honest without a setup package. |
---
## 10. Implementation notes — the toggle gate & the optional code (D-045, 2026-10-15c)
Built 2026-10-15. `build` · `vet` · `test -race` green; **static CGO-free** binary; live-smoked
against the real binary across all three states (locked boilerplate at `/` and `/setup`, the
code-less wizard, the code form → unlock → wizard, and a full bootstrap: `domain.save` →
`admin.create` → `admin.totp.begin/verify` → `finish` → `config.yaml` reads `setup: false` and
`/setup` → 404).
**What changed**
- **The gate is `config.setup`** — `setup: true` enables the wizard; `setup: false` with **no admin**
is the **LOCKED** state. The `!bootstrapped()` fail-safe clause was removed from setup mode and
became the locked predicate (latched at startup).
- **`setup_code` is optional** — `setup.New` no longer generates a code; a blank code makes
`Gate.Required()` false and the toggle alone admits. When set, the D-044 code mechanics apply
unchanged.
- **The peer/tunnel gate is retired** — `httpx.SetupRequestAllowed` (and `httpx.IsLoopbackIP`) are
**deleted**; the XFF/XFP handling is **kept** for the client IP and the `Secure`/`__Host-` cookies
+ HSTS (A6/A11/D-043).
- **Three first-run pages** — `Gate.CodeForm` (setup + required code, un-entered), `Gate.Landing`
(`GET /` while setup is enabled), `Gate.Locked` (setup off, no admin). `Deps.LockedMode` carries
the locked predicate; `Deps.SetupMode` now means "the wizard is enabled".
- **The startup banner** states the mode; it prints the code **only when `setup_code` is set**; in
the locked state it tells the operator to set `setup: true` and restart.
| # | Builder choice | Ruling |
|---|---|---|
| **D-1** | Static assets stay served in **every** first-run state (locked + enabled) so the boilerplate/code form render inside the shell — the "every other route → 404" rule covers app routes. | *(builder; the smallest faithful reading — a stylesheet for the page that replaces the 404)* |
| **D-2** | "No admin" (for the locked predicate) = **no user row at all**, latched at startup — not "no ACTIVE admin". A pending admin is therefore *productive* (enrolls via `/{GUID}`), never locked. | *(builder)* |
| **D-3** | The `unlock` action when no code is required is a no-op **303 → /setup** rather than a 404. | *(builder — a stale bookmark works)* |

View file

@ -95,7 +95,7 @@ log) rather than silently losing precedence (SPEC-008 §9·D-9).
| # | Criterion |
|---|---|
| AC-1 | A human completes **`/setup`** in a browser (over the tunnel) and reaches the dashboard |
| AC-1 | A human completes **`/setup`** in a browser (while the wizard is enabled, D-045) and reaches the dashboard |
| AC-2 | A **TOTP login** works in a browser; the **nudge** appears (non-loopback); **adding a passkey** succeeds; a later **passkey login** works |
| AC-3 | The page emits **no inline scripts** (the CSP `script-src 'self'` is not weakened) |
| AC-4 | The **manifest** is valid and the app **installs**; `start_url` is `/` |
@ -189,4 +189,4 @@ worker's `isCacheable` in Node (skipped when Node is absent) would harden AC-5 f
| **D-6** | The nudge's passkey button is **JS-only**. | **Accepted** — the ceremony *is* the request body; `later`/`never` keep plain forms + `<noscript>`. |
| **Q-1** | A discovered module may be named for a reserved **level-1** workflow (`static` · `access` · …). | **Resolved — extend the guard** to those names, **except `home`** (a legitimate module). A colliding module fails to load (error + log). *Fold into the next slice; O-15 resolved.* |
| **Q-2** | AC-5's service-worker JS is not *executed* by the suite. | **Accepted as-is** — the Go policy is the single testable statement of the rule. Running `isCacheable` under Node (skipped when absent) is optional hardening, later. |
| **Q-3** | AC-1/AC-2 need a browser; the tunnel must target `localhost`. | **Accepted** — the README gains the caveat: with an `https://` base URL the cookies are `Secure`, so reach the app at **`localhost`/`127.0.0.1`** (a trustworthy origin). *Fold into the next slice.* |
| **Q-3** | AC-1/AC-2 need a browser. | **Accepted** — the README notes the WebAuthn caveat: the passkey is enrolled at the app's real origin (a trustworthy https/`localhost` context), and with an `https://` base URL the cookies are `Secure`. *Fold into the next slice.* |