hsa-app/SPEC.md
Jean-Michel Tremblay 866bc175fb
All checks were successful
Build and Test / build-and-test (push) Successful in 38s
Security hardening: server-side session expiry + nosniff on files
1. Enforce the 12h session lifetime server-side in requireAuth (reject a
   session older than the TTL even if the sealed cookie is intact), so a
   leaked cookie value can't be replayed indefinitely. Shared sessionTTL
   const drives both the cookie MaxAge and the check.
2. Send X-Content-Type-Options: nosniff when serving user-uploaded
   receipt/attachment bytes, so the browser won't sniff past the declared
   (upload-time allowlisted) MIME type.

Update SPEC §2 and §6 accordingly; tests cover stale-session rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:56:38 -04:00

18 KiB
Raw Permalink Blame History

HSA Receipt Tracker — Specification

This is the source of truth for the app's current expected behavior, organized by domain. It describes what the app does today, not how it got here — for the history and rationale of each decision see DESIGN.md; for the version-by-version log see CHANGELOG.md.

Keywords MUST, SHOULD, and MUST NOT mark hard requirements vs. recommendations. When behavior changes, amend the relevant section here in the same commit.


1. Purpose & scope

Capture and archive HSA-eligible receipts for two household users, for future reimbursement and tax substantiation. The app is mobile-first (phone camera capture matters). It deliberately does not do reimbursement tracking, in-place editing, or general reporting beyond the Tally view.


