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}/ → 302 → /home (the ONE canonical redirect)
/{domain}/home → neutral landing page (public, no user) /{domain}/home → neutral landing page (public, no user)
/{domain}/access → password / access recovery /{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}/api | static | media | ws | dev → reserved infrastructure
/{domain}/holy-shit → 404 (unknown · not reserved · not a GUID) /{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 ### 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 1 the domain / base URL
Step 2 the top admin — name, email, **TOTP** enrollment (the passkey is added at first Step 2 the top admin — name, email, **TOTP** enrollment (the passkey is added at first
sign-in, D-041) (user_id = 1, role: admin) 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`). - `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** - **The gate is the config toggle `setup` (D-045).** Three states:
is served to **either** **(a)** a **direct tunnel** — a loopback TCP peer with **no** - **`setup: true`** → `/setup` serves the **wizard** (the operator's deliberate switch); `GET /`
`X-Forwarded-For` — **or** **(b)** a request **unlocked with the printed SETUP CODE** (D-044). points at it; every other route is 404.
Before it is unlocked, `GET /` and `GET /setup` show a friendly **first-run page** (never a bare - **`setup: false` and no admin** → the app is **LOCKED**: `GET /` and `GET /setup` show a
404, so the domain does not look "down"); every other route is 404. **boilerplate** telling the operator to set `setup: true` and restart; every other route is 404.
- **The setup code (D-044).** A **128-bit** code is generated at first run and printed in a **This state is not publicly claimable.**
**startup banner** (`setup_code` in config overrides it); it is **single-use** and - **completion** → the app writes **`setup: false`** and creates the admin → productive.
**rate-limited**. This is the general gate for the **LXC/Proxmox** topology, where an SSH tunnel - **The setup code is OPTIONAL (D-044 · D-045).** When `setup_code` is set it is a **second factor**
is impractical (the tunnel would land on the container *gateway*, not loopback). (the code form comes first; single-use · rate-limited · redacted — A8). Empty ⇒ the toggle alone
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator unlocks it (the admits. *(D-044's generated-code banner, the peer/tunnel gate D-025, and the bare 404 are retired.)*
code, or an SSH tunnel to `127.0.0.1:8080`) and completes it, then exposes the instance. - **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator enables setup and
- *README: do NOT expose MyDay publicly before the first admin exists.* 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 ### 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-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-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-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-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 — **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-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-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-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). | | **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-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-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-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-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-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-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-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-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.** | | 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) | | 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) | | 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 | | 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 | | 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) | | 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. | | **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**. | | **`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/`. | | **`docs/`** | this plan at the root; specs later in `docs/specs/`. |
| **CI (later)** | build + test on push. | | **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 | **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 | **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 | **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 The **first user becomes the top admin** (the root of the chain). Setup is gated by the **`setup`
**`/setup` wizard** that is **gated — never first-visitor-wins** (D-025/D-044). On first start it config toggle** (D-045): enable it, start MyDay, complete the wizard — the app then writes
prints a **one-time 128-bit SETUP CODE** in its startup log: **`setup: false`** and the instance becomes productive.
``` ```yaml
====================================================================== # config.yaml
MyDay is NOT set up yet — no admin exists. setup: true # enable the /setup wizard
====================================================================== setup_code: "" # OPTIONAL second factor — set it if the domain is reachable by others
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-…
``` ```
Open the domain (or `http://localhost:8080`) and enter that code at `/setup`. **Until then every 1. **Start MyDay.** The startup banner states the mode and prints the setup URL.
page is a friendly first-run page — never a bare 404** — and every other route answers 404. Read 2. **Open the wizard at `/setup`** (e.g. `https://myday.sechpoint.app/setup`).
the code again with: - `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 ```bash
journalctl -u myday # inside the container journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host 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. 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) ## Deployment — behind Zoraxy (TLS at the proxy)
> **Full walkthrough:** [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) — topology · config · > **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 Zoraxy ProxyHost · the firewall rule · the **`setup: true` bootstrap** · systemd ·
> · the misconfig warnings. This section is the summary. > the misconfig warnings. This section is the summary.
The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)** at 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` → **`10.1.0.10`**; the app itself speaks **plain HTTP**. Config (`config.example.yaml` →
`config.yaml`, D-043): `config.yaml`, D-043):
- **App** — `server.listen: "0.0.0.0:8080"`; the app binds any address (there is **no** setup-mode - **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 - **Proxy** — Zoraxy terminates TLS and forwards to `http://<app-host>:8080`, setting
`X-Forwarded-For` and `X-Forwarded-Proto`. `X-Forwarded-For` and `X-Forwarded-Proto`.
- **`trusted_proxies`** — `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from a - **`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` + 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 `__Host-`** and sends **HSTS**. Over plain http it keeps the bare, non-`Secure` cookie.
so the bootstrap works. A **wrong value fails silently** — no `Secure` cookie, no HSTS. A **wrong value fails silently** — no `Secure` cookie, no HSTS.
- **`base_url`** — `https://myday.sechpoint.app`: the public origin, used in invite/recovery links. - **`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 - **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. the port bypasses the proxy **and its TLS**; the app would then serve plain HTTP to that client.
```bash ```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 journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host 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 # Deploying MyDay behind Zoraxy
> **Status:** the reference deployment for **D-043** / **D-044**. The app is never the TLS > **Status:** the reference deployment for **D-043** / **D-044** / **D-045**. The app is never the
> endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and > 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 > 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)**; > code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-045** (the setup gate;
> the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7** and > **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**. > `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 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` + know a request arrived over `https` (that is what turns on the `Secure` +
`__Host-` session cookie and HSTS). `__Host-` session cookie and HSTS).
- **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. This is why - **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. That is why the
the first-run gate is a **setup code** and not just an SSH tunnel: SSH/`pct enter` lands on the first-run gate is the **`setup` config toggle** rather than an SSH tunnel: SSH/`pct enter` lands
container **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed (§5). 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 - **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 is the app's whole attack surface: anything that can reach it bypasses both
Zoraxy **and its TLS**. Zoraxy **and its TLS**.
@ -47,8 +47,8 @@ server:
- "10.1.0.10" # Zoraxy - "10.1.0.10" # Zoraxy
``` ```
- **`listen`** — any bind is allowed (there is **no** setup-mode bind refusal, - **`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. 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, - **`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). so it must be `https://` (WebAuthn derives its Relying Party ID from it).
- **`trusted_proxies`** — the peers whose `X-Forwarded-For` / `X-Forwarded-Proto` - **`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` - `X-Forwarded-Proto: https` is what makes the session cookie `Secure`
(`__Host-myday_session`) and adds HSTS (AC-13). (`__Host-myday_session`) and adds HSTS (AC-13).
- `X-Forwarded-For` is what keeps the setup gate's tunnel test (gate a, §5) honest: - `X-Forwarded-For` is what makes the **audit log** and rate limiting see the real
a request with **no** `X-Forwarded-For` from a loopback peer is the direct tunnel, client (A6) instead of the proxy.
not a proxy relay.
--- ---
@ -109,36 +108,54 @@ table inet myday_guard {
> If you run a host firewall, this rule is the load-bearing one: without it the > 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 > 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** The **first user becomes the top admin**, so the initial `/setup` wizard is gated by the **`setup`
— **never first-visitor-wins** (D-025 · D-044). There are **two** ways in: 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 | | **`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.** |
| **(b) the printed SETUP CODE** | anyone who can read the server log, over the public domain | yes | | **`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 **To bootstrap:**
first start and prints it in an unmistakable **startup banner**:
```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: Open the setup wizard at:
https://myday.sechpoint.app/setup 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 ```bash
# inside the container # inside the container
@ -148,17 +165,18 @@ journalctl -u myday | grep -i 'setup code'
pct exec 11020 -- 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 > **Residual risk — `setup: true` is not claimable-proof.** While the toggle is on, whoever
404**, so the domain does not look "down"; > reaches `/setup` first can claim the admin. **Set `setup_code`** (or complete setup immediately)
- **every other route** answers **404**; > if the domain is reachable by anyone else. This is the owner's deliberate trade (D-045): the
- the code is **single-use** and the entry form is **rate-limited** (wrong code → a generic 403). > 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 > **No tunnel required.** The old guidance ("bootstrap over an SSH tunnel first") no longer applies:
> applies: in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container > in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container gateway), and the
> gateway), which is exactly why D-044 replaced it with the code. The tunnel gate (a) still works > peer/tunnel gate (**D-025**) is **retired** by D-045.
> and needs no code, if you have a route to the app's loopback port.
The wizard creates the top admin (**TOTP**; the passkey is added at first sign-in on the real 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 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 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 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 **wizard is off** (`setup: false`; `GET /setup` → 404). **Boot the productive instance from the
`config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive mode written `config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive
by design). mode by design).
> **Pinning your own code.** Set `setup_code:` in `config.yaml` to override the generated one > **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 MyDay generate and print a fresh code each boot. > (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 | | 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 | | 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 | | `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) |
| 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 | | `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) |
| 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) | | 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 |
| `/setup` returns **404** after the wizard | **by design** — setup is complete | re-enter only via `setup: true` + restart | | 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 > **The load-bearing deployment requirement:** Zoraxy **must set `X-Forwarded-Proto`** (or the
> `X-Forwarded-For`**. It is what distinguishes a proxy relay from the direct > session cookie silently loses `Secure`/`__Host-` and HSTS) and **`X-Forwarded-For`** (or the audit
> tunnel (gate a): a proxied request with **no** `X-Forwarded-For` would look like > log records the proxy, not the client). Neither is a setup gate any more (D-045) — the gate is the
> the tunnel and open the wizard without a code. (The **setup code** still closes > `setup` toggle; set `setup_code` if you want a lock on top of it.
> first-visitor-wins for anything the proxy *does* mark.)
> `X-Forwarded-Proto` is what keeps the cookie `Secure` over the proxy.
--- ---
@ -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-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 | | [`config.example.yaml`](../config.example.yaml) | the commented config template |
| [`deploy/myday.service`](../deploy/myday.service) | the systemd unit | | [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |

View file

@ -6,7 +6,7 @@
| **Created** | 2026-10-13 | | **Created** | 2026-10-13 |
| **Author** | OpenArchitect | | **Author** | OpenArchitect |
| **Target milestone** | **M0** | | **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) | | **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) 3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
· Permissions-Policy · COOP · CORP [L4s] · Permissions-Policy · COOP · CORP [L4s]
4. routing.Dispatch() — the pure model (SPEC-002) 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 5. setup gate — the gate is the config toggle `setup` (D-045). While `setup: true`
(TCP peer loopback AND no XFF) OR unlocked with the setup code (D-044); `/setup` serves the wizard (the code form first when `setup_code` is
`GET /` and an un-unlocked `GET /setup` get the first-run boilerplate, set, D-044); `GET /` points at it; everything else 404. `setup: false`
everything else 404 (§7) with no admin is the LOCKED state: `/` and `/setup` get the "enable
setup" boilerplate, everything else 404 (§7)
6. switch on Kind: 6. switch on Kind:
Redirect → 302 Redirect → 302
NotFound → 404 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). - **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**: - **The gate is the config toggle `setup`** (D-045; the peer/tunnel rule of D-025 is **retired**).
- **(a)** the request is a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`** - **`setup: true`** → `/setup` serves the **wizard**. If `setup_code` is set the **code form**
(an empty/unparseable peer fails closed). *A same-host proxy presents a loopback peer, so the comes first (D-044) and the unlock is a signed cookie; otherwise the toggle alone admits.
absence of XFF is the proof the request did not traverse one;* **or** `GET /` points at the wizard; **every other route is 404**.
- **(b)** the request is **unlocked with the setup code** (D-044) — the boilerplate's code form. - **`setup: false`, no admin** → the **LOCKED** state: `GET /` and `GET /setup` show an
- Anything else in setup mode → the **first-run boilerplate** (`/` · `/setup`) or **404**. "enable setup" boilerplate; every other route is 404. Not publicly claimable.
- **Once productive** (`setup: false`), normal service: the trusted proxy's **`X-Forwarded-Proto`** - **Once productive** (`setup: false` with an admin), normal service: the trusted proxy's
drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS (§6 · D-043). **`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-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-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-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-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-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-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-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-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) ## 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 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) 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** 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 AC-7/AC-10 are reworded: "refused" now means "refused the **wizard**" — the boilerplate may still
answer `GET /`/`GET /setup` with 200 (AC-14). 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 | | 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 | | **Created** | 2026-10-13 |
| **Author** | OpenArchitect | | **Author** | OpenArchitect |
| **Target milestone** | **M0** | | **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) | | **Related** | SPEC-003 (door) · SPEC-004 (identity) |
--- ---
## 1. Goal ## 1. Goal
Bring a fresh instance to life with a **`/setup` wizard** reachable **only over the direct SSH Bring a fresh instance to life with a **`/setup` wizard** — enabled by the **`setup` config toggle**
tunnel** that creates the **first user = the top admin** (`user_id = 1`, role `admin`), then switches (D-045) and optionally locked by a **setup code** (D-044) — that creates the **first user = the top
the instance to productive mode. admin** (`user_id = 1`, role `admin`), then switches the instance to productive mode.
## 2. Non-goals ## 2. Non-goals
Module setup screens (each module owns its config). Multi-admin (there is **one**, D-034/§7.1). 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. setup: false · no admin → LOCKED (a "set setup: true and restart" boilerplate; not claimable)
- **Gated — never first-visitor-wins (D-025 · D-044).** `/setup` is served to a request that is setup: true → the WIZARD (or the code form when `setup_code` is set)
**either** **(a)** a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`** (an setup: false · admin → PRODUCTIVE
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: - The app **may bind any address** — `0.0.0.0:8080` behind the proxy (D-043); **no** bind refusal.
- `GET /` and `GET /setup` show a **friendly first-run page** — *"MyDay is not set up yet"* — with a - **The gate is the config toggle `setup` (D-045).**
**setup-code entry form** and the instruction to read the code from the **server log**. - **`setup: false`, no admin** → the app is **locked**: `GET /` and `GET /setup` show a
**Never a bare 404** (the domain must not look "down"). **boilerplate** — *"MyDay is not set up. Set `setup: true` in `config.yaml` and restart."*
- Every **other** route → **404**. Every other route → **404**. **This state is not publicly claimable.**
- The correct code **unlocks the wizard**; the code is **single-use**, **rate-limited**, and - **`setup: true`** → `/setup` serves the **wizard** (the operator's deliberate switch).
**redacted from the logs** (A8). `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 - 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). 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 ## 4. The wizard
``` ```
GET /setup → the first-run boilerplate + the setup-code form # setup: false · no admin → the boilerplate ("enable setup") ← locked, not claimable
POST /setup?do=setup&action=unlock → the code → unlocks the wizard (a session flag) # setup: true → the wizard
GET /setup → the wizard (once unlocked, or via the tunnel) 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 1 domain / base_url
Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041) Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041)
Step 3 mail (SMTP) · locale · timezone Step 3 mail (SMTP) · locale · timezone
POST /setup?do=setup&action=… → each step (CSRF-protected) 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 → 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 - **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. **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` - **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 ## 5. After setup
- `/setup` → **404** (delete, don't redirect). Re-entering requires `config.setup: true` + restart. - `/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, - 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. 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). - **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 | | # | Criterion |
|---|---| |---|---|
| AC-1 | Fresh instance → `GET /` and `GET /setup` show the **first-run boilerplate** (200 — **never a bare 404**); every **other** route → 404 | | 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 | 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-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-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-4 | After completion `config.setup = false` (or printed) and `/setup` → 404 |
| AC-5 | A second `admin` cannot be created | | AC-5 | A second `admin` cannot be created |
| AC-6 | The full bootstrap is audited | | 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-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-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-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:** **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) ## 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 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 against the real binary (banner + code, boilerplate at `/` and `/setup` behind a proxy, wrong code →
opens the wizard with no code, wrong code → **403**, correct code → **303** + unlock cookie, the **403**, correct code → **303** + unlock cookie, the wizard follows, a spent code is refused, and
wizard follows, a spent code is refused, and neither the code nor a query-borne copy appears in neither the code nor a query-borne copy appears in the access log).
the access log).
**What landed** **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. | | **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). | | **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. | | **Generic** | A wrong and an absent code return the same **403** page/message. |
@ -189,7 +205,40 @@ the access log).
| # | Builder choice | Ruling | | # | 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-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-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 boilerplate **always** in setup mode (tunnel or not) — `/` is not the wizard. | **Accepted.** | | **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) setup mode keeps the strict 404 — the entrypoint always wires it. | **Accepted** — keeps the door honest without a setup package. | | **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 | | # | 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-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-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 `/` | | 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>`. | | **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-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-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.* |