hsa-app/plan.md

158 lines
7.6 KiB
Markdown
Raw Permalink Normal View History

# 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()` (43128 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.