2. Users, authentication & authorization

  • Two users, both with full shared access — there is no per-user data isolation; everything is visible to every authorized user.
  • Authentication is OIDC with PKCE against an Authelia issuer (ISSUER_URL), using the registered client (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).
  • The login flow:
    • GET /login generates a PKCE verifier, state, and nonce, stores them in a short-lived (10 min) encrypted hsa_login cookie, and redirects to Authelia.
    • GET /callback validates state (CSRF), exchanges the code with the PKCE verifier, verifies the ID token and nonce, then reads claims. Groups and email come from the ID token, falling back to the UserInfo endpoint (Authelia serves these from UserInfo by default).
    • The user identity (subject) is the email, or the preferred username if no email is present.
  • Authorization gate: the user MUST be a member of REQUIRED_GROUP (exact, case-sensitive match). Otherwise the callback returns 403.
  • On success a hsa_session cookie is set for 12 hours. The session is an AES-256-GCM sealed token (tamper-evident; key = SHA-256(SESSION_SECRET)), and the 12h lifetime is enforced server-side (a session older than the TTL is rejected even if the cookie value is intact), not just via the cookie's MaxAge. Cookies are HttpOnly, SameSite=Lax, and Secure whenever REDIRECT_URL is https://.
  • GET /logout clears the session and redirects to /login.
  • Public routes (no session required): /healthz, /static/*, /login, /callback, /logout. Every other route requires a valid session; missing or invalid sessions redirect to /login.

3. Configuration

All runtime config is read from environment variables (a local .env is loaded best-effort on startup; see .env.example). Required vars (startup fails if any is missing): ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, REDIRECT_URL, REQUIRED_GROUP, SESSION_SECRET.

Var Default Meaning
LISTEN_ADDR :8080 HTTP listen address
DB_PATH ./data/hsa.db SQLite database file
STORAGE_DIR ./data storage root (receipts/attachments live under it)
MAX_UPLOAD_MB 32 per-file upload cap (reject larger)
CONFIG_PATH ./config.json people + categories catalog
CLAUDE_API_KEY (empty) Anthropic key; empty disables AI classification
CLASSIFY_MODEL claude-haiku-4-5-20251001 classification model id
BACKUP_DIR ./data/dbbackup scheduled metadata-only backups
BACKUP_INTERVAL 168h backup frequency (Go duration); 0 disables
BACKUP_KEEP 8 most-recent backups to retain

Catalog (config.json): defines persons (last/first/middle) and categories (name + authored examples). On startup the app seeds the canonical person labels (First Last) and category names into the DB (insert-if-absent), so labels renamed via Manage survive restarts. The per-category examples are used only to build the classifier prompt and are intentionally not stored in the DB. A missing/invalid catalog is a fatal startup error.


4. Data model (SQLite)

  • categoriesid, label (unique). Seeded; renamable via Manage.
  • peopleid, label (unique). Seeded from catalog; renamable via Manage. Format is " "
  • receiptsid (UUID), uploaded_by, uploaded_at, receipt_date, amount_cents (integer — money is never a float), category_id (FK, required), person_id (FK, nullable), file_path, image_data (BLOB), file_size_bytes, original_filename, mime_type, deleted_at (nullable, soft delete).
  • attachments — same shape as receipts minus the receipt-specific fields, plus receipt_id (FK). Supplementary files for a receipt; no amount/date/category of their own.
  • tagsid, label (unique, case-insensitive via COLLATE NOCASE).
  • receipt_tags — (receipt_id, tag_id) many-to-many, composite primary key.
  • ai_notesid, text, created_at, deleted_at (nullable). Temporal: edits soft-delete + insert, so the active set at any past time is recoverable.
  • classificationsreceipt_id (PK/FK), model, response_json (the AI suggestion blob), created_at, reviewed, reviewed_at, resolution.
  • miss_fixes — (receipt_id, note_id) linking a reviewed misread to the note(s) credited with fixing it.

PRAGMA foreign_keys=ON, journal_mode=WAL, busy_timeout=5000 are set on open.


5. Upload & receipt creation

The upload form (GET /{$}) and its handler (POST /upload) are the core flow.

5.1 Fields & validation

A submission MUST include: an amount, a date (YYYY-MM-DD), a category, and a receipt file. The who (person) and tags are optional, as are additional attachment files.

  • Amount is parsed to integer cents; accepts an optional $, spaces, and thousands separators; rejects empty, non-numeric, more than two decimals, zero, and negatives.
  • Category MUST reference an existing category; who, if provided, MUST reference an existing person.
  • File MUST be detected as image/* or application/pdf and be within MAX_UPLOAD_MB. Same allowlist and cap apply to each attachment.
  • On any validation error the form re-renders with messages and nothing is written; the user's entered values, including the tag selection, are preserved.

5.2 Image orientation normalization

On upload, a JPEG carrying an EXIF Orientation tag of 2..8 is rotated/flipped upright, re-encoded (JPEG q≈90), and the tag dropped, so the stored image is upright everywhere. Images with no tag, an upright tag (1), non-JPEG images, PDFs, or anything that fails to decode pass through byte-for-byte. This applies to the receipt, every attachment, and the image sent to the classifier. (No content-based guessing; new uploads only.)

5.3 AI auto-fill (classification)

When CLAUDE_API_KEY is set, attaching a file triggers POST /classify, which sends the (orientation-normalized) image to the configured model and returns suggested amount, date, category, and who to pre-fill the form. It mutates no state — the user reviews and submits normally.

  • Classification is forced tool-use, so the result is always structured. The person is constrained to the canonical labels (kept only on exact match); the category is constrained to the configured set, falling back to the last (most general) category if the model returns something off-list. Date/amount are dropped if null-ish.
  • The user can tick Skip AI to suppress the call and fill fields by hand. The footnote always shows which model runs (or that auto-fill is off).
  • After a successful classification the per-call cost is shown in cents (¢), computed locally from the response's token usage and a hardcoded per-model rate table. An unknown model id shows token usage but omits the ¢ figure (never a guess).
  • The active correction notes (§10) are appended to the classifier prompt. The suggestion the model returns is round-tripped through the form and recorded against the saved receipt, so misreads can be reviewed later (§10). Skip-AI and disabled-classification uploads record nothing.

5.4 Duplicate warning

Whenever date and amount are both known (edit: those are mandatory fields aren't they?), the form calls GET /duplicates and warns if a non-deleted receipt already has the same receipt_date and amount_cents. The warning lists each match (amount, date, category, who, uploader, upload time, filename) and disables submit until the user ticks "Add it anyway". No match → submit proceeds normally.

5.5 Attachments

The form accepts optional extra files in the same submission (a second page, an EOB, an itemized list). They inherit the parent receipt's identity, carry no metadata of their own, and are saved after the receipt row exists. AI runs only on the primary receipt, never on attachments.

5.6 Tags

The form has an "Add tags" control after Who that opens an in-page modal with an alphabetical chip mosaic. The user taps chips to select/deselect and can create a new tag inline (auto-selected for this receipt). Selected tags submit with the form.

  • The tag catalog is shared/global and free-form.
  • A new tag is written to the catalog only when the receipt is actually saved (create-if-missing by label), so abandoned uploads add nothing. Labels are trimmed and matched/deduped case-insensitively (first-seen casing kept).
  • A receipt's tags are shown on the confirmation page and in the recent lists.
  • The classifier does not suggest tags — tagging is a manual act.

5.7 Storage (dual-write & file layout)

Each receipt and attachment is written in both places:

  1. Filesystem, under the storage root with a browsable, dated, amount-tagged name:
    • receipts: <STORAGE_DIR>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>
    • attachments: <STORAGE_DIR>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>
    • Year/month/day and amount come from the receipt date/amount (attachments use their parent's), so an expense's files sort together. Path components derive only from date + amount (no user filename → no path-traversal surface).
    • Name collisions get a numeric suffix on the stem (_1, _2, …) via exclusive create (O_CREATE|O_EXCL), so concurrent uploads never overwrite.
  2. DB blob (image_data), so the single .db file is a complete, portable dataset (metadata + all images).

original_filename and mime_type are kept as metadata. The file is written before the DB row, so a crash in between can leave a harmless orphan file (never a row missing its data); orphans are not auto-cleaned. The DB blob is the source of truth for serving.

After a successful save, a confirmation page shows category, who, amount, date, filename, attachment count, and tags, with links to add another / Manage / Export.


6. Viewing & serving files

  • GET /receipt/{id}/file and GET /attachment/{id}/file serve the stored bytes from the DB blob (so serving works even if the on-disk copy is gone), with the original MIME type, Content-Disposition: inline, and X-Content-Type-Options: nosniff (so the browser won't sniff user bytes past the declared, upload-time-allowlisted type).

7. Recent lists

Two read-only listings of non-deleted receipts, 10 per page with a "Load next 10" control (offset paging):

  • GET /recent — ordered by upload date (uploaded_at desc).
  • GET /recent/receipts — ordered by receipt date (receipt_date desc).

Each row shows: amount (linking to the file), receipt date, category, the who abbreviated to initials + last name (e.g. "Jean-Michel Tremblay" → "JM. Tremblay"; each hyphenated first-name part is initialed), the upload date, paperclip links for any attachments, and the receipt's tag chips.


8. Tally

GET /tally — a read-only person × year matrix of summed amounts (dollars):

  • One row per person, one column per year that has data; receipts with no who are grouped under "Unassigned" (sorted last).
  • Right-margin column: total per person across years. Bottom-margin row: grand total per year. Bottom-right: overall grand total.
  • Excludes soft-deleted rows. Years are bucketed by the receipt date.

9. Manage lists

GET /manage lets authorized users curate the lookup lists:

  • Categories and People: rename existing entries (POST /manage/categories/rename, /manage/people/rename) and add new ones (POST /manage/categories, /manage/people). Renames propagate everywhere because receipts reference these by id.
  • On startup, stray partial-name people (e.g. a leftover "Jude") are merged into the unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first, so no receipt loses its who. Seeding is idempotent.
  • Tags are not managed here (see §15 gaps).

10. AI classifier notes & misread review

GET /ai — a tab for improving classification over time.

  • Correction notes: a single global list of free-text rules, appended to the classifier prompt (§5.3). Managed inline — add (POST /ai/notes), edit (POST /ai/notes/edit), delete (POST /ai/notes/delete). The table is temporal: an edit soft-deletes the old row and inserts a new one, so the notes active when any past receipt was classified are recoverable. Seeded once from a hardcoded default list (documented in the README; no PII) only when the table is empty, so a deleted default does not return. Read live on every classify call (no restart).
  • Read-only prompt view: renders the exact system prompt that would be sent today (static scaffolding + injected people/categories/today + active notes), when classification is enabled.
  • Misread review: every AI-run upload stores the suggestion blob + model. A miss is derived (never stored) — any of the four AI fields (amount, date, category, who) differing from the receipt's final value, where AI-null-then-filled counts as a miss. The review queue lists unreviewed misses; GET /ai/review/{id} shows the receipt image, the AI-guess-vs-entered per field, and the notes added since that classification. The user ticks which notes fixed it (POST /ai/review/{id}) → resolution fixed and miss_fixes links; ticking none closes it unresolved. Either way the row is marked reviewed.

Notes live only in the DB (private, not in git; included in /export/db and backups). The stored suggestion blob is client-supplied diagnostic data, not ground truth.


11. Export

  • GET /export/db — downloads a consistent point-in-time copy of the SQLite database produced via VACUUM INTO (the live file is never locked/served directly). Because images are stored as blobs, this single file is the complete dataset. Content-Type: application/octet-stream, filename hsa-export-YYYY-MM-DD.db.
  • GET /export/db?blobs=false — a metadata-only copy with image blobs stripped (≈99% smaller); filename hsa-export-YYYY-MM-DD-metadata.db.
  • Read-only; mutates no app state. Amounts stay integer cents in the export.

12. Scheduled backups

A background goroutine writes metadata-only snapshots (blobs stripped from both receipts and attachments) into BACKUP_DIR:

  • Frequency BACKUP_INTERVAL (default weekly; 0 disables), retaining the most recent BACKUP_KEEP (default 8).
  • Files are named hsa_sqlite_backup_<YYYY_MM_DD>.dbone per day; a same-day re-run is a no-op, so frequent restarts never spam the directory.
  • Runs once at startup (if the latest backup is due), then on the interval. Failures are logged, never fatal. Images are recoverable from the on-disk files and the full /export/db; the daily backup recovers metadata.

13. Durability & crash recovery

  • WAL mode makes the DB self-healing: an interrupted write is rolled forward if committed, discarded otherwise, bringing the DB up at its last committed state.
  • On open the app runs PRAGMA quick_check; a crash-interrupted write passes, but genuine corruption fails fast and the app refuses to start, pointing the operator at BACKUP_DIR / a .db export to restore.

14. Deployment & operations

  • The app builds as a fully static binary (CGO_ENABLED=0; the SQLite driver is pure Go), runnable on any linux/<arch>.
  • It runs as a user systemd service behind a reverse proxy. Tagged releases are built and deployed by Forgejo Actions: a release tag X.Y.Z is built, tested, staged into ~/hsa-app/releases/hsa-app-V<tag>/, then activated (symlink swap
    • service restart); pre-release tags (e.g. 0.0.0a2) are built and staged only. See deploy/INSTALL.md.

15. Known gaps / not yet implemented

These are intentionally absent today (the schema or storage layer may support some; the UI/endpoint does not):

  • Deleting receipts: deleted_at and a SoftDelete storage method exist and all listings/tally already exclude deleted rows, but no route or UI triggers a delete — there is currently no way to delete a receipt from the app.
  • Editing existing receipts (fix by deleting and re-adding — once delete exists).
  • Adding attachments or tags to an already-saved receipt (no edit/detail page).
  • Duplicate same-person highlighting: the warning matches on date + amount only; it does not yet weight or highlight a same-person match differently.
  • Tag features: no tag-based filtering/search, no tally-by-tag, and no editing/merging/deleting tags in Manage (free-form tags will accumulate; a cleanup surface is future work).
  • Archive/zip export (/export/archive): not implemented; only /export/db.
  • HEIC images are not decodable in pure Go; browser camera uploads arrive as JPEG in practice.

Related future work now that AI notes + review exist (§10): per-category/vendor- scoped notes, automatic re-classification of past misses, editing the static prompt scaffolding, and accuracy-rate stats. See DESIGN.md item 15.