chore: bootstrap MyDay repository
This commit is contained in:
commit
f81dac9881
4 changed files with 922 additions and 0 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
9
.opencode/git.env.example
Normal file
9
.opencode/git.env.example
Normal file
|
|
@ -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
|
||||
605
PROJECT_PLAN.md
Normal file
605
PROJECT_PLAN.md
Normal file
|
|
@ -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.<module>`** (`MyDay.auth`, `MyDay.time`, …).
|
||||
|
||||
| | Owns |
|
||||
|---|---|
|
||||
| **The core (`MyDay.auth` + the door)** | routing cascade · the registry · identity/GUID · sessions · Passkey/TOTP · roles + the chain · **user management** · audit · CSRF · config |
|
||||
| **A module** | its routes (functions) · its handlers · **its own tables** · its templates/assets |
|
||||
|
||||
- A module depends only on **core interfaces — never on another module directly**.
|
||||
- A module may contribute to shared extension points: nav entries, **request types**, approval
|
||||
handlers, report sources.
|
||||
- **Planned modules:** see the catalogue in **§4.8**.
|
||||
|
||||
### 4.7 Drop-in modules & discovery
|
||||
|
||||
**A module is a directory.** `internal/modules/<name>/` — self-contained: routes, handlers, **its
|
||||
own migrations**, templates. Drop it in, and it joins the registry.
|
||||
|
||||
**Discovery is at BUILD time, not runtime.** Go has no safe runtime plugin loading (the `plugin`
|
||||
package needs an exact toolchain match, is Linux-only, and effectively CGO). So, following the
|
||||
**Caddy** pattern:
|
||||
|
||||
- each module **self-registers** into the core registry from its package `init()`;
|
||||
- a generated aggregator (`internal/modules/imports_gen.go`, from `go generate`) blank-imports every
|
||||
module directory — so **the directory is the source of truth**;
|
||||
- adding a module = add the directory → `go generate` → rebuild. **No core edits.**
|
||||
|
||||
**Guardrails:**
|
||||
|
||||
- **Deterministic order** — modules register in a defined sequence (never map iteration); migrations
|
||||
and route precedence depend on it.
|
||||
- **Log the loaded module set at startup** — a visible registry beats hidden magic.
|
||||
- **A module owns its tables**; migrations come from the module, versioned, applied in order.
|
||||
- A module may expose an optional `Start(ctx)` for background work (**integration wrappers**, sync).
|
||||
- A module registers **both** its **routes** (path mode) and its **actions** (`do` / `action` mode).
|
||||
- **Module manifest** (`app.go`): the module declares its **name**, whether it is **required**, its
|
||||
**migrations**, and the extension points it contributes.
|
||||
- **Enable / disable:** optional modules can be switched off from a core **Modules** page
|
||||
(config-backed, D-038). Disabling **unmounts routes + hides nav — it never deletes data**.
|
||||
|
||||
### 4.8 The module catalogue (provisional)
|
||||
|
||||
Two kinds: the **core** (not a module) and **modules**.
|
||||
|
||||
| Module | Kind | Owns |
|
||||
|---|---|---|
|
||||
| **core `MyDay.auth`** | core | identity · sessions · Passkey/TOTP · roles · the chain · the door · the registry · **user management** |
|
||||
| `org` | records | departments · positions · the richer hierarchy |
|
||||
| `time` | domain | **presence + hours — one module.** A per-user **mode**: `presence` (in/out/away) or `timesheet` (exact hours, pay-by-hour). Optional **geo check-in** (D-031). |
|
||||
| `absence` | domain | PTO · leave balances |
|
||||
| `requests` | workflow | the **issue/ticket engine + approve queue** (pluggable request types) — the "must-have" |
|
||||
| `hr` | records | simple HR records |
|
||||
| `expense` | domain | expense claims · receipts |
|
||||
| `reports` | analytical | read model · rollups |
|
||||
| `integrations` | integration | wrappers to external apps (later; may need `Start()`) |
|
||||
|
||||
**Extension points** a module can contribute to: request types (`requests`), nav entries, dashboard
|
||||
widgets, report sources.
|
||||
|
||||
- **Location (optional, D-031):** **no tracking, no geofencing** — the audit log simply records an
|
||||
approximate **location derived from the client IP**. A VPN defeats it, and that's accepted.
|
||||
- **`time` mode (D-030):** `presence` (in/out/away) or `timesheet` (exact hours) — an **employment
|
||||
attribute** on the user (core/`hr`), read by the `time` module.
|
||||
|
||||
> **Naming note:** *user management belongs to the **core**, not a module* — it manages identities,
|
||||
> roles, and the chain, which the core owns. So "management" decomposes into **core (users)** +
|
||||
> **`requests` (tickets/approvals)** + **`hr` (records)**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bootstrap & the top admin
|
||||
|
||||
**There is no separate "super" account.** The **first user is the top admin** — a normal user with
|
||||
the highest role, the **root of the chain**. When the `users` table is empty, the instance is in
|
||||
**setup mode**.
|
||||
|
||||
### 5.1 First run — the `/setup` wizard
|
||||
|
||||
```
|
||||
fresh deploy → config.setup: true → the app serves ONLY /setup (loopback-only, D-025)
|
||||
Step 1 the domain / base URL
|
||||
Step 2 the top admin — name, email, Passkey + TOTP enrollment (user_id = 1, role: admin)
|
||||
Step 3 mail / SMTP · locale · timezone
|
||||
Finish → settings persisted → the app runs in PRODUCTIVE MODE (setup: false)
|
||||
→ /setup now 404s (delete, don't redirect); re-run only via config.setup: true + restart
|
||||
```
|
||||
|
||||
- `setup` is a **reserved `PARAM1`** (always — so no user can ever be named `setup`).
|
||||
- In setup mode, `/setup` is the **only served route**, and it is **loopback-only** (D-025).
|
||||
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator runs it once
|
||||
over an **SSH tunnel** to `127.0.0.1:8080`, then exposes the instance.
|
||||
- *README: do NOT expose MyDay publicly before the first admin exists.*
|
||||
|
||||
### 5.2 Admin recovery — a config-gated enrolment link
|
||||
|
||||
```
|
||||
sysadmin sets the recovery approval flag in config (e.g. admin_recovery: true) → restart
|
||||
→ the top admin's (user 1) identity is ROTATED → status: pending_enrollment
|
||||
→ the system EMAILS the enrolment link to user 1's ADDRESS ON RECORD
|
||||
→ the admin re-enrolls Passkey + TOTP
|
||||
→ audited · the flag is cleared
|
||||
```
|
||||
|
||||
- **Dual control.** Without the config flag **nothing happens** — no rotation, no email. The
|
||||
operator holds one half; the admin's **inbox** holds the other.
|
||||
- The top admin **has an email** (collected by `/setup`, §5.1), so the link has somewhere to go.
|
||||
The config flag is the *approval*; the inbox is the *delivery*.
|
||||
|
||||
### 5.3 Rules of the top admin
|
||||
|
||||
- The top admin **cannot be deleted** (by themselves or anyone) — lockout prevention. Only
|
||||
`reset_admin` changes the account.
|
||||
- Recommended (O-7): **exactly one** admin at the top; everyone else is director / manager / team.
|
||||
|
||||
---
|
||||
|
||||
## 6. The auth concept
|
||||
|
||||
### 6.1 Identity anchors
|
||||
|
||||
| Anchor | Meaning | Rotates? |
|
||||
|---|---|---|
|
||||
| **`user_id`** | internal, stable person id (**the first user = 1 = the top admin**); never in a URL | **no** |
|
||||
| **`guid`** | the access username in the path | **yes** (recovery) |
|
||||
| **`email`** | contact + recovery authority | no |
|
||||
|
||||
- `user_id` relates to the GUID and the email. **Stable user ≠ rotating identity.**
|
||||
- Credentials hang off an **identity**; only one active identity per user (D-008).
|
||||
- **GUIDs are never recycled** — a tombstoned GUID stays reserved forever.
|
||||
|
||||
### 6.2 The two factors
|
||||
|
||||
**Passkey (primary) + TOTP (fallback) — only.** No OTP-by-email as a login factor; no recovery
|
||||
codes. Email is *never* a login factor; it is only the recovery **channel**.
|
||||
|
||||
### 6.3 Enroll vs login — one path, two states
|
||||
|
||||
```
|
||||
GET /{GUID} → found · not enrolled → ENROLL (mandatory Passkey + TOTP)
|
||||
→ found · enrolled → LOGIN (Passkey assertion, or TOTP) → session
|
||||
→ not found → 404
|
||||
```
|
||||
|
||||
**The GUID is identification; the session is the credential.** The door resolves *which* user; the
|
||||
handler proves *you are* them.
|
||||
|
||||
### 6.4 Recovery
|
||||
|
||||
| Who | Path |
|
||||
|---|---|
|
||||
| **team / manager / director** | `/access` → email on record → **manager escalation** (approve/deny) → new GUID |
|
||||
| **top admin** | **`reset_admin`** config toggle → identity rotated → re-enroll (§5.2) |
|
||||
|
||||
```
|
||||
GET /access → enter email on record
|
||||
POST /access?do=request → ALWAYS generic ("if a record exists, we've emailed you")
|
||||
└─ on match: recovery_request(status: pending_approval) · notify approver chain + employee
|
||||
Approver (a role above the requester) — authenticated session
|
||||
→ approve / deny (approver ≠ requester, always; both audited)
|
||||
└─ on approval: revoke OLD identity · mint NEW GUID · email the new link
|
||||
Employee re-enrolls Passkey + TOTP under the NEW GUID → /{old GUID} permanently dead
|
||||
```
|
||||
|
||||
**Why this is safe:** email compromise alone can only *initiate*; credentials require a **second,
|
||||
independent human approval** from the chain. With identity rotation (D-007), a compromised inbox
|
||||
cannot take over an account on its own.
|
||||
|
||||
**Edge cases:** approver must be above the requester · never the requester · pending requests
|
||||
expire (~72h), capped per user.
|
||||
|
||||
---
|
||||
|
||||
## 7. Onboarding & roles
|
||||
|
||||
### 7.1 Roles (KISS)
|
||||
|
||||
```
|
||||
admin → director → manager → team
|
||||
(top) (employee)
|
||||
```
|
||||
|
||||
| Role | May create | Their manager (default) |
|
||||
|---|---|---|
|
||||
| **admin** | director · manager · team | — (root) |
|
||||
| **director** | manager · team | the admin |
|
||||
| **manager** | team | a director (or admin) |
|
||||
| **team** | — | a manager |
|
||||
|
||||
**One role per user** — the role **ripples down** (a director can do all a manager and a team member can). No tick-boxes, no ACL (D-034). The **chain** (`manager_user_id`) is separate: it answers *who approves you*, not *what you may do*.
|
||||
|
||||
### 7.2 Manager first, then team
|
||||
|
||||
**The chain is built top-down**, so a manager always exists before their reports do:
|
||||
|
||||
```
|
||||
the top admin (first user)
|
||||
└─ director created by the admin (admin approves their recovery)
|
||||
└─ manager created by the director (the director approves recovery)
|
||||
└─ team created by the manager (the manager approves recovery)
|
||||
```
|
||||
|
||||
### 7.3 Who may create a user
|
||||
|
||||
- **You may create only roles *below* your own** (admin → director → manager → team).
|
||||
- The **new user's manager defaults to the creator**.
|
||||
- This **unifies creation authority and recovery authority** on the same chain — no one can invent
|
||||
a peer or top-level account and sidestep escalation.
|
||||
|
||||
### 7.4 The minimal hierarchy (M0)
|
||||
|
||||
- A **self-referential `users.manager_user_id`** ships in **M0**, because onboarding *and* recovery
|
||||
both need to answer *"who is above this person?"* (D-020).
|
||||
- Departments, positions, and richer org charts come in **M1**.
|
||||
- Onboarding is **not** manager-approved for the *initial* invite (the creator's authority + the
|
||||
emailed `/{GUID}` link is the control); **recovery** is the step that requires approval.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions log
|
||||
|
||||
| ID | Date | Decision | Rationale / notes |
|
||||
|---|---|---|---|
|
||||
| **S-001** | 2026-10-13 | Scope = **org structure + time + absence + reporting**. | Payroll/recruiting/benefits excluded. |
|
||||
| **S-002** | 2026-10-13 | **Self-hosted, single-tenant.** No tenant `{slug}`. | GMS's `{slug}/{GUID}` collapses to one level. |
|
||||
| **S-003** | 2026-10-13 | **Language: Go — confirmed, not Rust.** | I/O-bound CRUD + HTTP, self-hosted single binary. The "no runtime plugins" limit is solved by build-time registration (**Caddy** does this); **Rust's dynamic loading is *no better*** (no stable ABI). Go wins on iteration speed, ecosystem (WebAuthn · TOTP · SQLite · sqlc), and IDM precedent. **Rust is reserved** for a future CPU-heavy module/service (e.g. on-device AI) — a contained component, not a rewrite. |
|
||||
| **D-001** | 2026-10-13 | **The GUID is the username**, at `PARAM1`, **always visible** in the path. | Unguessable (≥128-bit). The **session is the credential**. |
|
||||
| **D-002** | 2026-10-13 | The door **resolves**; the handler **authenticates**. | A bookmark is never a login. |
|
||||
| **D-003** | 2026-10-13 | **One binary**, headless door, **in-process `RequestContext`**. | Self-host simplicity; GMS field names kept for a future split. |
|
||||
| **D-004** | 2026-10-13 | **The path cascade** (§4.2): `PARAM1` = a reserved workflow **or a GUID**; the GUID's namespace carries the app. Empty `PARAM1` → 302 `/home`. | Reserved: `home · access · setup · api · static · media · ws · dev`. |
|
||||
| **D-005** | 2026-10-13 | **Two factors only: Passkey (primary) + TOTP (fallback).** | No OTP-by-email login factor; no recovery codes. |
|
||||
| **D-006** | 2026-10-13 | **Enrollment is GUID-driven — no `/auth/enroll/{token}`.** `GET /{GUID}`: N → enroll, Y → login. The invite carries `/{GUID}`. | Un-enrolled GUID is a bearer → **bounded** window (A1). |
|
||||
| **D-007** | 2026-10-13 | **Recovery rotates the identity:** new GUID, old revoked; **all previous auth records invalid.** | Old GUID tombstoned; sessions/passkeys/TOTP destroyed. |
|
||||
| **D-008** | 2026-10-13 | **Stable user record ≠ rotating identity.** Credentials hang off an identity. **GUIDs never recycled.** | Employment survives rotation. |
|
||||
| **D-009** | 2026-10-13 | **Manager escalation.** Every non-admin user sits below someone. | Recovery needs **approval from the chain**. Email *initiates*; a human *authorises*. |
|
||||
| **D-010** | 2026-10-13 | **`config.yaml` holds config + secrets.** Gitignored; `config.example.yaml` shipped; env override. | The DB is the truth for identities/credentials. |
|
||||
| **D-011** | 2026-10-13 | **PWA `start_url: "/"`.** The door resolves the session and redirects to the current GUID. | Install survives identity rotation. |
|
||||
| **D-012** | 2026-10-13 | **SQLite** datastore. | WAL · FK ON · **CGO-free driver** · file backup. **One instance only** (single-writer). |
|
||||
| **D-013** | 2026-10-13 | **Internal `user_id`** relates to the **GUID** and the **email**; never in a URL. **The first user = 1 = the top admin.** | The person is permanent; the GUID is the revocable door. |
|
||||
| ~~D-014~~ | 2026-10-13 | ~~Super GUID in config~~ | **SUPERSEDED by D-021/D-022** — no separate super account. |
|
||||
| ~~D-015~~ | 2026-10-13 | ~~Mandatory first-run super rollout~~ | **SUPERSEDED by D-022** — the first user is the top admin. |
|
||||
| ~~D-016~~ | 2026-10-13 | ~~Super enable/disable toggle~~ | **SUPERSEDED by D-023** — `reset_admin`. |
|
||||
| **D-017** | 2026-10-13 | **Dual dispatch:** path requests *or* `do={action}` requests. `do` = hidden form field. | Form state, not identity. **Not a security control** (A8). |
|
||||
| **D-018** | 2026-10-13 | **List filters ride the PATH**, not the query: `…/request/open` · `…/approve/closed`. | A filtered view is a **page**; deliberate divergence from GMS. |
|
||||
| **D-019** | 2026-10-13 | **Onboarding is top-down (manager first, then team).** Creation authority = a role **above** the new user; the new user's manager defaults to the creator. | Unifies creation + recovery authority on one chain. |
|
||||
| **D-020** | 2026-10-13 | **Minimal hierarchy in M0:** self-referential `users.manager_user_id`. | Onboarding and recovery both need the chain; richer org structure is M1. |
|
||||
| **D-021** | 2026-10-13 | **No super account — the first user is the top admin** (`user_id = 1`, role `admin`, root of the chain). | Removes a standing privileged account. |
|
||||
| **D-022** | 2026-10-13 | **Bootstrap:** `users` empty → **setup mode**; the top admin is created at a **gated** path; setup ends at the first user. | **Gate it** (loopback and/or setup token) or "first user" = "first visitor". |
|
||||
| **D-023** | 2026-10-13 | **Admin recovery = `reset_admin` config toggle** — rotate the top admin's identity to `pending_enrollment`, log the new GUID; audited; auto-clears. | The config is the offline-held root of trust. |
|
||||
| **D-024** | 2026-10-13 | **Roles (KISS): `admin > director > manager > team`.** You may create only roles **below** your own; the new user's manager defaults to the creator. | Answers "who may create a user?" |
|
||||
| **D-025** | 2026-10-13 | **The bootstrap is loopback-only, enforced.** While in **setup mode** the app **refuses to serve a non-loopback address**; the operator reaches it over an **SSH tunnel** to `127.0.0.1:8080`. Optional `{bootstrap_guid}` = defence-in-depth. | Closes first-visitor-wins **in code**, not only in the README. |
|
||||
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **setup-mode only · loopback-only** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
|
||||
| **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. |
|
||||
| **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. |
|
||||
| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). |
|
||||
| **D-030** | 2026-10-13 | **`presence` and `time` unify into one module (`time`).** A per-user **mode** decides the flow: `presence` (simple in/out/away) or `timesheet` (exact hours, pay-by-hour). | The mode is an **employment attribute** on the user (core/`hr`), read by the module. `presence` becomes a *view*, not a module. |
|
||||
| **D-031** | 2026-10-13 | **Location = audit only. No tracking, no geofencing.** The audit log records an approximate **location derived from the client IP**. Off by default; a VPN defeats it (accepted). | Deliberately shallow — enough for a security trail, nowhere near geo-tracking. |
|
||||
| **D-032** | 2026-10-13 | **Actions carry TWO routing fields: `do={module}` + `action={function}`** (both whitelisted). The GUID still rides the **path**. | The PHP-style simplicity for module dispatch (`do=hr&action=save-data`). Modules register routes **and** actions. |
|
||||
| **D-033** | 2026-10-13 | **Admin recovery: a config approval flag emails the enrolment link to user 1's address on record.** Without the flag, **nothing happens**. | **Dual control** — the operator's config half + the admin's inbox half. Refines D-023. |
|
||||
| **D-034** | 2026-10-13 | **Roles: simple tiers, NO ACL, ONE role per user.** `admin > director > manager > team`; the assigned role **ripples down** — it implies all lower roles. *(No tick-boxes.)* | KISS — basic levels only, no permission matrix. The **chain** (`manager_user_id`) stays separate from roles. |
|
||||
| **D-035** | 2026-10-13 | **Settings live in the CONFIG FILE** (infra · secrets · domain · mail · locale). The **DB holds operational data only**. | Owner's call: editing a setting in the file beats a DB migration. Caveat: the app must be able to write `config.yaml` (or the wizard prints the values for the operator). |
|
||||
| **D-036** | 2026-10-13 | **Layout: separated dirs, one tree** — `internal/core/` (the door · registry · identity · DB) and `internal/modules/<name>/` (**self-contained vertical slices**: routes · actions · handlers · queries · migrations · templates). | The boundary becomes **physical**, and each module stays truly drop-in. Rejected: one flat dir. |
|
||||
| **D-037** | 2026-10-13 | **Every module has the same default skeleton** — `app.go` (the manifest: name · required · migrations · nav · `Start`) · `routing.go` (routes + actions) · `handlers.go` (the action handlers). More files as needed. | Predictability + drop-in: every module reads the same way. |
|
||||
| **D-038** | 2026-10-13 | **Modules are enabled/disabled from a core Modules page** (state in config, D-035). Disabling unmounts routes + hides nav; **it never deletes data**. **Required** modules can't be disabled. | Runtime activation of compiled-in modules. Toggles are **audited** (A11). |
|
||||
| **D-039** | 2026-10-13 | **Two level-1 entry points — ROUTER (path) and HANDLER (actions) — both resolve to `(module, function, params)`.** The branch is keyed on the **routing fields** (`do`/`action`), **not** the HTTP method. Identity + role gate run **before** the module function. | The top-level dispatch, made explicit: **two doors, one destination.** |
|
||||
|
||||
---
|
||||
|
||||
## 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.** |
|
||||
279
docs/specs/spec-001-git-bootstrap.md
Normal file
279
docs/specs/spec-001-git-bootstrap.md
Normal file
|
|
@ -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.<name>` | ✅ |
|
||||
| `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://<user>:<token>@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).
|
||||
Loading…
Reference in a new issue