REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s, M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the reason), and each fix is tied to its code and test. - SPEC-003: the door pipeline (XFF right→left, the per-request setup gate, the isolated hardening headers, the pinned setup module), §6 the `__Host-` cookie and the startup-generated signing keys, §7 loopback enforcement per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new §11 that maps every REVIEW-001 finding to its fix. - SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong (the wizard's loopback + module pin, the nudge's configured-origin signal, the async /access notification ruled in). - PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin); A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15 now enforced by this pass; A11 left open (L4c). §14 change log entries. - README: the status said "planning / pre-alpha · nothing is built" — it is stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart adopts the generated secrets, and state the per-request loopback rule.
642 lines
56 KiB
Markdown
642 lines
56 KiB
Markdown
# 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.<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 → the app serves ONLY /setup (loopback-only, D-025)
|
||
Step 1 the domain / base URL
|
||
Step 2 the top admin — name, email, Passkey + TOTP enrollment (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`).
|
||
- In setup mode, `/setup` is the **only served route**, and it is **loopback-only** (D-025).
|
||
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator runs it once
|
||
over an **SSH tunnel** to `127.0.0.1:8080`, 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** | 2026-10-13 | **The bootstrap is loopback-only, enforced.** While in **setup mode** the app **refuses to serve a non-loopback address**; the operator reaches it over an **SSH tunnel** to `127.0.0.1:8080`. Optional `{bootstrap_guid}` = defence-in-depth. | Closes first-visitor-wins **in code**, not only in the README. |
|
||
| **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 · loopback-only** — 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. |
|
||
|
||
---
|
||
|
||
## 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): **loopback-only, enforced** · SSH-tunnel first step · optional `bootstrap_guid`. |
|
||
| 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` served **only** in setup mode; **loopback-only, enforced per request (H2)**; the `setup` module **pinned at the door (M1c/L3s)**; rate-limited; `/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; bootstrap over 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.** |
|