NextMyDay/PROJECT_PLAN.md

664 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (the `setup` toggle, D-045)
/{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.<module>`** (`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/<name>/` — 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` serves the WIZARD
Step 1 the domain / base URL
Step 2 the top admin — name, email, **TOTP** enrollment (the passkey is added at first
sign-in, D-041) (user_id = 1, role: admin)
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 gate is the config toggle `setup` (D-045).** Three states:
- **`setup: true`** → `/setup` serves the **wizard** (the operator's deliberate switch); `GET /`
points at it; every other route is 404.
- **`setup: false` and no admin** → the app is **LOCKED**: `GET /` and `GET /setup` show a
**boilerplate** telling the operator to set `setup: true` and restart; every other route is 404.
**This state is not publicly claimable.**
- **completion** → the app writes **`setup: false`** and creates the admin → productive.
- **The setup code is OPTIONAL (D-044 · D-045).** When `setup_code` is set it is a **second factor**
(the code form comes first; single-use · rate-limited · redacted — A8). Empty ⇒ the toggle alone
admits. *(D-044's generated-code banner, the peer/tunnel gate D-025, and the bare 404 are retired.)*
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator enables setup and
completes it, then exposes the instance.
- *README: do NOT expose MyDay publicly before the first admin exists; set `setup_code` (or complete
setup immediately) because while `setup: true` first-visitor-wins is possible (D-045 residual).*
### 5.2 Admin recovery — a config-gated enrolment link
```
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).~~ | **SUPERSEDED by D-045** — the peer/tunnel gate is retired; the gate is the `setup` toggle (the **setup code** stays as an optional second factor). |
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **only while the `setup` toggle is on (D-045)** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
| **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. |
| **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. |
| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). |
| **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/<name>/` (**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. **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. |
---
## 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-045): the gate is the **`setup` config toggle**; the **setup code** (D-044) is an **optional** second factor. The peer/tunnel rule (D-025) is **superseded**; the boilerplate replaces the bare 404. **Residual:** while `setup: true`, first-visitor-wins unless `setup_code` is set. |
| O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. |
| O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. |
| O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** |
| 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 while the **`setup` toggle** is on (D-045); `setup: false` **and no admin** ⇒ a **locked boilerplate** at `/` and `/setup` (never a bare 404), every other route 404; when **`setup_code` is set** the code form comes first (D-044) — the code **single-use** and **rate-limited** (A3), the unlock **redacted** (A8); the `setup` module **pinned at the door (M1c/L3s)**; `/setup` → 404 once productive. **Residual:** while `setup: true`, first-visitor-wins unless `setup_code` is set. | HIGH | **enforced** |
| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open |
| A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | **enforced** — signing keys generated at startup in setup mode (M1s) |
---
## 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; enable `setup: true` and (optionally) set `setup_code` to claim the wizard.** |
| **`docs/`** | this plan at the root; specs later in `docs/specs/`. |
| **CI (later)** | build + test on push. |
### 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. |
| 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**. |
| 2026-10-15 | **SPEC-010 ✅ IMPLEMENTED → M0.2 (the admin & account UI).** The live instance had **no nav, no way to add a user and no way to add a passkey** — the services existed, the UI did not. New **`internal/core/account`**: the **account page** (`GET /{GUID}/account` — passkey count · TOTP enrolled · an **Add a passkey** button that **LINKS** to the existing D-041 screen, so the WebAuthn ceremony is never duplicated · **Don't ask again** writing `users.passkey_opt_out`) and the **admin Users page** (`GET /{GUID}/users` — list name·email·role·manager·identity status; **create** with only roles **below** the creator (D-019) and the manager defaulting to the creator → `users.Create` + `identities.Mint` + **email the `/{GUID}` invite** (the mailer) and **show the link** for copy/paste). The **shell** now renders the **role-gated core nav** (Home · Account for everyone; **Users · Modules for an admin** — the registry entries ride alongside, the discovered `home` collapsed onto the core Home) and, while the signed-in user has **no passkey, has not opted out and the origin is not loopback**, a prominent **Add-a-passkey banner** — closing the D-041 gap where the wizard's **direct session mint bypasses the TOTP-login nudge** and the admin was never prompted. The door computes `(admin, banner)` in `serveGUID` (it already holds the session/identity/user) and attaches it to the request context; `web.PageFor` copies it onto every authenticated page (`RequestContext` untouched). Role gate: `/users` + `users.create` require **admin** (`server.Tiers`). Additive: `auth.Identities.TOTPEnrolled` · `users.Users.List` · `module.CoreDispatchNames` += `account·users` · `server.Deps.Credentials`. AC-1…AC-7 green (`build` · `vet` · `test -race` — all packages) · **static CGO-free** binary · `routing` purity intact · **no inline scripts**. Deviations D-1…D-5 in SPEC-010 §9. |