# HSA Receipt Tracker — Implementation Plan (v1) Companion to `spec.md`. This is the *how* and the *order*; `spec.md` is the *what*. Strategy: red/green (write a failing test, make it pass) wherever the logic is pure and deterministic. Where behaviour depends on the live world (the real OIDC round-trip against Authelia, a camera, a browser), we verify manually instead and say so explicitly. --- ## Decisions locked (from consultation) | Topic | Decision | |------------------|-----------------------------------------------------------------| | Stack | Go — single static binary, systemd in LXC, SQLite | | App domain | `hsa.maisym.com` | | Auth issuer | `auth.maisym.com` | | OIDC client | Confidential + PKCE | | Auth group | `hsa-users` (gates access; not in group → 403) | | Session | Encrypted, signed cookie (stateless, no server-side store) | | Edit | Designed-for, **not built** in v1 | | Export | `/export/db` only, with include-blobs **yes/no** toggle | | Files | PDF + images, accepted as-is; client compression is **phase 2** | | View list/detail | Out of scope for v1 | | Auth testing | Unit-test pure pieces; verify live round-trip manually | --- ## Build order (milestones) - **M0 — Auth flow only.** App authenticates against live Authelia and shows a "you're logged in" result, including the group gate. *This is the first thing JM wants to see running.* Detailed below. - **M1 — Storage layer.** SQLite schema + receipts data access (insert, soft-delete, fetch). Dual-write (filesystem + BLOB) in one transaction. - **M2 — Upload flow.** Mobile-first capture/upload form (amount, date, category), PDF+image handling, validation, store both copies, confirmation page. - **M3 — Export.** `GET /export/db` with include-blobs toggle, via point-in-time snapshot (never the live DB file). - **Phase 2 / later.** Client-side image compression; edit functionality. Only **M0 is planned in detail** below — we plan the next milestone when we get there, so the plan stays honest. --- ## Milestone 0 — Auth flow **Goal / acceptance:** Open `hsa.maisym.com` in a phone/browser → redirected to `auth.maisym.com` → log in → land back on a page that reads roughly: > Logged in as `jeanmi.tremblay@gmail.com` · groups: `[hsa-users]` · access: **GRANTED** …and a user who is **not** in `hsa-users` gets a **403**. That single screen proves the entire chain: OIDC+PKCE round-trip, identity claims, group claim, and the authorization gate. ### Step 0.1 — Prerequisites (setup, not tested) - Install Go toolchain (not currently present on this machine). - `git init` the repo. - `go mod init` — **module path TBD** (see Open Questions). Default proposal: `maisym.com/hsa`. - Project skeleton: `cmd/hsa/main.go`, `internal/auth/`, `internal/web/`, `internal/config/`. ### Step 0.2 — JM's Authelia-side config (your action; app can't do this) Enumerated here so the app and Authelia agree on every value: - Create group `hsa-users`; add both users. - Register a **confidential** OIDC client in `identity_providers.oidc.clients`: - `client_id`: e.g. `hsa-tracker` - `client_secret`: random, stored **hashed** (via `authelia crypto hash generate`) - `redirect_uris`: `https://hsa.maisym.com/callback` (+ a dev redirect, e.g. `http://localhost:8080/callback` — see Open Questions) - `scopes`: `openid`, `profile`, `email`, `groups` - `response_types`: `code`; `grant_types`: `authorization_code` - PKCE: require `S256` - token endpoint auth method: `client_secret_basic` (confirm at execution) - Reload Authelia. ### Step 0.3 — App config (env vars) `ISSUER_URL`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URL`, `REQUIRED_GROUP` (=`hsa-users`), `SESSION_KEY` (cookie encryption key), `LISTEN_ADDR`. Loaded and validated at startup (fail fast if any missing). ### Step 0.4 — Red/green units (pure logic) Each is a failing test first, then the implementation: 1. **PKCE** — `GenerateVerifier()` (43–128 URL-safe chars) and `ChallengeS256(verifier)`. Test with the **RFC 7636 Appendix B** known vector (fixed verifier → known challenge) so the encoding is provably correct. 2. **State / nonce** — sufficient length, two calls differ (CSRF + replay defense). 3. **Session codec** — `Encode(session)`/`Decode(cookie)` round-trips; a **tampered** value is rejected; a value signed with the **wrong key** is rejected. 4. **Authorization decision** — `IsAuthorized(groups, required) bool`. Table tests: in-group → true; empty groups → false; other-group-only → false. *This is the 403 rule, tested in isolation.* 5. **Authorize-URL builder** — construct `oauth2.Config` with fixed endpoints (no network/discovery) and assert `AuthCodeURL` carries `state`, `code_challenge`, `code_challenge_method=S256`, and the right scopes. ### Step 0.5 — Handlers tested via `httptest` (no live Authelia) - `GET /healthz` → 200. - `GET /` (protected) behind `RequireAuth`: forged **valid** session cookie → 200 and the page shows identity + groups; **no/invalid** cookie → 302 to `/login`. - `GET /logout` → clears cookie, 302. ### Step 0.6 — Live round-trip (manual verification — the M0 acceptance) Not unit-testable (needs real Authelia + a human clicking login): - `GET /login` → builds PKCE+state, stashes verifier/state in a short-lived cookie, 302 to Authelia's authorize endpoint. - `GET /callback` → validate `state`, exchange `code` (with PKCE verifier), verify the ID token, extract claims, run `IsAuthorized`, set the session cookie, redirect to `/`. (The pure sub-parts — claim extraction, group gate — are already red/green from 0.4.) **Manual test script:** 1. Run the app with real config (locally with the dev redirect, or deployed in the LXC behind Caddy). 2. Browser → app → redirected to `auth.maisym.com` → log in → returned to success page showing identity + `[hsa-users]` + **GRANTED**. 3. Negative path: an account **not** in `hsa-users` → **403**. ### M0 done = All red/green units green, `httptest` handler tests green, and the manual round-trip (both positive and 403 paths) confirmed against live Authelia. --- ## Open questions (revisit before/at execution — not guessing) 1. **Module path / repo name** — `maisym.com/hsa`? a GitHub path? Will there be a GitHub remote, or local-only for now? 2. **Dev redirect URI** — do you want to test M0 *locally first* (needs a `http://localhost:8080/callback` redirect added to the Authelia client), or **deploy-to-LXC-first** and test only at `https://hsa.maisym.com`? 3. **Token endpoint auth method** — `client_secret_basic` vs `_post`. Pick when we wire it; basic is the default assumption. 4. **Infra ownership** — Caddy vhost + systemd unit: you handle, or you want me to draft the unit file / Caddyfile snippet as part of M0? ## Deferred to later milestones (noted so we don't design against them) - SQLite driver choice — lean **`modernc.org/sqlite`** (pure Go, keeps the static binary, no cgo). Decide at M1. - Edit (v1: schema/storage must not preclude it — on a future edit, update FS file and BLOB in one transaction, keep `file_path` UUID stable). - Client-side image compression (phase 2): re-encode camera images to JPEG/WebP at a quality factor before upload; PDFs pass through untouched.