hsa-app/plan.md
Jean-Michel Tremblay 8b7252dad4 Initial commit: HSA receipt tracker
Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth
against Authelia, SQLite storage with dual-write (filesystem + DB blob),
mobile-first upload, and DB export.

Adds AI receipt classification: a config.json catalog of people and
categories (seeded into the DB on startup), a prompt builder that derives
name-order/initial variants from the data (with same-surname ambiguity
handling), and an Anthropic tool-use client behind POST /classify. Tests
run against a mock endpoint; a live integration test is env-gated to the
cheapest model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:40:12 -04:00

157 lines
7.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.