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>
157 lines
7.6 KiB
Markdown
157 lines
7.6 KiB
Markdown
# 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.
|