69 KiB
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:
- The path is the request. Identity lives in the path; the query carries only transient state.
- One door. The door identifies and routes; the handler authenticates and owns content.
- Identification ≠ authorisation. The GUID is a username (public, bookmarkable); the session cookie is the credential. A bookmark is never a login.
- The same three rules at every level: presence (empty → default / 404) · whitelist (known
values) ·
default:(a classifier — match a pattern, else 404). - Delete, don't redirect. Reserved names from one source.
- 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-processRequestContext. 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
GETmay carrydo/action(a read action); aPOSTmay be a plain path submit. Method-agnostic is robust. - Both doors resolve to the same tuple
(module, function, params): the router reads it fromPARAM[1..n], the handler fromdo/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. (authis retired — enrollment is GUID-driven.) PARAM2is a module registry (§4.6):home·request·approve·org·time·absence·reports.- Lists & filters:
…/request/open·…/request/closed·…/approve/open·…/approve/closedare path values (D-018), so a filtered view is a bookmarkable page, not query state. PARAM3whitelist underrequest=open·closed(views) andpto·equipment(new request forms); a number falls to thedefault: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:
/homematches at level 1 and never touches SQL. /{GUID}/homepasses thedefault: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, fromgo 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/actionmode). - 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.
timemode (D-030):presence(in/out/away) ortimesheet(exact hours) — an employment attribute on the user (core/hr), read by thetimemodule.
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
setupis a reservedPARAM1(always — so no user can ever be namedsetup).- The gate is the config toggle
setup(D-045). Three states:setup: true→/setupserves the wizard (the operator's deliberate switch);GET /points at it; every other route is 404.setup: falseand no admin → the app is LOCKED:GET /andGET /setupshow a boilerplate telling the operator to setsetup: trueand restart; every other route is 404. This state is not publicly claimable.- completion → the app writes
setup: falseand creates the admin → productive.
- The setup code is OPTIONAL (D-044 · D-045). When
setup_codeis 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 whilesetup: truefirst-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_adminchanges 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_idrelates 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_idships 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. |
| 2026-10-13 | SUPERSEDED by D-021/D-022 — no separate super account. | ||
| 2026-10-13 | SUPERSEDED by D-022 — the first user is the top admin. | ||
| 2026-10-13 | 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?" |
| 2026-10-13 | /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:
mainis 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.yamlis gitignored from commit one. - The agent git contract is
.opencode/git.env(gitignored);.opencode/git.env.exampleis 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(mode600). 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;slogwith 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. |