# PROJECT_PLAN.md — MyDay > **Product:** MyDay — an employee time, absence & reporting app for **Sechpoint**. > **Owner:** Claus (Sechpoint) · **Architect:** OpenArchitect > **Created:** 2026-10-13 · **Status:** living document — updated as decisions are made. > > **How to read this:** §1–§2 say *what* MyDay is. §3–§7 describe *how it works*, in the order it > is built: **routing → bootstrap/top admin → auth → onboarding & roles**. §8+ are reference tables. --- ## 1. What MyDay is A **self-hosted, single-tenant** employee application delivered as a **PWA** (mobile + desktop). It is *not* a multi-tenant SaaS. Each company runs its own instance with one data boundary. **Shape:** a workday-style employee tool covering **time & absence**, **reporting**, and the **company org structure** those two depend on. **Modular:** a small core (**`MyDay.auth`** — identity · session · the door) plus registered **modules** mounted at **`/{GUID}/{module}/…`** (§4.6). --- ## 2. Scope | In scope | Notes | |---|---| | **Org structure** | users, the **manager hierarchy**, departments, positions | | **Time** | clock in/out or timesheets, hours worked | | **Absence** | leave requests, approvals, balances | | **Reporting** | aggregations/rollups over the above | **Out of scope (for now):** payroll · recruiting/ATS · benefits · finance — the rest of Workday. ### 2.1 Future / potential extensions (parked) - **AI expense processing** — receipts → structured expenses. - **IDM for other products** — MyDay's identity (GUID · Passkey/TOTP · sessions · roles) as an **identity provider (SSO/OIDC)** for other Sechpoint products. > Both are **out of current scope**. Recorded so the identity model is designed with them in mind > (stable `user_id`, clean credentials, roles) — but **not built**. --- ## 3. Guiding doctrine (adapted) MyDay adapts two documents from the GMS project — **guidelines, not scope**: **ROUTING.md** (the URL/cascade model + the one-door front-controller) and **SECURITY.md** (a deferred-risk register with explicit gates — *"the redesign will solve it"* is how bugs survive). Carried over: 1. **The path is the request.** Identity lives in the path; the query carries only transient state. 2. **One door.** The door *identifies* and routes; the handler *authenticates* and owns content. 3. **Identification ≠ authorisation.** The GUID is a *username* (public, bookmarkable); the **session cookie is the credential**. A bookmark is never a login. 4. **The same three rules at every level:** presence (empty → default / 404) · whitelist (known values) · `default:` (a **classifier** — match a pattern, else 404). 5. **Delete, don't redirect.** Reserved names from one source. 6. **Security debt is explicit and gated.** > **Divergence from GMS:** GMS threads context between *separate processes* via `X-*-*` headers. > MyDay is **one binary** — the hand-over is an in-process `RequestContext`. Discipline identical; > field names kept so a future split is mechanical. --- ## 4. The routing concept A **dual-mode door** over a **path cascade**. One request resolves to one context — which principal, which page or action — and then a handler executes it. ### 4.1 The dual dispatch (level 1) — two doors, one destination ``` DOOR (top level) parse: method · path · query · body ├─ carries routing fields? (do / action) │ NO → ROUTER : explode path → PARAM[] → cascade (§4.2) │ YES → HANDLER : do = {module} · action = {function} (§4.3) └─ resolve identity (GUID) + session + role gate ↓ (module, function, params) → the module's function ``` - The branch is keyed on the **routing fields, not the HTTP method** — a `GET` may carry `do`/`action` (a read action); a `POST` may be a plain path submit. Method-agnostic is robust. - Both doors resolve to the **same tuple** `(module, function, params)`: the router reads it from `PARAM[1..n]`, the handler from `do`/`action`. One registry, one set of module functions. - The **identity / session / role gate runs before** the module function — a module never sees a request it shouldn't. ### 4.2 The path cascade ``` /{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}/api | static | media | ws | dev → reserved infrastructure /{domain}/holy-shit → 404 (unknown · not reserved · not a GUID) /{domain}/{GUID} → GUID lookup → user not found → 404 found · NOT enrolled → ENROLL (mandatory TOTP + Passkey) found · enrolled · no session → LOGIN (Passkey, or TOTP) found · enrolled · session → /{GUID}/home /{GUID}/home → the user landing page /{GUID}/request → list MY requests (all) /{GUID}/request/open → list · OPEN view /{GUID}/request/closed → list · CLOSED view /{GUID}/request/pto → a new PTO request (form) /{GUID}/request/equipment → a new equipment request (form) /{GUID}/request/{request_id} → my request detail (classifier: id → lookup → 404) /{GUID}/approve → list requests to approve (all) /{GUID}/approve/open → list · OPEN view /{GUID}/approve/closed → list · CLOSED view /{GUID}/approve/{request_id} → view + act on a request (classifier: id → lookup → 404) ``` - The **GUID is always visible** in every authenticated path — identity never leaves the URL. - **Reserved `PARAM1` = `home · access · setup · api · static · media · ws · dev`** — one source constant; a GUID can never equal a reserved name. *(`auth` is retired — enrollment is GUID-driven.)* - **`PARAM2` is a module registry** (§4.6): `home` · `request` · `approve` · `org` · `time` · `absence` · `reports`. - **Lists & filters:** `…/request/open` · `…/request/closed` · `…/approve/open` · `…/approve/closed` are **path values** (D-018), so a filtered view is a **bookmarkable page**, not query state. - **`PARAM3` whitelist under `request`** = `open` · `closed` (views) and `pto` · `equipment` (new request forms); a **number** falls to the `default:` classifier (id); anything else → 404. ### 4.3 The action mode — two routing fields (`do` + `action`) A mutation carries **two routing fields**: `do` selects the **module**, `action` the **function**. ``` POST /{GUID}?do={module}&action={function}&… (a module action) ├─ do=hr & action=save-data ├─ do=time & action=clock-in └─ do=requests & action=submit POST /access?do=auth&action=request (recovery request) ``` - **Both fields are whitelisted** — `do` → the module registry (unknown → 404); `action` → the module's action set (unknown → 404). The same three rules as the path. - The **GUID still rides the path** (identity is never a form field); form **parameters** are separate fields/query. - **Not a security control.** Hiding the action in a field is *obscurity*; every action still requires **session auth + authorisation (ownership / role) + CSRF** (A8). ### 4.4 Worked example — why `home` works at two levels **It is two switches, not one tag.** The *level* decides which switch runs. ``` LEVEL 1 switch (PARAM1) "" → 302 /home "home" → neutral landing ← "home" is a CASE HERE "access" → recovery "setup" → the bootstrap wizard (setup mode only) "api"|"static"|"media"|"ws"|"dev" → infra default: user = SQL lookup(PARAM1) not found → 404 not enrolled → ENROLL no session → LOGIN else → LEVEL 2 (GUID context established) LEVEL 2 switch (PARAM2) — reached ONLY inside a resolved branch # GUID branch: "" → /{GUID}/home (the branch default) "home" → user landing ← "home" is a CASE HERE TOO "request" → LEVEL 3 (open | closed | pto | equipment | {id}) "approve" → LEVEL 3 (open | closed | {id}) default: → 404 ``` - Reserved cases are checked **before** the DB: `/home` matches at level 1 and never touches SQL. - `/{GUID}/home` passes the `default:` classifier first (SQL lookup → user) → enters level 2 → `PARAM2 = "home"` → the **user's** landing. - **The enroll/login gate sits between level 1 and level 2** for path requests — a GUID branch is never dispatched to level 2 unauthenticated. ### 4.5 The GUID lifecycle (enrolled Y/N) ``` a user is created → identity minted (status: pending_enrollment) + GUID → the invite carries /{GUID} → the enrollment window is BOUNDED (TTL); an un-enrolled GUID is a bearer (A1) GET /{GUID} → not enrolled → mandatory enroll (Passkey + TOTP) → status: active → enrolled → Passkey assertion (TOTP fallback) → session → /{GUID}/home recovery → NEW GUID minted · OLD identity revoked + GUID tombstoned (never recycled) ``` ### 4.6 The module registry — `/{GUID}/{module}/{function}/{param}` The `PARAM2` switch is a **registry of modules**, not a hard-coded list. Everything the app can do hangs off a module: ``` /{GUID}/{module}/{function}/{param…} core registry owned by the module ``` - **`PARAM2` = the module** — a registered name (whitelist); unknown → 404. - **`PARAM3+` = the module's own cascade** — the same three rules, one level down. The core never parses a module's internals. - **The core enforces auth / session / role *before* dispatch** — a module never sees an unauthenticated request. - Modules are named **`MyDay.`** (`MyDay.auth`, `MyDay.time`, …). | | Owns | |---|---| | **The core (`MyDay.auth` + the door)** | routing cascade · the registry · identity/GUID · sessions · Passkey/TOTP · roles + the chain · **user management** · audit · CSRF · config | | **A module** | its routes (functions) · its handlers · **its own tables** · its templates/assets | - A module depends only on **core interfaces — never on another module directly**. - A module may contribute to shared extension points: nav entries, **request types**, approval handlers, report sources. - **Planned modules:** see the catalogue in **§4.8**. ### 4.7 Drop-in modules & discovery **A module is a directory.** `internal/modules//` — self-contained: routes, handlers, **its own migrations**, templates. Drop it in, and it joins the registry. **Discovery is at BUILD time, not runtime.** Go has no safe runtime plugin loading (the `plugin` package needs an exact toolchain match, is Linux-only, and effectively CGO). So, following the **Caddy** pattern: - each module **self-registers** into the core registry from its package `init()`; - a generated aggregator (`internal/modules/imports_gen.go`, from `go generate`) blank-imports every module directory — so **the directory is the source of truth**; - adding a module = add the directory → `go generate` → rebuild. **No core edits.** **Guardrails:** - **Deterministic order** — modules register in a defined sequence (never map iteration); migrations and route precedence depend on it. - **Log the loaded module set at startup** — a visible registry beats hidden magic. - **A module owns its tables**; migrations come from the module, versioned, applied in order. - A module may expose an optional `Start(ctx)` for background work (**integration wrappers**, sync). - A module registers **both** its **routes** (path mode) and its **actions** (`do` / `action` mode). - **Module manifest** (`app.go`): the module declares its **name**, whether it is **required**, its **migrations**, and the extension points it contributes. - **Enable / disable:** optional modules can be switched off from a core **Modules** page (config-backed, D-038). Disabling **unmounts routes + hides nav — it never deletes data**. ### 4.8 The module catalogue (provisional) Two kinds: the **core** (not a module) and **modules**. | Module | Kind | Owns | |---|---|---| | **core `MyDay.auth`** | core | identity · sessions · Passkey/TOTP · roles · the chain · the door · the registry · **user management** | | `org` | records | departments · positions · the richer hierarchy | | `time` | domain | **presence + hours — one module.** A per-user **mode**: `presence` (in/out/away) or `timesheet` (exact hours, pay-by-hour). Optional **geo check-in** (D-031). | | `absence` | domain | PTO · leave balances | | `requests` | workflow | the **issue/ticket engine + approve queue** (pluggable request types) — the "must-have" | | `hr` | records | simple HR records | | `expense` | domain | expense claims · receipts | | `reports` | analytical | read model · rollups | | `integrations` | integration | wrappers to external apps (later; may need `Start()`) | **Extension points** a module can contribute to: request types (`requests`), nav entries, dashboard widgets, report sources. - **Location (optional, D-031):** **no tracking, no geofencing** — the audit log simply records an approximate **location derived from the client IP**. A VPN defeats it, and that's accepted. - **`time` mode (D-030):** `presence` (in/out/away) or `timesheet` (exact hours) — an **employment attribute** on the user (core/`hr`), read by the `time` module. > **Naming note:** *user management belongs to the **core**, not a module* — it manages identities, > roles, and the chain, which the core owns. So "management" decomposes into **core (users)** + > **`requests` (tickets/approvals)** + **`hr` (records)**. --- ## 5. Bootstrap & the top admin **There is no separate "super" account.** The **first user is the top admin** — a normal user with the highest role, the **root of the chain**. When the `users` table is empty, the instance is in **setup mode**. ### 5.1 First run — the `/setup` wizard ``` fresh deploy → config.setup: true → setup mode: `/` and `/setup` show a first-run page 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) Step 3 mail / SMTP · locale · timezone Finish → settings persisted → the app runs in PRODUCTIVE MODE (setup: false) → /setup now 404s (delete, don't redirect); re-run only via config.setup: true + restart ``` - `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.* ### 5.2 Admin recovery — a config-gated enrolment link ``` sysadmin sets the recovery approval flag in config (e.g. admin_recovery: true) → restart → the top admin's (user 1) identity is ROTATED → status: pending_enrollment → the system EMAILS the enrolment link to user 1's ADDRESS ON RECORD → the admin re-enrolls Passkey + TOTP → audited · the flag is cleared ``` - **Dual control.** Without the config flag **nothing happens** — no rotation, no email. The operator holds one half; the admin's **inbox** holds the other. - The top admin **has an email** (collected by `/setup`, §5.1), so the link has somewhere to go. The config flag is the *approval*; the inbox is the *delivery*. ### 5.3 Rules of the top admin - The top admin **cannot be deleted** (by themselves or anyone) — lockout prevention. Only `reset_admin` changes the account. - Recommended (O-7): **exactly one** admin at the top; everyone else is director / manager / team. --- ## 6. The auth concept ### 6.1 Identity anchors | Anchor | Meaning | Rotates? | |---|---|---| | **`user_id`** | internal, stable person id (**the first user = 1 = the top admin**); never in a URL | **no** | | **`guid`** | the access username in the path | **yes** (recovery) | | **`email`** | contact + recovery authority | no | - `user_id` relates to the GUID and the email. **Stable user ≠ rotating identity.** - Credentials hang off an **identity**; only one active identity per user (D-008). - **GUIDs are never recycled** — a tombstoned GUID stays reserved forever. ### 6.2 The two factors **Passkey (primary) + TOTP (fallback) — only.** No OTP-by-email as a login factor; no recovery codes. Email is *never* a login factor; it is only the recovery **channel**. ### 6.3 Enroll vs login — one path, two states ``` GET /{GUID} → found · not enrolled → ENROLL (mandatory Passkey + TOTP) → found · enrolled → LOGIN (Passkey assertion, or TOTP) → session → not found → 404 ``` **The GUID is identification; the session is the credential.** The door resolves *which* user; the handler proves *you are* them. ### 6.4 Recovery | Who | Path | |---|---| | **team / manager / director** | `/access` → email on record → **manager escalation** (approve/deny) → new GUID | | **top admin** | **`reset_admin`** config toggle → identity rotated → re-enroll (§5.2) | ``` GET /access → enter email on record POST /access?do=request → ALWAYS generic ("if a record exists, we've emailed you") └─ on match: recovery_request(status: pending_approval) · notify approver chain + employee Approver (a role above the requester) — authenticated session → approve / deny (approver ≠ requester, always; both audited) └─ on approval: revoke OLD identity · mint NEW GUID · email the new link Employee re-enrolls Passkey + TOTP under the NEW GUID → /{old GUID} permanently dead ``` **Why this is safe:** email compromise alone can only *initiate*; credentials require a **second, independent human approval** from the chain. With identity rotation (D-007), a compromised inbox cannot take over an account on its own. **Edge cases:** approver must be above the requester · never the requester · pending requests expire (~72h), capped per user. --- ## 7. Onboarding & roles ### 7.1 Roles (KISS) ``` admin → director → manager → team (top) (employee) ``` | Role | May create | Their manager (default) | |---|---|---| | **admin** | director · manager · team | — (root) | | **director** | manager · team | the admin | | **manager** | team | a director (or admin) | | **team** | — | a manager | **One role per user** — the role **ripples down** (a director can do all a manager and a team member can). No tick-boxes, no ACL (D-034). The **chain** (`manager_user_id`) is separate: it answers *who approves you*, not *what you may do*. ### 7.2 Manager first, then team **The chain is built top-down**, so a manager always exists before their reports do: ``` the top admin (first user) └─ director created by the admin (admin approves their recovery) └─ manager created by the director (the director approves recovery) └─ team created by the manager (the manager approves recovery) ``` ### 7.3 Who may create a user - **You may create only roles *below* your own** (admin → director → manager → team). - The **new user's manager defaults to the creator**. - This **unifies creation authority and recovery authority** on the same chain — no one can invent a peer or top-level account and sidestep escalation. ### 7.4 The minimal hierarchy (M0) - A **self-referential `users.manager_user_id`** ships in **M0**, because onboarding *and* recovery both need to answer *"who is above this person?"* (D-020). - Departments, positions, and richer org charts come in **M1**. - Onboarding is **not** manager-approved for the *initial* invite (the creator's authority + the emailed `/{GUID}` link is the control); **recovery** is the step that requires approval. --- ## 8. Decisions log | ID | Date | Decision | Rationale / notes | |---|---|---|---| | **S-001** | 2026-10-13 | Scope = **org structure + time + absence + reporting**. | Payroll/recruiting/benefits excluded. | | **S-002** | 2026-10-13 | **Self-hosted, single-tenant.** No tenant `{slug}`. | GMS's `{slug}/{GUID}` collapses to one level. | | **S-003** | 2026-10-13 | **Language: Go — confirmed, not Rust.** | I/O-bound CRUD + HTTP, self-hosted single binary. The "no runtime plugins" limit is solved by build-time registration (**Caddy** does this); **Rust's dynamic loading is *no better*** (no stable ABI). Go wins on iteration speed, ecosystem (WebAuthn · TOTP · SQLite · sqlc), and IDM precedent. **Rust is reserved** for a future CPU-heavy module/service (e.g. on-device AI) — a contained component, not a rewrite. | | **D-001** | 2026-10-13 | **The GUID is the username**, at `PARAM1`, **always visible** in the path. | Unguessable (≥128-bit). The **session is the credential**. | | **D-002** | 2026-10-13 | The door **resolves**; the handler **authenticates**. | A bookmark is never a login. | | **D-003** | 2026-10-13 | **One binary**, headless door, **in-process `RequestContext`**. | Self-host simplicity; GMS field names kept for a future split. | | **D-004** | 2026-10-13 | **The path cascade** (§4.2): `PARAM1` = a reserved workflow **or a GUID**; the GUID's namespace carries the app. Empty `PARAM1` → 302 `/home`. | Reserved: `home · access · setup · api · static · media · ws · dev`. | | **D-005** | 2026-10-13 | **Two factors only: Passkey (primary) + TOTP (fallback).** | No OTP-by-email login factor; no recovery codes. | | **D-006** | 2026-10-13 | **Enrollment is GUID-driven — no `/auth/enroll/{token}`.** `GET /{GUID}`: N → enroll, Y → login. The invite carries `/{GUID}`. | Un-enrolled GUID is a bearer → **bounded** window (A1). | | **D-007** | 2026-10-13 | **Recovery rotates the identity:** new GUID, old revoked; **all previous auth records invalid.** | Old GUID tombstoned; sessions/passkeys/TOTP destroyed. | | **D-008** | 2026-10-13 | **Stable user record ≠ rotating identity.** Credentials hang off an identity. **GUIDs never recycled.** | Employment survives rotation. | | **D-009** | 2026-10-13 | **Manager escalation.** Every non-admin user sits below someone. | Recovery needs **approval from the chain**. Email *initiates*; a human *authorises*. | | **D-010** | 2026-10-13 | **`config.yaml` holds config + secrets.** Gitignored; `config.example.yaml` shipped; env override. | The DB is the truth for identities/credentials. | | **D-011** | 2026-10-13 | **PWA `start_url: "/"`.** The door resolves the session and redirects to the current GUID. | Install survives identity rotation. | | **D-012** | 2026-10-13 | **SQLite** datastore. | WAL · FK ON · **CGO-free driver** · file backup. **One instance only** (single-writer). | | **D-013** | 2026-10-13 | **Internal `user_id`** relates to the **GUID** and the **email**; never in a URL. **The first user = 1 = the top admin.** | The person is permanent; the GUID is the revocable door. | | ~~D-014~~ | 2026-10-13 | ~~Super GUID in config~~ | **SUPERSEDED by D-021/D-022** — no separate super account. | | ~~D-015~~ | 2026-10-13 | ~~Mandatory first-run super rollout~~ | **SUPERSEDED by D-022** — the first user is the top admin. | | ~~D-016~~ | 2026-10-13 | ~~Super enable/disable toggle~~ | **SUPERSEDED by D-023** — `reset_admin`. | | **D-017** | 2026-10-13 | **Dual dispatch:** path requests *or* `do={action}` requests. `do` = hidden form field. | Form state, not identity. **Not a security control** (A8). | | **D-018** | 2026-10-13 | **List filters ride the PATH**, not the query: `…/request/open` · `…/approve/closed`. | A filtered view is a **page**; deliberate divergence from GMS. | | **D-019** | 2026-10-13 | **Onboarding is top-down (manager first, then team).** Creation authority = a role **above** the new user; the new user's manager defaults to the creator. | Unifies creation + recovery authority on one chain. | | **D-020** | 2026-10-13 | **Minimal hierarchy in M0:** self-referential `users.manager_user_id`. | Onboarding and recovery both need the chain; richer org structure is M1. | | **D-021** | 2026-10-13 | **No super account — the first user is the top admin** (`user_id = 1`, role `admin`, root of the chain). | Removes a standing privileged account. | | **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-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//`) 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-030** | 2026-10-13 | **`presence` and `time` unify into one module (`time`).** A per-user **mode** decides the flow: `presence` (simple in/out/away) or `timesheet` (exact hours, pay-by-hour). | The mode is an **employment attribute** on the user (core/`hr`), read by the module. `presence` becomes a *view*, not a module. | | **D-031** | 2026-10-13 | **Location = audit only. No tracking, no geofencing.** The audit log records an approximate **location derived from the client IP**. Off by default; a VPN defeats it (accepted). | Deliberately shallow — enough for a security trail, nowhere near geo-tracking. | | **D-032** | 2026-10-13 | **Actions carry TWO routing fields: `do={module}` + `action={function}`** (both whitelisted). The GUID still rides the **path**. | The PHP-style simplicity for module dispatch (`do=hr&action=save-data`). Modules register routes **and** actions. | | **D-033** | 2026-10-13 | **Admin recovery: a config approval flag emails the enrolment link to user 1's address on record.** Without the flag, **nothing happens**. | **Dual control** — the operator's config half + the admin's inbox half. Refines D-023. | | **D-034** | 2026-10-13 | **Roles: simple tiers, NO ACL, ONE role per user.** `admin > director > manager > team`; the assigned role **ripples down** — it implies all lower roles. *(No tick-boxes.)* | KISS — basic levels only, no permission matrix. The **chain** (`manager_user_id`) stays separate from roles. | | **D-035** | 2026-10-13 | **Settings live in the CONFIG FILE** (infra · secrets · domain · mail · locale). The **DB holds operational data only**. | Owner's call: editing a setting in the file beats a DB migration. Caveat: the app must be able to write `config.yaml` (or the wizard prints the values for the operator). | | **D-036** | 2026-10-13 | **Layout: separated dirs, one tree** — `internal/core/` (the door · registry · identity · DB) and `internal/modules//` (**self-contained vertical slices**: routes · actions · handlers · queries · migrations · templates). | The boundary becomes **physical**, and each module stays truly drop-in. Rejected: one flat dir. | | **D-037** | 2026-10-13 | **Every module has the same default skeleton** — `app.go` (the manifest: name · required · migrations · nav · `Start`) · `routing.go` (routes + actions) · `handlers.go` (the action handlers). More files as needed. | Predictability + drop-in: every module reads the same way. | | **D-038** | 2026-10-13 | **Modules are enabled/disabled from a core Modules page** (state in config, D-035). Disabling unmounts routes + hides nav; **it never deletes data**. **Required** modules can't be disabled. | Runtime activation of compiled-in modules. Toggles are **audited** (A11). | | **D-039** | 2026-10-13 | **Two level-1 entry points — ROUTER (path) and HANDLER (actions) — both resolve to `(module, function, params)`.** The branch is keyed on the **routing fields** (`do`/`action`), **not** the HTTP method. Identity + role gate run **before** the module function. | The top-level dispatch, made explicit: **two doors, one destination.** | | **D-040** | 2026-10-13 | **Agent-workflow variables live in `.opencode/git.env`** (gitignored), with a committed `.opencode/git.env.example`. Git-native settings (identity · remote · branch · pull) live in repo-local `.git/config`. | Two-tier config (SPEC-001 A-04): git owns what git owns; automation knobs stay out of git. | | **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. | --- ## 9. Parked / open questions | # | Item | Status | |---|---|---| | O-1 / O-2 | First-run bootstrap · super recovery | **SUPERSEDED** by §5 (D-021..D-023) | | O-3 | Recovery approver before the hierarchy exists | **RESOLVED** by top-down onboarding (D-019): the admin approves the first directors; thereafter the chain. | | O-4 | Datastore | **RESOLVED** (D-012, SQLite) | | 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-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.** | | O-13 | WebAuthn origin binding at bootstrap | **RESOLVED** (D-041): the wizard enrolls **TOTP only**; the passkey is added at first login on the real origin, via a **nudge** with an opt-out marker. | | O-14 | Enrollment reach of the passkey nudge | **RESOLVED** (owner, Option A · D-042): TOTP-only is the **bootstrap admin only**; **invited users enroll both factors**. The nudge is the safety net for any identity without a passkey. | | O-15 | Module names vs reserved level-1 workflows | **RESOLVED** (SPEC-009 §13 · Q-1): **extend the guard** — a discovered module named for a reserved level-1 workflow (`static` · `access` · `api` · `media` · `ws` · `dev`) **fails to load**, **except `home`** (a legitimate module). | --- ## 10. Security register (deferred, gated) > **THE GATE — re-check every open item before the first real client / beta.** The threat model > flips from *"nobody has access"* to *"strangers do."* > > **2026-10-14 — REVIEW-001** (M0, independent): the identity/session/CSRF core was **sound**; the > **perimeter** was not. **H1 · H2 · M1c · M1s · L3s · L1c/L2s · M2s · M3s · L1s · L5s · L4s · L7c** > are fixed (see `docs/reviews/review-001-m0.md`); **L3c · L4c · L5c · L6c** remain open. | # | Item | Sev | Status | |---|---|---|---| | A1 | **Enrollment window bounded** — un-enrolled GUID is a bearer; TTL + invite delivery + rate limit | HIGH | designed (D-006) | | A2 | Empty/deleted session id rejected on **every** path | HIGH | **enforced** (M0 review verified) | | A3 | `/{GUID}` and `/access` rate-limited; **generic** responses (no enumeration/no timing oracle) | HIGH | **enforced** — limiter pruned & capped (M3s); `/access` notification is async (M2s) | | A4 | Recovery requires **manager escalation**; approver above the requester; both audited | HIGH | designed (D-009) | | A5 | PWA service worker never caches authenticated responses or `/{GUID}` across sessions | MED | **enforced** (M0 review verified) | | A6 | Trusted-proxy client IP — XFF **set**, not appended | MED | **enforced** — XFF resolved **right→left**, first untrusted hop (H1) | | A7 | **Email = recovery initiation only**; a human must approve | HIGH | designed (D-009) | | A8 | **Actions (`do=`) are form state, not a control** — server-side authz + CSRF on every action | HIGH | **enforced** (M0 review verified) | | A9 | TOTP secret **encrypted at rest** (key from config) | HIGH | **enforced** — key generated at startup (D-1/R-6); replay refused (L5s) | | 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** | | 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) | --- ## 11. Milestones (provisional) | # | Milestone | Depends on | Notes | |---|---|---|---| | **Prep** | **Repo bootstrap** — git ✅ · `.gitignore` ✅ · `docs/specs/` ✅ · `AGENT.md` ✅ · `README.md` ✅ · `config.example.yaml` ✅ | — | **done** | | **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | **✅ done** (SPEC-002…008) | | **M1** | **Org structure** — departments, positions, richer hierarchy | M0 | enriches the chain | | **M2** | **Time** — clock/timesheets | M1 | kiosk/shared-device mode is a design concern here | | **M3** | **Absence** — leave, approvals, balances | M1, M2 | `request/pto` lands here | | **M4** | **Reporting** — rollups and aggregations | M2, M3 | own read model; likely its own workflow | --- ## 12. Repository & project setup (planned) **Not built yet** — these are planned artefacts, to be created at the **Prep** step before M0. | Artifact | Purpose | |---|---| | **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`).** | | **`docs/`** | this plan at the root; specs later in `docs/specs/`. | | **CI (later)** | build + test on push. | ### 12.1 Layout (D-036) ``` myday/ cmd/myday/ entrypoint — assembles config → core → modules → server internal/core/ THE CORE (MyDay.auth) routing/ the pure routing model (no HTTP, no DB) httpx/ door helpers (session cookie · client IP · redaction) auth/ identity · session · passkey · TOTP users/ users · roles · the manager chain module/ the module contract + the registry store/ the DB connection + the migration runner web/ the shared shell / layout internal/modules/ first-party modules — ONE DIR EACH (drop-in) expense/ app.go · routing.go · handlers.go (default skeleton) + queries.go · migrations/ · web/ (as needed) time/ absence/ requests/ org/ hr/ reports/ integrations/ docs/ config.example.yaml AGENT.md README.md PROJECT_PLAN.md ``` **The core owns the DB connection and the migration runner; each module owns its own tables, queries, and migrations** (embedded, applied in registration order). Templates: the shared shell/layout lives in `core/web`; a module's views live with the module. ### 12.2 Git integration - **Branching:** `main` is the source of truth; work happens on short-lived feature branches. - **Commits:** **Conventional Commits** (`feat:` · `fix:` · `docs:` · `chore:` …), one logical change each. - **The agent commits AND pushes at every submilestone** (each spec slice or revision) once the gate (`build` · `vet` · `test -race`) is green; if a decision changes, **this plan is updated in the same commit**. - **Secrets are never committed** — `config.yaml` is gitignored from commit one. - **The agent git contract** is `.opencode/git.env` (gitignored); `.opencode/git.env.example` is committed. Git-native settings are **repo-local** (`.git/config`) — **never** a global identity. - **Remote & credentials:** the remote URL is **clean** (no token); the token lives in `~/.git-credentials` (mode `600`). **Rotate any token ever transmitted in plaintext.** ## 13. Conventions - **Language/stack:** Go; **single binary**; **SQLite** (WAL, foreign keys ON, CGO-free driver); `sqlc`; embedded migrations; `slog` with a correlation id. - **Docs:** specs live in `docs/specs/` (index: `docs/specs/README.md`); this file is the decision log of record. - **Source doctrine:** GMS `ROUTING.md` + `SECURITY.md` (references, not scope). - **Rule of the door:** the door resolves; the handler authenticates; the handler never parses a raw path. --- ## 14. Change log | Date | Change | |---|---| | 2026-10-13 | Created. Logged S-001..S-002, D-001..D-011; auth model, security register, milestones. | | 2026-10-13 | SQLite (D-012); `user_id`/GUID/email anchors (D-013); super account (D-014..D-016). | | 2026-10-13 | **Routing corrected** — GUID at `PARAM1`, always visible (D-004); enrollment GUID-driven (D-006); dual dispatch + `do=` (D-017); `home` = two switches; list filters on the path (D-018). | | 2026-10-13 | **Reordered for build sequence** — routing → super → auth → onboarding; manager-first (D-019); minimal M0 hierarchy (D-020). | | 2026-10-13 | **Super replaced by the first user = top admin** (D-021); gated bootstrap (D-022); `reset_admin` recovery (D-023); roles `admin>director>manager>team` (D-024). Opened O-5..O-9. | | 2026-10-13 | Added **§12 Repository & project setup (planned)** — git bootstrap, **`AGENT.md`**, README, git workflow; **Prep** milestone added before M0. *(Planning only — nothing built.)* | | 2026-10-13 | **Bootstrap made loopback-only, enforced (D-025)** — closes first-visitor-wins in code; README documents the SSH-tunnel first step. Resolved O-8; A13 → designed. | | 2026-10-13 | **`/setup` wizard is the bootstrap** (D-026) — reserved route, setup-mode-only, loopback-only; configures domain + top admin + mail/locale; supersedes `{bootstrap_guid}`. Added **§2.1 future extensions** (AI expenses · IDM); opened O-10. | | 2026-10-13 | **Modular architecture (D-027)** — core = `MyDay.auth`; modules mounted at `/{GUID}/{module}/{function}/{param}`; added **§4.6 the module registry** (core/module split, module isolation). Opened O-11 (module boundaries). | | 2026-10-13 | **Drop-in modules, build-time discovery (D-028)** — Caddy pattern: self-registering dirs + a generated aggregator; no runtime plugins. **User management is core (D-029).** Added **§4.7 discovery** + **§4.8 the module catalogue** (`org` · `time` · `presence` · `absence` · `requests` · `hr` · `expense` · `reports` · `integrations`). | | 2026-10-13 | **Language confirmed: Go, not Rust (S-003)** — the plugin limit is solved by build-time registration; Rust's dynamic loading is no better. **`presence` + `time` unified** with a per-user mode (D-030); **geo restored as an optional, consent-gated geofenced check-in** (D-031). | | 2026-10-13 | Closed O-5 and reshaped D-031/O-11. **Actions = `do` + `action`** two-field routing (D-032); **admin recovery = config flag → email the enrolment link to user 1** (D-033, dual control); **location reduced to audit-only (IP-derived)** (D-031 revised); O-11 now purely the `requests` data-model question. | | 2026-10-13 | Closed **O-7** (one admin — the Highlander), **O-9** (KISS = principle; tiered roles, no ACL, D-034), **O-10** (settings in the config file, D-035). Added **D-036 layout** (separated `core/` + `modules/`), rewrote **§12.1**. | | 2026-10-13 | **D-034 amended** — one role per user, ripple-down, **no tick-boxes**. **D-037** module skeleton (`app.go` · `routing.go` · `handlers.go`); **D-038** Modules page (enable/disable, required modules, never deletes data); §12.1 shows the module skeleton. | | 2026-10-13 | **Level-1 dispatch made explicit (D-039)** — ROUTER (path) vs HANDLER (`do`/`action`), keyed on the **routing fields, not the method**; both resolve to `(module, function, params)`. **O-11 closed** as a build-time data-model detail. **The open list is now empty.** | | 2026-10-13 | **Prep artefacts written** — `AGENT.md` · `README.md` · `config.example.yaml`. **D-040** the `.opencode/git.env` contract (SPEC-001 A-04); §12.2 updated with the git/credential contract; Prep marked done. | | 2026-10-13 | **M0 specs started** — **SPEC-002** (the routing model · the Stage-0 gate) · **SPEC-003** (the core door) · **SPEC-004** (identity & sessions) · `docs/specs/README.md` index. Next: SPEC-005 (`/setup`), SPEC-006 (enroll/login), SPEC-007 (roles/chain/recovery), SPEC-008 (registry). | | 2026-10-13 | **M0 spec set COMPLETE** — **SPEC-005** (`/setup` wizard) · **SPEC-006** (enroll/login) · **SPEC-007** (roles/chain/recovery) · **SPEC-008** (module registry + Modules page). All eight specs are *Ready for implementation*; the index reflects it. | | 2026-10-13 | **SPEC-002 ✅ IMPLEMENTED** — `go.mod` · `cmd/myday` stub · `internal/core/routing` (pure model + all 22 test rows + a real purity assertion). `build`/`vet`/`test` green. Builder's D-1..D-6 rulings accepted and recorded in SPEC-002 §7. **Next build slice: SPEC-004.** | | 2026-10-13 | **SPEC-004 ✅ IMPLEMENTED** — `core/store` (modernc SQLite · WAL · migrations) · `core/audit` · `core/users` · `core/auth` (identities · sessions · AEAD TOTP sealer). AC-1..8 green, `-race` clean, **static CGO-free binary**. **R-1 ruling**: audit metadata threads via `context.Context`; SPEC-004 §5 rewritten. Next: **SPEC-003**. | | 2026-10-13 | **SPEC-003 ✅ IMPLEMENTED** — `core/config` · `core/httpx` · `core/middleware` · `core/server` (the door) · `main` wired. AC-1..9 green, static binary, loopback fail-closed, live-smoked. Rulings: **R-4** (context actor for identity ops) · **R-5 → A15** (pre-session CSRF, also for enroll/login). Next: **SPEC-006**. | | 2026-10-13 | **SPEC-006 ✅ IMPLEMENTED** — WebAuthn (`webauthn.Service`, fake in tests) · TOTP (sealed secret) · the GUID state machine in `auth/flow` · pre-session double-submit CSRF (**A15**) · enrollment window. AC-1..7 green, `-race` clean, live-smoked. **R-4** applied. **F-1** (login screen 200) → folded into SPEC-005; **SPEC-009** (web/PWA) recorded. Next: **SPEC-005**. | | 2026-10-13 | **F-1 FIXED + SPEC-005 ✅ IMPLEMENTED** — the **login screen returns 200** (a rendered page; only a *failed credential* is 401/400). The **`/setup` wizard** (`core/setup`): setup mode is **latched at startup** (`config.setup` OR not-yet-bootstrapped) and cleared by an atomic `Done()` — so it holds through the whole wizard and avoids a per-request DB hit / data race; the door serves **only `/setup`** (+ static) in setup mode; steps domain → top admin (**reusing SPEC-006's enroll ceremony**, `flow.Deps.SuccessPath`) → mail/locale; persists `config.yaml` with `setup: false` (or **prints** when unwritable). One admin only; pre-session CSRF (**A15**) on every step. AC-1..6 green, `-race` clean, static binary, live-smoked (incl. the fail-safe `setup:false`/empty-DB path). Additive: `Identities.ByUserID` · `config.Marshal`. Class B flagged (secrets not generated; WebAuthn RP built at startup). Next: **SPEC-007**. | | 2026-10-13 | **SPEC-005 rulings** — **R-6**: the wizard **generates strong secrets** when a secret is empty/placeholder. **F-2**: `Cache-Control: no-store` on the enroll/TOTP (`otpauth`) responses. **O-13 opened** (§9): a passkey enrolled over plain-HTTP loopback binds RP ID `127.0.0.1` — unusable at the public domain; **owner decision**. Next: **SPEC-007**. | | 2026-10-13 | **SPEC-007 ✅ IMPLEMENTED** — `core/mail` (fake + SMTP) · `core/recovery` (`/access` flow · **D-033** admin path) · `core/server/roles.go` (the **D-034** RoleGate policy). AC-1..6 green, `-race` clean, live-smoked (generic response · admin flag on/off). **Step 0** (R-6 secrets · F-2 `no-store`) landed. **R-7 ruling**: recovery approval is the **chain** (manager upward) or the admin, never "role above" — folded into SPEC-008. Next: **SPEC-008**. | | 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED → M0 COMPLETE.** `core/module` (contract · registry · Modules page) · `internal/modules/` (the `go generate` discovery + the reference `home` module) · wiring. AC-1..6 green, `-race` clean, static binary, live-smoked (a dropped module registered with **no core edits**). **R-7** applied. Follow-ups: module **name-collision guard** · a neutral `handoff` package (invert `module → server`) · nav rendering is SPEC-009. **All M0 specs (002–008) implemented.** | | 2026-10-14 | **O-13 RESOLVED (D-041).** The setup wizard enrolls **TOTP only**; any **TOTP login with no passkey** is **nudged** to add one (*not now* / *don't ask again* → `users.passkey_opt_out`). The nudge is **suppressed on loopback / in setup mode** (a `127.0.0.1` passkey is dead at the public domain). SPEC-005/006/004 revised. | | 2026-10-14 | **D-041 IMPLEMENTED.** Migration `0002_passkey_opt_out.sql` · `users.PasskeyOptOut`/`SetPasskeyOptOut` · `/setup` step 2 enrolls **TOTP only** (`flow.Deps.TOTPOnlyEnroll`, the wizard's own flow) · the **passkey nudge** after a TOTP login (screen `GET /{GUID}/auth/passkey`, actions `passkey.add`/`later`/`never`, session-gated). AC-8…AC-11 + revised setup ACs green, `-race` clean, static binary. | | 2026-10-14 | **D-041 rulings** — `passkey.later` is a skip (nothing persisted, **not** audited); `passkey.never` / `passkey.add` **are** audited. The nudge fires only on the **active-identity** TOTP branch; any lookup error **fails safe** (no nudge). **O-14 opened** (§9): the nudge's reach — keep both-factor onboarding, or go **TOTP-first universal**. | | 2026-10-14 | **O-14 RESOLVED (D-042, Option A)** — TOTP-only enrollment is the **bootstrap admin only**; **invited users enroll both factors**; the nudge is the safety net. **SPEC-009 (web layer & PWA)** written — the thin *make-it-usable* slice (shell · nav · vanilla WebAuthn JS · manifest · a service worker with **no authenticated caching**, A5); folds in the SPEC-008 collision guard. **Next: build SPEC-009.** | | 2026-10-14 | **SPEC-009 ✅ IMPLEMENTED → M0.1 (human-usable).** `internal/core/web/` — the **shell** (`layout.html` · `NavLister`/`Render` · nav from `Registry.Nav()`, D-038) and the embedded assets (`app.css` · **`webauthn.js`** — the three ceremonies, vanilla, **no inline scripts** · `sw.js` + the `CacheablePrefixes` policy, **A5** · `manifest.webmanifest` with **`start_url: "/"`**, D-011 · icons) · the reserved **`static`** workflow (`core/server/static.go`) · every screen restyled inside the shell (`/setup` · enroll · login · nudge · dashboard · `/access` · Modules page · `/home`) · **Step 0 the module name-collision guard** (`module.CoreDispatchNames` · `ErrCoreDispatchName` — a colliding module now **fails to load**, error + log, SPEC-008 §9 D-9). AC-3…AC-7 asserted **without a browser**; live-smoked end to end from a copy of `config.example.yaml` (bootstrap → TOTP login → **nudge** → dashboard → restart). `-race` clean · static CGO-free binary · `routing` purity intact. **D-1 (flag):** the **TOTP key is now generated at startup** — D-041 enrolls TOTP *before* `finish` persists the R-6 secrets, so a fresh copy of the example could never complete `/setup` ("no TOTP sealer configured"); the ruling R-6 is unchanged, only the timing. **D-2:** `home` is excluded from the collision guard (the dashboard *is* the discovered reference module). **O-15 opened** (§9): should a module named for a reserved level-1 workflow be refused too? | | 2026-10-14 | **SPEC-009 rulings (§13)** — **D-1 accepted** (the TOTP key is generated at **startup**; it fixed a real blocker — a fresh `config.example.yaml` could never complete `/setup`); **D-2 accepted** (the collision guard = the core dispatch modules `auth·setup·recovery·core`; `home` is a level-1 *workflow*). **O-15 RESOLVED**: extend the guard to the reserved level-1 names, **except `home`**. Q-3 → a **README caveat** (reach loopback via `localhost`). Guard extension + README caveat folded into the next slice. | | 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED + Step 0 (R-7)** — the **module registry** & the core **Modules page**: `core/module` (the contract · Registry · page) · `internal/modules/` (**build-time discovery**, D-028: a `go generate` tool writes `imports_gen.go` from the directories) · the reference required **`home`** dashboard module · `main` wiring (load → migrations → mount → log). **R-7 landed**: `users.CanApprove(approver, requester, approverIsAncestor)` — approval authority is the **chain** (manager upward) or the admin (`recovery.isAncestor`). AC-1..6 green, `-race` clean, static binary, live-smoked; `core/routing` purity intact. **M0 complete.** Next: **M1 — org structure**. | | 2026-10-14 | **REVIEW-001 (M0) written** — two independent passes (correctness · adversarial security). Verdict: the **identity/session/CSRF core is sound** (no auth bypass, no session forgery, no CSRF gap, no SQLi, no traversal) but the **perimeter is weak**: **H2** (loopback enforced only at *bind*, not per request) and **H1** (XFF resolved *leftmost*-untrusted, forgeable through an append-style proxy) are **HIGH**; plus M1c/M1s/M2s/M3s/M2c and L1c/L3s/L1s/L5s/L4s/L7c/L8c. `docs/reviews/review-001-m0.md`; register rows re-opened where the review contradicted "enforced". | | 2026-10-14 | **REVIEW-001 FIX PASS (security).** **H2** — a **per-request** loopback gate in `dispatch` while in setup mode (a non-loopback *resolved* client IP → 404; the bind check alone is defeated by a reverse proxy — the exact first-visitor-wins D-025 claims to close); SPEC-003 §7 + AC-10, A13 → **enforced**. **H1** — `ClientIP` walks XFF **right→left** and returns the first untrusted hop, falling back to the peer (A6/AC-12); the unit test that encoded the vulnerable expectation is rewritten. **M1c/L3s** — the `setup` **module is pinned at the door**: `serveSetupAction` rejects `Module != "setup"`, and any other resolution naming it 404s in every mode instead of falling through to `serveGUID`. **M1s** — the CSRF/cookie signing keys are **generated at startup** in setup mode (mirroring the TOTP key, D-1/R-6), so the wizard's ceremony is never signed with a shipped public placeholder; `finish` persists the same values. **M2s** — the `/access` notification runs **off the request path** (a `Dispatch` seam, `go fn()` by default, context detached) so a hit is not timed against a miss (A3). **M3s** — the rate limiter's state map is **pruned** (time-gated sweep at a size threshold) and **capped** (memory-DoS). **L1s** — the session cookie is **`__Host-myday_session`** over https (conditional: a browser rejects `__Host-` without `Secure`, which would break the http loopback bootstrap). **L5s** — **TOTP replay** refused: the highest accepted counter per identity is tracked (reset on re-enrollment). **L4s** — `Permissions-Policy` · `Cross-Origin-Opener-Policy` · `Cross-Origin-Resource-Policy`. **L7c** — the nudge's origin signal is the configured **`base_url` host** (falling back to the request `Host`), so a proxy rewriting `Host` cannot suppress it. SPEC-003 amended (§4/§6/§7/§9/§10 + §11 the fix table). | | 2026-10-14 | **REVIEW-001 FIX PASS (correctness & cleanup).** **L1c/L2s** — dead `secrets.session_key` **removed** (`config.go` · `setup.go` · `config.example.yaml`; SPEC-003 D-2 amended). **L8c** — README corrected (M0/M0.1 status; `/setup` **persists `config.yaml`** and a **restart adopts the generated secrets**; the per-request loopback rule stated). **M2c** — an automated **restart-survival test** (`cmd/myday/main_test.go`) generates the TOTP key at startup, enrolls a secret, persists via the wizard, reopens the DB with the persisted key and asserts the secret still unseals — the **D-1** invariant, previously covered only by a manual smoke. Gate green (`build` · `vet` · `test -race`), **static CGO-free** binary, `routing` purity intact; the H2 guard was reverted to confirm its test fails without it. **Still open (deferred, low): L3c · L4c · L5c · L6c.** | | 2026-10-15 | **Deployment topology (D-043) + D-025 REVISED.** The app sits **behind Zoraxy** (`myday.sechpoint.app`); **TLS terminates at the proxy**, so the app **listens on `0.0.0.0:8080` over HTTP** — a **bind refusal is impossible**. **D-025 becomes a per-request gate**: in setup mode serve only a request whose **TCP peer is loopback *and* which carries no `X-Forwarded-For`** (the **direct SSH tunnel**); anything else → 404. **D-043:** honour the proxy's **`X-Forwarded-Proto`** (`Secure` + `__Host-` cookies, HSTS); `trusted_proxies` = Zoraxy; **firewall `:8080` to Zoraxy only**. SPEC-003 §4/§6/§7/§9 amended (AC-7 · AC-10 · **AC-13**). | | 2026-10-15 | **D-043 follow-ups (rulings).** **B1 → accepted (add):** a **startup warning** when `base_url` is `https://` but `trusted_proxies` is empty/wrong — otherwise `X-Forwarded-Proto` is ignored and the session cookie is silently **not** `Secure`. **B2 accepted** — `X-Forwarded-Proto`'s leftmost token is correct for a single Zoraxy; a multi-proxy chain would need the same right→left walk as XFF. **B3 accepted** — the tunnel gate trusts any **local** connection (inherent to "local = trusted"; D-025). Deviations **D1–D5 accepted**, incl. D4 (no redirect code was needed — redirects are relative and absolute URLs come from `base_url`). **Deployment requirement:** Zoraxy **must set `X-Forwarded-For`** — the setup gate's "no XFF" test depends on it. | | 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. |