hsa-app/internal/config/config.go
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

122 lines
3.4 KiB
Go

package config
import (
"bufio"
"crypto/sha256"
"fmt"
"os"
"strconv"
"strings"
)
// Config holds all runtime configuration, loaded from environment variables.
type Config struct {
IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com
ClientID string // OIDC client_id
ClientSecret string // OIDC client secret (plaintext)
RedirectURL string // e.g. http://localhost:8080/callback
RequiredGroup string // group that gates access, e.g. hsa-users
SessionKey [32]byte // AES-256 key derived from SESSION_SECRET
ListenAddr string // e.g. :8080
SecureCookies bool // true when serving over https
DBPath string // SQLite file path
StorageDir string // directory for receipt files on disk
MaxUploadBytes int64 // reject uploads larger than this
ConfigPath string // path to config.json (people + categories)
ClassifyAPIKey string // Anthropic API key; empty disables auto-classification
ClassifyModel string // model id for classification
}
// Load reads configuration from the environment, validating required values.
func Load() (Config, error) {
var c Config
var missing []string
get := func(key string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
missing = append(missing, key)
}
return v
}
c.IssuerURL = get("ISSUER_URL")
c.ClientID = get("OIDC_CLIENT_ID")
c.ClientSecret = get("OIDC_CLIENT_SECRET")
c.RedirectURL = get("REDIRECT_URL")
c.RequiredGroup = get("REQUIRED_GROUP")
sessionSecret := get("SESSION_SECRET")
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", "))
}
// Derive a fixed 32-byte AES key from whatever secret string was provided.
c.SessionKey = sha256.Sum256([]byte(sessionSecret))
c.ListenAddr = strings.TrimSpace(os.Getenv("LISTEN_ADDR"))
if c.ListenAddr == "" {
c.ListenAddr = ":8080"
}
c.SecureCookies = strings.HasPrefix(c.RedirectURL, "https://")
c.DBPath = envOr("DB_PATH", "./data/hsa.db")
c.StorageDir = envOr("STORAGE_DIR", "./data/receipts")
maxMB := 32
if v := strings.TrimSpace(os.Getenv("MAX_UPLOAD_MB")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxMB = n
}
}
c.MaxUploadBytes = int64(maxMB) * 1024 * 1024
// Domain config + classification (all optional; classification is best-effort).
c.ConfigPath = envOr("CONFIG_PATH", "./config.json")
c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY"))
c.ClassifyModel = envOr("CLASSIFY_MODEL", "claude-opus-4-8")
return c, nil
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
// LoadDotEnv is a best-effort loader that reads KEY=VALUE lines from path into
// the process environment if the file exists. Existing env vars are not overwritten.
// Intended for local development convenience only.
func LoadDotEnv(path string) error {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
return scanner.Err()
}