From f81dac988138659ce9d28f52d32cd3c29024b97b Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Tue, 13 Oct 2026 11:52:30 +0000 Subject: [PATCH] chore: bootstrap MyDay repository --- .gitignore | 29 ++ .opencode/git.env.example | 9 + PROJECT_PLAN.md | 605 +++++++++++++++++++++++++++ docs/specs/spec-001-git-bootstrap.md | 279 ++++++++++++ 4 files changed, 922 insertions(+) create mode 100644 .gitignore create mode 100644 .opencode/git.env.example create mode 100644 PROJECT_PLAN.md create mode 100644 docs/specs/spec-001-git-bootstrap.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06c21ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# --- secrets: never committed (D-010) --- +config.yaml +.env +.env.* +!.env.example +.opencode/git.env +*.token +*.pem +*.key + +# --- credential stores --- +.git-credentials + +# --- local runtime state --- +*.db +*.db-wal +*.db-shm +data/ + +# --- Go build output --- +/bin/ +myday +*.exe + +# --- editor / OS --- +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/.opencode/git.env.example b/.opencode/git.env.example new file mode 100644 index 0000000..d633a4b --- /dev/null +++ b/.opencode/git.env.example @@ -0,0 +1,9 @@ +# Agent git-workflow contract. Copy to .opencode/git.env (gitignored) and fill secrets. +GIT_URL=https://git.lohmar.co.uk/cclohmar/NextMyDay.git +GIT_TOKEN= # NEVER commit a real value +GIT_USER_NAME=Claus Lohmar +GIT_USER_EMAIL=claus.lohmar@lohmar.co.uk +REMOTE_ORIGIN=origin +DEFAULT_BRANCH=main +COMMIT_PREFIX=chore: +AUTO_STASH=true diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..b31cd09 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,605 @@ +# PROJECT_PLAN.md — MyDay + +> **Product:** MyDay — an employee time, absence & reporting app for **Sechpoint**. +> **Owner:** Claus (Sechpoint) · **Architect:** OpenArchitect +> **Created:** 2026-10-13 · **Status:** living document — updated as decisions are made. +> +> **How to read this:** §1–§2 say *what* MyDay is. §3–§7 describe *how it works*, in the order it +> is built: **routing → bootstrap/top admin → auth → onboarding & roles**. §8+ are reference tables. + +--- + +## 1. What MyDay is + +A **self-hosted, single-tenant** employee application delivered as a **PWA** (mobile + desktop). +It is *not* a multi-tenant SaaS. Each company runs its own instance with one data boundary. + +**Shape:** a workday-style employee tool covering **time & absence**, **reporting**, and the +**company org structure** those two depend on. + +**Modular:** a small core (**`MyDay.auth`** — identity · session · the door) plus registered +**modules** mounted at **`/{GUID}/{module}/…`** (§4.6). + +--- + +## 2. Scope + +| In scope | Notes | +|---|---| +| **Org structure** | users, the **manager hierarchy**, departments, positions | +| **Time** | clock in/out or timesheets, hours worked | +| **Absence** | leave requests, approvals, balances | +| **Reporting** | aggregations/rollups over the above | + +**Out of scope (for now):** payroll · recruiting/ATS · benefits · finance — the rest of Workday. + +### 2.1 Future / potential extensions (parked) + +- **AI expense processing** — receipts → structured expenses. +- **IDM for other products** — MyDay's identity (GUID · Passkey/TOTP · sessions · roles) as an + **identity provider (SSO/OIDC)** for other Sechpoint products. + +> Both are **out of current scope**. Recorded so the identity model is designed with them in mind +> (stable `user_id`, clean credentials, roles) — but **not built**. + +--- + +## 3. Guiding doctrine (adapted) + +MyDay adapts two documents from the GMS project — **guidelines, not scope**: **ROUTING.md** +(the URL/cascade model + the one-door front-controller) and **SECURITY.md** (a deferred-risk +register with explicit gates — *"the redesign will solve it"* is how bugs survive). + +Carried over: + +1. **The path is the request.** Identity lives in the path; the query carries only transient state. +2. **One door.** The door *identifies* and routes; the handler *authenticates* and owns content. +3. **Identification ≠ authorisation.** The GUID is a *username* (public, bookmarkable); the + **session cookie is the credential**. A bookmark is never a login. +4. **The same three rules at every level:** presence (empty → default / 404) · whitelist (known + values) · `default:` (a **classifier** — match a pattern, else 404). +5. **Delete, don't redirect.** Reserved names from one source. +6. **Security debt is explicit and gated.** + +> **Divergence from GMS:** GMS threads context between *separate processes* via `X-*-*` headers. +> MyDay is **one binary** — the hand-over is an in-process `RequestContext`. Discipline identical; +> field names kept so a future split is mechanical. + +--- + +## 4. The routing concept + +A **dual-mode door** over a **path cascade**. One request resolves to one context — which +principal, which page or action — and then a handler executes it. + +### 4.1 The dual dispatch (level 1) — two doors, one destination + +``` +DOOR (top level) + parse: method · path · query · body + ├─ carries routing fields? (do / action) + │ NO → ROUTER : explode path → PARAM[] → cascade (§4.2) + │ YES → HANDLER : do = {module} · action = {function} (§4.3) + └─ resolve identity (GUID) + session + role gate + ↓ + (module, function, params) → the module's function +``` + +- The branch is keyed on the **routing fields, not the HTTP method** — a `GET` may carry + `do`/`action` (a read action); a `POST` may be a plain path submit. Method-agnostic is robust. +- Both doors resolve to the **same tuple** `(module, function, params)`: the router reads it from + `PARAM[1..n]`, the handler from `do`/`action`. One registry, one set of module functions. +- The **identity / session / role gate runs before** the module function — a module never sees a + request it shouldn't. + +### 4.2 The path cascade + +``` +/{domain}/ → 302 → /home (the ONE canonical redirect) +/{domain}/home → neutral landing page (public, no user) +/{domain}/access → password / access recovery +/{domain}/setup → the bootstrap wizard (setup mode · loopback only) +/{domain}/api | static | media | ws | dev → reserved infrastructure +/{domain}/holy-shit → 404 (unknown · not reserved · not a GUID) + +/{domain}/{GUID} → GUID lookup → user + not found → 404 + found · NOT enrolled → ENROLL (mandatory TOTP + Passkey) + found · enrolled · no session → LOGIN (Passkey, or TOTP) + found · enrolled · session → /{GUID}/home + +/{GUID}/home → the user landing page +/{GUID}/request → list MY requests (all) +/{GUID}/request/open → list · OPEN view +/{GUID}/request/closed → list · CLOSED view +/{GUID}/request/pto → a new PTO request (form) +/{GUID}/request/equipment → a new equipment request (form) +/{GUID}/request/{request_id} → my request detail (classifier: id → lookup → 404) +/{GUID}/approve → list requests to approve (all) +/{GUID}/approve/open → list · OPEN view +/{GUID}/approve/closed → list · CLOSED view +/{GUID}/approve/{request_id} → view + act on a request (classifier: id → lookup → 404) +``` + +- The **GUID is always visible** in every authenticated path — identity never leaves the URL. +- **Reserved `PARAM1` = `home · access · setup · api · static · media · ws · dev`** — one source + constant; a GUID can never equal a reserved name. *(`auth` is retired — enrollment is GUID-driven.)* +- **`PARAM2` is a module registry** (§4.6): `home` · `request` · `approve` · `org` · `time` · `absence` · `reports`. +- **Lists & filters:** `…/request/open` · `…/request/closed` · `…/approve/open` · `…/approve/closed` + are **path values** (D-018), so a filtered view is a **bookmarkable page**, not query state. +- **`PARAM3` whitelist under `request`** = `open` · `closed` (views) and `pto` · `equipment` (new + request forms); a **number** falls to the `default:` classifier (id); anything else → 404. + +### 4.3 The action mode — two routing fields (`do` + `action`) + +A mutation carries **two routing fields**: `do` selects the **module**, `action` the **function**. + +``` +POST /{GUID}?do={module}&action={function}&… (a module action) + ├─ do=hr & action=save-data + ├─ do=time & action=clock-in + └─ do=requests & action=submit +POST /access?do=auth&action=request (recovery request) +``` + +- **Both fields are whitelisted** — `do` → the module registry (unknown → 404); `action` → the + module's action set (unknown → 404). The same three rules as the path. +- The **GUID still rides the path** (identity is never a form field); form **parameters** are + separate fields/query. +- **Not a security control.** Hiding the action in a field is *obscurity*; every action still + requires **session auth + authorisation (ownership / role) + CSRF** (A8). + +### 4.4 Worked example — why `home` works at two levels + +**It is two switches, not one tag.** The *level* decides which switch runs. + +``` +LEVEL 1 switch (PARAM1) + "" → 302 /home + "home" → neutral landing ← "home" is a CASE HERE + "access" → recovery + "setup" → the bootstrap wizard (setup mode only) + "api"|"static"|"media"|"ws"|"dev" → infra + default: user = SQL lookup(PARAM1) + not found → 404 + not enrolled → ENROLL + no session → LOGIN + else → LEVEL 2 (GUID context established) + +LEVEL 2 switch (PARAM2) — reached ONLY inside a resolved branch + # GUID branch: + "" → /{GUID}/home (the branch default) + "home" → user landing ← "home" is a CASE HERE TOO + "request" → LEVEL 3 (open | closed | pto | equipment | {id}) + "approve" → LEVEL 3 (open | closed | {id}) + default: → 404 +``` + +- Reserved cases are checked **before** the DB: `/home` matches at level 1 and never touches SQL. +- `/{GUID}/home` passes the `default:` classifier first (SQL lookup → user) → enters level 2 → + `PARAM2 = "home"` → the **user's** landing. +- **The enroll/login gate sits between level 1 and level 2** for path requests — a GUID branch is + never dispatched to level 2 unauthenticated. + +### 4.5 The GUID lifecycle (enrolled Y/N) + +``` +a user is created → identity minted (status: pending_enrollment) + GUID + → the invite carries /{GUID} + → the enrollment window is BOUNDED (TTL); an un-enrolled GUID is a bearer (A1) + +GET /{GUID} → not enrolled → mandatory enroll (Passkey + TOTP) → status: active + → enrolled → Passkey assertion (TOTP fallback) → session → /{GUID}/home + +recovery → NEW GUID minted · OLD identity revoked + GUID tombstoned (never recycled) +``` + +### 4.6 The module registry — `/{GUID}/{module}/{function}/{param}` + +The `PARAM2` switch is a **registry of modules**, not a hard-coded list. Everything the app can do +hangs off a module: + +``` +/{GUID}/{module}/{function}/{param…} + core registry owned by the module +``` + +- **`PARAM2` = the module** — a registered name (whitelist); unknown → 404. +- **`PARAM3+` = the module's own cascade** — the same three rules, one level down. The core never + parses a module's internals. +- **The core enforces auth / session / role *before* dispatch** — a module never sees an + unauthenticated request. +- Modules are named **`MyDay.`** (`MyDay.auth`, `MyDay.time`, …). + +| | Owns | +|---|---| +| **The core (`MyDay.auth` + the door)** | routing cascade · the registry · identity/GUID · sessions · Passkey/TOTP · roles + the chain · **user management** · audit · CSRF · config | +| **A module** | its routes (functions) · its handlers · **its own tables** · its templates/assets | + +- A module depends only on **core interfaces — never on another module directly**. +- A module may contribute to shared extension points: nav entries, **request types**, approval + handlers, report sources. +- **Planned modules:** see the catalogue in **§4.8**. + +### 4.7 Drop-in modules & discovery + +**A module is a directory.** `internal/modules//` — self-contained: routes, handlers, **its +own migrations**, templates. Drop it in, and it joins the registry. + +**Discovery is at BUILD time, not runtime.** Go has no safe runtime plugin loading (the `plugin` +package needs an exact toolchain match, is Linux-only, and effectively CGO). So, following the +**Caddy** pattern: + +- each module **self-registers** into the core registry from its package `init()`; +- a generated aggregator (`internal/modules/imports_gen.go`, from `go generate`) blank-imports every + module directory — so **the directory is the source of truth**; +- adding a module = add the directory → `go generate` → rebuild. **No core edits.** + +**Guardrails:** + +- **Deterministic order** — modules register in a defined sequence (never map iteration); migrations + and route precedence depend on it. +- **Log the loaded module set at startup** — a visible registry beats hidden magic. +- **A module owns its tables**; migrations come from the module, versioned, applied in order. +- A module may expose an optional `Start(ctx)` for background work (**integration wrappers**, sync). +- A module registers **both** its **routes** (path mode) and its **actions** (`do` / `action` mode). +- **Module manifest** (`app.go`): the module declares its **name**, whether it is **required**, its + **migrations**, and the extension points it contributes. +- **Enable / disable:** optional modules can be switched off from a core **Modules** page + (config-backed, D-038). Disabling **unmounts routes + hides nav — it never deletes data**. + +### 4.8 The module catalogue (provisional) + +Two kinds: the **core** (not a module) and **modules**. + +| Module | Kind | Owns | +|---|---|---| +| **core `MyDay.auth`** | core | identity · sessions · Passkey/TOTP · roles · the chain · the door · the registry · **user management** | +| `org` | records | departments · positions · the richer hierarchy | +| `time` | domain | **presence + hours — one module.** A per-user **mode**: `presence` (in/out/away) or `timesheet` (exact hours, pay-by-hour). Optional **geo check-in** (D-031). | +| `absence` | domain | PTO · leave balances | +| `requests` | workflow | the **issue/ticket engine + approve queue** (pluggable request types) — the "must-have" | +| `hr` | records | simple HR records | +| `expense` | domain | expense claims · receipts | +| `reports` | analytical | read model · rollups | +| `integrations` | integration | wrappers to external apps (later; may need `Start()`) | + +**Extension points** a module can contribute to: request types (`requests`), nav entries, dashboard +widgets, report sources. + +- **Location (optional, D-031):** **no tracking, no geofencing** — the audit log simply records an + approximate **location derived from the client IP**. A VPN defeats it, and that's accepted. +- **`time` mode (D-030):** `presence` (in/out/away) or `timesheet` (exact hours) — an **employment + attribute** on the user (core/`hr`), read by the `time` module. + +> **Naming note:** *user management belongs to the **core**, not a module* — it manages identities, +> roles, and the chain, which the core owns. So "management" decomposes into **core (users)** + +> **`requests` (tickets/approvals)** + **`hr` (records)**. + +--- + +## 5. Bootstrap & the top admin + +**There is no separate "super" account.** The **first user is the top admin** — a normal user with +the highest role, the **root of the chain**. When the `users` table is empty, the instance is in +**setup mode**. + +### 5.1 First run — the `/setup` wizard + +``` +fresh deploy → config.setup: true → 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//`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. | +| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). | +| **D-030** | 2026-10-13 | **`presence` and `time` unify into one module (`time`).** A per-user **mode** decides the flow: `presence` (simple in/out/away) or `timesheet` (exact hours, pay-by-hour). | The mode is an **employment attribute** on the user (core/`hr`), read by the module. `presence` becomes a *view*, not a module. | +| **D-031** | 2026-10-13 | **Location = audit only. No tracking, no geofencing.** The audit log records an approximate **location derived from the client IP**. Off by default; a VPN defeats it (accepted). | Deliberately shallow — enough for a security trail, nowhere near geo-tracking. | +| **D-032** | 2026-10-13 | **Actions carry TWO routing fields: `do={module}` + `action={function}`** (both whitelisted). The GUID still rides the **path**. | The PHP-style simplicity for module dispatch (`do=hr&action=save-data`). Modules register routes **and** actions. | +| **D-033** | 2026-10-13 | **Admin recovery: a config approval flag emails the enrolment link to user 1's address on record.** Without the flag, **nothing happens**. | **Dual control** — the operator's config half + the admin's inbox half. Refines D-023. | +| **D-034** | 2026-10-13 | **Roles: simple tiers, NO ACL, ONE role per user.** `admin > director > manager > team`; the assigned role **ripples down** — it implies all lower roles. *(No tick-boxes.)* | KISS — basic levels only, no permission matrix. The **chain** (`manager_user_id`) stays separate from roles. | +| **D-035** | 2026-10-13 | **Settings live in the CONFIG FILE** (infra · secrets · domain · mail · locale). The **DB holds operational data only**. | Owner's call: editing a setting in the file beats a DB migration. Caveat: the app must be able to write `config.yaml` (or the wizard prints the values for the operator). | +| **D-036** | 2026-10-13 | **Layout: separated dirs, one tree** — `internal/core/` (the door · registry · identity · DB) and `internal/modules//` (**self-contained vertical slices**: routes · actions · handlers · queries · migrations · templates). | The boundary becomes **physical**, and each module stays truly drop-in. Rejected: one flat dir. | +| **D-037** | 2026-10-13 | **Every module has the same default skeleton** — `app.go` (the manifest: name · required · migrations · nav · `Start`) · `routing.go` (routes + actions) · `handlers.go` (the action handlers). More files as needed. | Predictability + drop-in: every module reads the same way. | +| **D-038** | 2026-10-13 | **Modules are enabled/disabled from a core Modules page** (state in config, D-035). Disabling unmounts routes + hides nav; **it never deletes data**. **Required** modules can't be disabled. | Runtime activation of compiled-in modules. Toggles are **audited** (A11). | +| **D-039** | 2026-10-13 | **Two level-1 entry points — ROUTER (path) and HANDLER (actions) — both resolve to `(module, function, params)`.** The branch is keyed on the **routing fields** (`do`/`action`), **not** the HTTP method. Identity + role gate run **before** the module function. | The top-level dispatch, made explicit: **two doors, one destination.** | + +--- + +## 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.** | + +--- + +## 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."* + +| # | 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 | open | +| A3 | `/{GUID}` and `/access` rate-limited; **generic** responses (no enumeration/no timing oracle) | HIGH | open | +| 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 | open | +| A6 | Trusted-proxy client IP — XFF **set**, not appended | MED | open | +| 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 | open | +| A9 | TOTP secret **encrypted at rest** (key from config) | HIGH | open | +| 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 | +| 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)**; rate-limited; `/setup` → 404 once productive | HIGH | designed (D-025/D-026) | +| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open | + +--- + +## 11. Milestones (provisional) + +| # | Milestone | Depends on | Notes | +|---|---|---|---| +| **Prep** | **Repo bootstrap** — `git init` · `.gitignore` · `config.example.yaml` · **`AGENT.md`** · `README.md` · commit workflow | — | **planning artefact — done first** | +| **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | *starting after Prep* | +| **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 its own slices**; if a decision changes, **this plan is updated in the same change**. +- **Secrets are never committed** — `config.yaml` is gitignored from commit one. + +## 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/`; 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.** | diff --git a/docs/specs/spec-001-git-bootstrap.md b/docs/specs/spec-001-git-bootstrap.md new file mode 100644 index 0000000..c843a8a --- /dev/null +++ b/docs/specs/spec-001-git-bootstrap.md @@ -0,0 +1,279 @@ +# SPEC-001 — Git connection bootstrap (remote `origin` → Gitea) + +| Field | Value | +|---|---| +| **Status** | Ready for implementation | +| **Created** | 2026-10-13 | +| **Author** | OpenArchitect | +| **Target milestone** | **Prep** (first slice) — see `PROJECT_PLAN.md` §11 | +| **Governing doctrine** | §12 / §12.2 · **D-010** (secrets never committed) · **D-036** (layout) | +| **Related spec** | *(none)* | + +--- + +## 1. Goal + +Make `/root/myday` a git repository whose `main` branch is **connected to and verified against** +the Gitea remote `NextMyDay`, with a repeatable, secret-safe push/pull path. + +**Definition of done:** a clean `git push -u origin main` succeeds and the commit is visible in +Gitea, with **no secret present in `.git/config`, in the commit, or in `git remote -v`.** + +### 1.1 Non-goals (explicitly deferred) + +These are **later Prep slices** and must NOT be built here: + +- `AGENT.md`, `README.md`, `config.example.yaml` +- `docs/` restructuring, `docs/specs/` index +- CI (build + test on push) +- Feature-branch workflow automation +- The Go project skeleton (`cmd/myday/`, `internal/core/`, `internal/modules/`) + +--- + +## 2. Environment facts (discovery, verified 2026-10-13) + +| Fact | Evidence | +|---|---| +| `/root/myday` is **not** a git repo | no `.git/`; directory holds only `PROJECT_PLAN.md` | +| Remote host is reachable and is **Gitea 1.22.0** | `GET /api/v1/version` → `9.0.2+gitea-1.22.0` | +| Remote repo is **empty** (no commits) | confirmed by operator | +| **No git identity configured** | `/root/.gitconfig` does not exist | +| No `.opencode/` directory exists in the project yet | `ls /root/myday` | + +--- + +## 3. Architectural decisions introduced by this spec + +| ID | Decision | Rationale | +|---|---|---| +| **A-01** | Canonical remote is `https://git.lohmar.co.uk/cclohmar/NextMyDay.git`. The truncated `GIT_URL` originally supplied (`…/cclohmar/`) is **discarded** — an org root is not a valid remote. | Single source of truth for the remote; prevents a silent misconfiguration. | +| **A-02** | The token lives in a **credential store outside the repo** (`~/.git-credentials`, mode `600`). The remote URL is **clean**. | The token must never enter `.git/config`, `git remote -v`, shell history, commit objects, or error output. Embedding it in the URL (rejected) leaks through all five. | +| **A-03** | `AUTO_STASH="true"` is realised as git-native config: `pull.rebase=true` + **`rebase.autoStash=true`**. No custom script. | `rebase.autoStash` *is* the platform feature for this requirement. Prefer native over bespoke. | +| **A-04** | **Two-tier configuration.** Git-native settings (identity, remote, branch, pull behaviour) → `.git/config`. Agent-workflow variables (`COMMIT_PREFIX`, `AUTO_STASH`, …) → `.opencode/git.env` (**gitignored**) with a committed `.opencode/git.env.example`. | Git is authoritative for what git owns; automation knobs are not git's business. Keeps git-native state discoverable via `git config --list`. | +| **A-05** | `user.name` / `user.email` are set **repo-local only**, never `--global`. | A sandbox-hosted agent must not mutate machine-wide identity. | + +> **Open decision for the plan (raise as O-12):** A-04 introduces `.opencode/git.env` as a +> convention. `PROJECT_PLAN.md` does not yet define where agent-workflow variables live. +> This spec assumes `.opencode/`. Confirm before M0 so the convention is documented once. + +--- + +## 4. Configuration contract + +The canonical variable contract. **Committed** column states whether the *value* may ever be +committed. + +| Variable | Value | Materialises as | Committed? | +|---|---|---|---| +| `GIT_URL` | `https://git.lohmar.co.uk/cclohmar/NextMyDay.git` | `remote.origin.url` (clean, no token) | ✅ (URL only) | +| `GIT_TOKEN` | *(secret)* | `~/.git-credentials` via `credential.helper=store` | ❌ **never** | +| `GIT_USER_NAME` | `Claus Lohmar` ⚠️ *assumed — confirm* | `.git/config` `user.name` | ✅ | +| `GIT_USER_EMAIL` | `claus.lohmar@lohmar.co.uk` | `.git/config` `user.email` | ✅ | +| `REMOTE_ORIGIN` | `origin` | `remote.` | ✅ | +| `DEFAULT_BRANCH` | `main` | `HEAD` / `init.defaultBranch` | ✅ | +| `COMMIT_PREFIX` | `chore:` | commit-message convention (Conventional Commits, §12.2) | ✅ | +| `AUTO_STASH` | `true` | `pull.rebase=true` + `rebase.autoStash=true` | ✅ | + +--- + +## 5. Structural changes + +### 5.1 Files to CREATE — inside the repo (committed) + +| Path | Purpose | +|---|---| +| `.gitignore` | **D-010 gate.** Must exist **before the first `git add`**. Excludes `config.yaml`, credentials, DB files, build output. | +| `.opencode/git.env.example` | Documents the agent-workflow variable contract with placeholder secrets. | +| `docs/specs/spec-001-git-bootstrap.md` | This spec (already written). | + +**`.gitignore` — initial content (minimal, expand later):** + +```gitignore +# --- secrets: never committed (D-010) --- +config.yaml +.env +.env.* +!.env.example +.opencode/git.env +*.token +*.pem +*.key + +# --- credential stores --- +.git-credentials + +# --- local runtime state --- +*.db +*.db-wal +*.db-shm +data/ + +# --- Go build output --- +/bin/ +myday +*.exe + +# --- editor / OS --- +.DS_Store +.idea/ +.vscode/ +*.swp +``` + +**`.opencode/git.env.example` — content:** + +```dotenv +# Agent git-workflow contract. Copy to .opencode/git.env (gitignored) and fill secrets. +GIT_URL=https://git.lohmar.co.uk/cclohmar/NextMyDay.git +GIT_TOKEN= # NEVER commit a real value +GIT_USER_NAME=Claus Lohmar +GIT_USER_EMAIL=claus.lohmar@lohmar.co.uk +REMOTE_ORIGIN=origin +DEFAULT_BRANCH=main +COMMIT_PREFIX=chore: +AUTO_STASH=true +``` + +### 5.2 Files to CREATE — outside the repo (never committed) + +| Path | Mode | Purpose | +|---|---|---| +| `/root/.git-credentials` | **`600`** | Holds `https://:@git.lohmar.co.uk`. | +| `/root/myday/.opencode/git.env` | **`600`** | Local copy of the contract with the real token. | + +### 5.3 Files MODIFIED via `git config` (not hand-edited) + +`.git/config` gains: `user.name`, `user.email`, `push.default`, `pull.rebase`, +`rebase.autoStash`, `remote.origin.*`, `branch.main.*`. +`/root/.gitconfig` gains **only** `credential.helper=store` (the sole global write, justified). + +### 5.4 Untouched + +`PROJECT_PLAN.md` — **not** modified by this spec. (Any plan change, e.g. logging O-12, is a +separate `docs:` commit.) + +--- + +## 6. Developer implementation spec + +--- +### 🏗️ DEVELOPER IMPLEMENTATION SPEC +**Target Module:** `/root/myday` (repository root) +**Pattern:** Bootstrap / idempotent provisioning script +**Structural Changes:** +- Create `.gitignore` **before any staging** (D-010 gate) +- Create `.opencode/git.env.example` (committed) and `.opencode/git.env` (`600`, gitignored) +- Create `/root/.git-credentials` (`600`, outside repo) +- Initialise repo, set repo-local identity, add clean remote, first commit, push `-u` + +**Contract/API:** the variable table in §4. +**Instructions for the builder:** +1. Run steps 6.1 → 6.6 in order. **Stop and report** on any failed assertion. +2. Do **not** set `user.name`/`user.email` globally. Do **not** put the token in the remote URL. +3. Do **not** force-push. Do **not** touch `PROJECT_PLAN.md`. +--- + +#### 6.1 Create the guard files FIRST + +```bash +cd /root/myday +mkdir -p docs/specs .opencode +# write .gitignore and .opencode/git.env.example exactly as specified in §5.1 +chmod 600 .opencode/git.env # after writing the real values +``` + +#### 6.2 Initialise the repository + +```bash +cd /root/myday +git init -b main # git >= 2.28 +# fallback if `-b` is unsupported: +# git init && git symbolic-ref HEAD refs/heads/main + +git config --local user.name "Claus Lohmar" +git config --local user.email "claus.lohmar@lohmar.co.uk" +git config --local push.default simple +git config --local pull.rebase true +git config --local rebase.autoStash true # A-03: this is AUTO_STASH="true" +``` + +#### 6.3 Credential store (token never touches the repo) + +```bash +umask 077 +printf 'https://%s:%s@git.lohmar.co.uk\n' "cclohmar" "$GIT_TOKEN" > /root/.git-credentials +chmod 600 /root/.git-credentials +git config --global credential.helper store +``` + +#### 6.4 Remote (clean URL) + +```bash +cd /root/myday +git remote add origin https://git.lohmar.co.uk/cclohmar/NextMyDay.git +git remote -v +# ASSERT: output contains no "@" character +``` + +#### 6.5 First commit — asserted secret-free + +```bash +cd /root/myday +git add -A +git status --porcelain +# ASSERT: no config.yaml, no .env, no .opencode/git.env, no *.db, no .git-credentials +git check-ignore -v config.yaml .opencode/git.env # ASSERT: both are ignored +git commit -m "chore: bootstrap MyDay repository" +``` + +#### 6.6 Push and verify + +```bash +cd /root/myday +git push -u origin main +git ls-remote --heads origin main # ASSERT: main present on the remote +git branch -vv # ASSERT: main tracks origin/main +git log --oneline -1 +``` + +**Acceptance criteria** + +| # | Criterion | +|---|---| +| AC-1 | `git remote get-url origin` → `https://git.lohmar.co.uk/cclohmar/NextMyDay.git` (**no** credentials) | +| AC-2 | `git push -u origin main` succeeded; `main` visible in Gitea | +| AC-3 | `git log --oneline -1` → subject starting `chore:` (honours `COMMIT_PREFIX`) | +| AC-4 | `git ls-files` contains `.gitignore`, `.opencode/git.env.example`, `docs/specs/spec-001-git-bootstrap.md`, `PROJECT_PLAN.md` | +| AC-5 | `git ls-files` contains **no** `config.yaml`, `.env`, `.opencode/git.env`, `*.db` | +| AC-6 | `git config --local pull.rebase` → `true`; `rebase.autoStash` → `true` | +| AC-7 | `stat -c '%a' /root/.git-credentials` → `600` | + +**Rollback** + +```bash +cd /root/myday +git remote remove origin +rm -rf /root/myday/.git +rm -f /root/.git-credentials /root/myday/.opencode/git.env +git config --global --unset credential.helper +``` + +--- + +## 7. Security notes + +- **The supplied token was transmitted in plaintext and is therefore considered + compromised.** Rotate it in Gitea (`/user/settings/applications`) once the connection is + verified, then update `/root/.git-credentials`. Prefer a **scoped** token (repo write only). +- `.git-credentials` stores the token in **plaintext** on disk. Acceptable here (single-tenant, + root-owned, `600`). A future hardening step is a keyring/secret-store helper — out of scope. +- The `600` mode is the only protection; verify it (AC-7). +- Consider replacing token-over-HTTPS with an **SSH deploy key** before CI writes to this repo. + +## 8. Follow-ups (not in this spec) + +1. Log **A-04 / O-12** (where agent-workflow variables live) into `PROJECT_PLAN.md` — separate `docs:` commit. +2. Confirm `GIT_USER_NAME` (currently assumed `Claus Lohmar`). +3. Remaining Prep artefacts: `AGENT.md`, `README.md`, `config.example.yaml`, `docs/` layout. +4. Rotate the exposed token (§7).