diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d9544a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Keep the build context small and free of secrets and local state. +.git +.gitignore +.dockerignore +Dockerfile +.remember/ +.claude/ +design/ +**/.DS_Store +node_modules +web/node_modules +web/dist +# Generated embed output; the Go stage takes the PWA from the web stage. +internal/frontend/dist/ +bin/ +*.db +*.db-* +.env* +**/.env* +deploy/secrets/ +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/*.jks +**/*.keystore diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d475990 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,39 @@ +# Runs on every push and pull request (no branch filter) so feature branches, +# their pull requests, and dev/main are all checked. Runs directly on the +# self-hosted act_runner; no container: key. +# +# The Makefile is the single build definition: CI runs `make test` and +# `make build` so it fails whenever either target would, and the binary +# carries the Makefile's -trimpath and version-stamped -ldflags (the +# `make build` log shows the -X main.version=... value). `make sqlc-check` +# fails when the committed internal/db code is stale against sqlc.yaml, +# migrations/ and migrations/queries/. +name: CI + +on: [push, pull_request] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: make test + run: make test + + - name: make sqlc-check + run: | + make sqlc-install + make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc" + + - name: make build + run: make build diff --git a/.gitignore b/.gitignore index ee38b4e..60709ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,18 @@ .DS_Store design/.DS_Store + +# Secrets and local environment +.env* +deploy/secrets/ + +# Build artifacts and dependencies +node_modules +dist +bin/ +# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored. +!internal/frontend/dist/ +internal/frontend/dist/* +!internal/frontend/dist/index.html + +# Local databases +*.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d306043 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,143 @@ +# AGENTS.md — MealQuest agent guide + +This is the authoritative guide for every agent (and human) working in this +repository. `CLAUDE.md` only imports this file. Where this guide and a general +habit disagree, this guide wins. + +MealQuest is a single Go service with an embedded React PWA for one +household. The architecture of record is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md); +product requirements are in [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md). + +## House rules + +1. **Fail fast.** Invalid configuration, input or state is an error reported + immediately and clearly — the service refuses to start on bad config. +2. **No silent fallbacks.** Never substitute a default, retry quietly, or + swallow an error to "keep going". If something is missing, say so. +3. **One way to do a thing.** No parallel code paths, alternative settings or + "just in case" options. Pick one approach and use it everywhere. +4. **No new third-party dependency without owner approval.** Use only the + approved list below; anything else needs the owner's explicit yes first. +5. **Never use `tea`.** The Gitea CLI on this host is authenticated as the + human owner. Agents act only through their own Gitea identities. + +## Approved dependencies (from CTXD) + +Backend: + +- Go 1.25, built with `CGO_ENABLED=0`. +- `github.com/go-chi/chi/v5` — HTTP router. +- `modernc.org/sqlite` — CGO-free SQLite driver. +- `sqlc` — query code generation (dev tool only). +- `github.com/coreos/go-oidc/v3` — OIDC (Authentik). +- `golang.org/x/oauth2` and `golang.org/x/oauth2/google`. +- `github.com/SherClockHolmes/webpush-go` — Web Push. +- `golang.org/x/image` — photo processing. +- `github.com/oklog/ulid/v2` — IDs. +- Standard library `log/slog` and `net/http`. +- **Not allowed:** LLM vendor SDKs, Google API SDKs (call the HTTP APIs + directly), goose, golang-migrate. + +Frontend: + +- React 19, TypeScript, Vite. +- React Router 7, TanStack Query 5. +- `vite-plugin-pwa`. +- `lucide-react` icons at stroke width 2.75. +- CSS Modules over the design tokens in + [tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css). + **No Tailwind, no Framer Motion, no hard-coded hex colours.** +- Self-hosted fonts: Bagel Fat One and Figtree. +- Tests: Vitest, Testing Library, jsdom. + +## Quality floor + +Every change must pass all of these before review (CI in +[.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) enforces them): + +```sh +gofmt -l . # must print nothing +go vet ./... +go test -race ./... +cd web && npm run typecheck && npm test && npm run build +``` + +And the build entry points in the [Makefile](Makefile) and [Dockerfile](Dockerfile): + +```sh +make test +make build +docker build . +``` + +Lint proves code parses, not that it works: where a change has runtime +behaviour, exercise it (hit the endpoint, render the page). + +## Repository layout + +| Path | Purpose | +| --- | --- | +| `cmd/server` | Entry point. Wiring only — no business logic. | +| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. | +| `internal/server` | HTTP server, routes, `/health`. | +| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. | +| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. | +| `migrations/` | SQL migrations (planned). | +| `web/` | React PWA source. | +| `deploy/` | Deployment files (planned). | +| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). | +| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. | +| `Dockerfile` | Multi-stage image build, at the repository root. | +| `Makefile` | `dev`, `test`, `build`, `docker`. | +| `.gitea/workflows/ci.yml` | CI quality floor. | + +Design references: + +- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) +- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) +- [design/MealQuest UI Designs/design_handoff_mealquest/tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css) + +## Branches, pull requests and merges + +- Integration branch is `dev`. `main` is the release branch. +- **Feature branches:** `feature/-`, branched from `dev`. +- **Task branches:** `task/_`, branched from the feature branch. +- Task pull requests **squash-merge into the feature branch**. +- The feature pull request merges into `dev`. +- **Only `overseer` merges `dev` → `main`** (a release). +- Never push directly to `dev` or `main`. +- Authors never review, approve, merge or close their own work. +- Never store credentials in the repository; environment or ignored files only. + +## Where context lives and where work is tracked + +- **Context:** CTXD project `mealquest` — client guide at + . Project documents: `CONTEXT.MD` + (architecture), `DECISIONS.MD` (decision log, e.g. D-021), `RUNBOOKS.MD`, + `PROMPTS.MD`, `GLOSSARY.MD`. +- **Work tracking:** Maestro at , + workspace **Maniacs-Mansion** → project **MealQuest**. Every change has a + task with a verifiable done-condition. + +## Vocabulary (GLOSSARY) + +Use these product terms in code names, UI copy, docs and commit messages. Do +not invent synonyms. + +| Term | Meaning | +| --- | --- | +| **Regulars** | Dinners the household makes routinely. | +| **Side quest** | An AI-suggested new dinner to try. | +| **Quest log** | The to-try pool of accepted Side quests, scheduled later. | +| **Try it / Pass** | Accept (swipe right) / decline (swipe left) a suggestion. | +| **Rotation check** / **"Keep it in rotation?"** | The prompt after a Quest-log dinner is tried, asking whether it becomes a Regular. Promotion is always the household's choice, never automatic. | +| **Supply run** | The current week's shopping list, grouped by dinner. | +| **Supply archive** | The blog-style history of past shopping lists (by week, month, year). | +| **Draft board** | The mobile weekly plan layout the AI drafts for the household to adjust. | +| **Rebuild week** | Ask the AI to redraft the week's plan. | +| **Push to Calendar** | Push the plan one-way to Google Calendar. | +| **Quick dinner** | Cooked in a crock pot **or** 30 minutes or less from prep to table. | +| **Cook from what I have** | Ideas from a list of ingredients on hand. | +| **Kitchen reading mode** | Tablet recipe view: large text, ingredient checkboxes, step-by-step. | +| **"Why this?"** | The reason shown for a suggestion, referencing the household's Regulars. | +| **Our kitchen / Shared household** | The single shared household identity; no per-person profiles. | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ef5a49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# Base images are pinned by digest (multi-arch index); tags are for readability. + +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS server +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Replace the placeholder with the built PWA; recreate the directory so no +# stale file survives into the embed. +RUN rm -rf internal/frontend/dist +COPY --from=web /src/web/dist/ internal/frontend/dist/ +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o /out/mealquest ./cmd/server + +# Alpine ships the CA bundle and busybox wget for the health check. +FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +RUN addgroup -S -g 10001 mealquest \ + && adduser -S -D -H -u 10001 -G mealquest mealquest \ + && mkdir /data && chown mealquest:mealquest /data +COPY --from=server /out/mealquest /usr/local/bin/mealquest +ENV MEALQUEST_HTTP_ADDR=:8080 \ + MEALQUEST_DATA_DIR=/data +VOLUME /data +EXPOSE 8080 +USER 10001:10001 +# Probe the listener from MEALQUEST_HTTP_ADDR: host is everything before the +# last colon (IPv6 keeps its brackets). Wildcard hosts map to loopback, as +# does localhost: Go listens on its IPv4 address but wget would try ::1. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD host="${MEALQUEST_HTTP_ADDR%:*}"; \ + case "$host" in ""|0.0.0.0|"[::]"|localhost) host=127.0.0.1 ;; esac; \ + wget -q -O /dev/null "http://${host}:${MEALQUEST_HTTP_ADDR##*:}/health" || exit 1 +ENTRYPOINT ["/usr/local/bin/mealquest"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4356184 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# MealQuest build entry points. +# +# make dev Go server on :8080 plus Vite dev server on :5173 (proxies +# /api, /auth, /media to :8080). Needs the MEALQUEST_* env set. +# make test gofmt check, go vet, go test -race, npm typecheck and test. +# make build Build the PWA, embed it, build the static binary bin/mealquest. +# make docker Build the container image from ./Dockerfile. +# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc. +# make sqlc-check Regenerate; fail if internal/db differs from the commit. +# make sqlc-install Install the pinned sqlc into the Go bin directory. + +FRONTEND_DIST := internal/frontend/dist +# Dirty means a tracked change outside the generated embed output, which +# `make embed` rewrites; plain `--dirty` would flag every rebuild. +GIT_DIRTY := $(shell git status --porcelain --untracked-files=no -- . ':(exclude)$(FRONTEND_DIST)' 2>/dev/null) +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(GIT_DIRTY),-dirty) +IMAGE ?= mealquest:$(VERSION) +LDFLAGS := -s -w -X main.version=$(VERSION) + +# sqlc is a dev tool only, never a go.mod dependency. Generated code records +# the version that wrote it, so every run must use exactly this one. +SQLC_VERSION := v1.31.1 +SQLC ?= sqlc +SQLC_OUT := internal/db + +.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install + +# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built +# first and run directly because `go run` would orphan its child on kill. +dev: SHELL := /bin/bash +dev: + go build -ldflags "$(LDFLAGS)" -o bin/mealquest-dev ./cmd/server + @trap 'kill $$(jobs -p) 2>/dev/null; wait' EXIT; \ + bin/mealquest-dev & \ + (cd web && exec npm run dev) & \ + wait -n + +test: go-test web-test + +go-test: + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi + go vet ./... + go test -race ./... + +web-test: + cd web && npm ci && npm run typecheck && npm test + +build: embed + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server + +web: + cd web && npm ci && npm run build + +# Replace the committed placeholder with the built PWA so go:embed picks it up. +# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds. +embed: web + rm -rf $(FRONTEND_DIST) + mkdir -p $(FRONTEND_DIST) + cp -R web/dist/. $(FRONTEND_DIST)/ + +# Regression check: a stale hidden file must not survive an embed. +check-embed-clean: + touch $(FRONTEND_DIST)/.stale-embed-check + $(MAKE) embed + test ! -e $(FRONTEND_DIST)/.stale-embed-check + +docker: + docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + +# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch +# it for this install only, even where the environment pins the local Go. +sqlc-install: + GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) + +# Previously generated files are removed first so a dropped query file does +# not leave its stale output behind; hand-written files are kept. +sqlc: + @v="$$($(SQLC) version)"; \ + if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi + find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm -- + $(SQLC) generate + +# Fails on any difference from the committed generated code, including new +# or deleted files. +sqlc-check: sqlc + @drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \ + if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi diff --git a/README.md b/README.md index e69de29..f9f43e2 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,80 @@ +# MealQuest + +MealQuest is a mobile-first Progressive Web App for weekly dinner planning for +one household. The household lists the dinners it makes regularly, an AI +assistant suggests new dinners to try (swipe "Try it" or "Pass"), and the app +builds a weekly plan, pushes it to Google Calendar, and generates a shopping +list grouped by dinner, kept as a browsable weekly history. It ships as a +single Go service with the React PWA embedded in the binary. + +> Status: this repository is the Feature 01 scaffold (Go service with +> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned +> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Quick start + +Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that +is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0 +or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the +`engines` range of jsdom in the committed lockfile, the strictest of all the +web dependencies. + +```sh +(cd web && npm ci) +mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret +chmod 700 ~/.mealquest-dev +head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key +chmod 600 ~/.mealquest-dev/secret_key +export MEALQUEST_HTTP_ADDR=:8080 +export MEALQUEST_PUBLIC_URL=http://localhost:5173 +export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev" +export MEALQUEST_SHUTDOWN_TIMEOUT=10s +export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key" +# Authentik OAuth2/OpenID provider for MealQuest (redirect URI: +# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file. +export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/ +export MEALQUEST_OIDC_CLIENT_ID=mealquest +export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret" +export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin +# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h) +# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h). +# export MEALQUEST_SESSION_IDLE=720h +# export MEALQUEST_SESSION_MAX=4320h +# Optional: comma-separated IPs/CIDRs of reverse proxies whose +# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset +# trusts no peer, which is right for local development. +# export MEALQUEST_TRUSTED_PROXY=10.60.1.33 +export MEALQUEST_DEFAULT_TZ=America/New_York +make dev +``` + +`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173` +(which proxies `/api`, `/auth` and `/media` to `:8080`). Open +http://localhost:5173. Configuration is environment-only and fails fast: +every variable above is required except the commented-out session +lifetimes (when set they must be positive, with MAX at least IDLE), +trusted proxy list (every entry must be an IP or CIDR) and +`MEALQUEST_DEFAULT_TZ`, and +secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the +endpoints the issuer advertises must be `https://`; plain `http://` is +accepted only for `localhost`, `127.0.0.1` and `[::1]`. +`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created +with on first boot: until those settings exist, boot fails unless it is a +valid IANA zone. Later boots ignore it, set or not (change the zone in +household settings instead). + +## Common commands + +| Command | What it does | +| -------------- | --------------------------------------------------------------- | +| `make dev` | Go server plus Vite dev server. | +| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. | +| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. | +| `make docker` | Build the container image from the root `Dockerfile`. | + +## Where to read next + +- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions. +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record. +- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements. +- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff. diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go new file mode 100644 index 0000000..082b579 --- /dev/null +++ b/cmd/server/boot_test.go @@ -0,0 +1,370 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// useBootMigrations swaps bootMigrations for the test and restores it after. +// Tests that call it must not run in parallel. +func useBootMigrations(t *testing.T, m fstest.MapFS) { + t.Helper() + saved := bootMigrations + bootMigrations = m + t.Cleanup(func() { bootMigrations = saved }) +} + +// loadConfig loads a configuration through config.Load whose secret key file +// holds keyFile. Tests that call it must not run in parallel. +func loadConfig(t *testing.T, keyFile string) config.Config { + t.Helper() + dir := t.TempDir() + keyPath := filepath.Join(dir, "secret_key") + if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil { + t.Fatal(err) + } + clientSecretPath := filepath.Join(dir, "oidc_client_secret") + if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0") + t.Setenv(config.EnvPublicURL, "http://localhost") + t.Setenv(config.EnvDataDir, t.TempDir()) + t.Setenv(config.EnvShutdownTimeout, "1s") + t.Setenv(config.EnvSecretKeyFile, keyPath) + t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/") + t.Setenv(config.EnvOIDCClientID, "mealquest") + t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath) + t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin") + // MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value. + t.Setenv(config.EnvDefaultTZ, "") + if err := os.Unsetenv(config.EnvDefaultTZ); err != nil { + t.Fatal(err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + return cfg +} + +// validKeyFile returns secret key file contents as written by the README: +// base64 of 32 random bytes followed by a newline. +func validKeyFile(t *testing.T) string { + t.Helper() + key := make([]byte, secretbox.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(key) + "\n" +} + +var brokenMigrations = fstest.MapFS{ + "0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")}, + "0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")}, +} + +func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) { + useBootMigrations(t, brokenMigrations) + + db, err := openStore(context.Background(), t.TempDir()) + if err == nil { + db.Close() + t.Fatal("openStore succeeded with a broken migration") + } + if db != nil { + t.Error("openStore returned a store alongside the error") + } + if !strings.Contains(err.Error(), "0001_broken.sql") { + t.Errorf("error does not name the broken migration: %v", err) + } +} + +func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) { + ctx := context.Background() + db, err := openStore(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + var n int + if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil { + t.Fatal(err) + } + files, err := fs.Glob(migrations.FS, "*.sql") + if err != nil { + t.Fatal(err) + } + if n != len(files) { + t.Errorf("schema_migrations has %d rows, want %d", n, len(files)) + } +} + +func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) { + useBootMigrations(t, brokenMigrations) + + var logs bytes.Buffer + cfg := loadConfig(t, validKeyFile(t)) + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + select { + case err := <-errc: + if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") { + t.Fatalf("run error = %v, want the broken migration's error", err) + } + case <-time.After(10 * time.Second): + t.Fatal("run kept going after a migration failure") + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening despite the migration failure: %s", logs.String()) + } +} + +func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) { + cfg := loadConfig(t, validKeyFile(t)) + box, err := openSecretBox(cfg.SecretKey) + if err != nil { + t.Fatal(err) + } + token, err := box.Encrypt([]byte("x")) + if err != nil { + t.Fatal(err) + } + if _, err := box.Decrypt(token); err != nil { + t.Fatal(err) + } +} + +func TestRunBadSecretKeyStopsBoot(t *testing.T) { + short := base64.StdEncoding.EncodeToString(make([]byte, 31)) + tests := []struct { + name string + keyFile string + want error + }{ + {"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding}, + {"wrong length", short + "\n", secretbox.ErrKeyLength}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadConfig(t, tt.keyFile) + var logs bytes.Buffer + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + var err error + select { + case err = <-errc: + case <-time.After(10 * time.Second): + t.Fatal("run kept going with a bad secret key") + } + if !errors.Is(err, tt.want) { + t.Fatalf("run error = %v, want %v", err, tt.want) + } + if !strings.Contains(err.Error(), config.EnvSecretKeyFile) { + t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err) + } + if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) { + t.Errorf("error leaks the key: %v", err) + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening despite the bad key: %s", logs.String()) + } + if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) { + t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr) + } + }) + } +} + +func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) { + loadConfig(t, validKeyFile(t)) // sets every other variable + t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing")) + _, err := config.Load() + var cfgErr *config.Error + if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) { + t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile) + } +} + +func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) { + tests := []struct { + name string + defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset + want error + }{ + {"unset", nil, household.ErrNoDefaultZone}, + {"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loadConfig(t, validKeyFile(t)) + if tt.defaultTZ != nil { + t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + var logs bytes.Buffer + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + select { + case err = <-errc: + case <-time.After(10 * time.Second): + t.Fatal("run kept going with no household settings and no valid default zone") + } + if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) { + t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ) + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening without household settings: %s", logs.String()) + } + }) + } +} + +// A reboot over existing household settings must not depend on +// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding +// ignores it, keeping the stored zone. +func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) { + ctx := context.Background() + cfg := loadConfig(t, validKeyFile(t)) + + db, err := openStore(ctx, cfg.DataDir) + if err != nil { + t.Fatal(err) + } + if err := seedSettings(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons") + cfg, err = config.Load() + if err != nil { + t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err) + } + db, err = openStore(ctx, cfg.DataDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil { + t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err) + } + got, err := household.NewSettingsService(db).Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.Timezone != "America/Denver" { + t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone) + } +} + +func ptr[T any](v T) *T { return &v } + +func TestSeedSettingsAtBoot(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds from the default zone. + db, err := openStore(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := seedSettings(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + // A later boot needs no default zone and keeps the stored one. + db, err = openStore(ctx, dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedSettings(ctx, db, ""); err != nil { + t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err) + } + got, err := household.NewSettingsService(db).Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.Timezone != "America/Denver" { + t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone) + } +} + +func TestSeedDefaultsAtBoot(t *testing.T) { + ctx := context.Background() + db, err := openStore(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedDefaults(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + + if _, err := household.NewSettingsService(db).Get(ctx); err != nil { + t.Errorf("household settings: %v", err) + } + var provider string + if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil { + t.Errorf("AI settings: %v", err) + } else if provider != household.SeedAIProvider { + t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider) + } + var prefs int + if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil { + t.Fatal(err) + } + if prefs != 5 { + t.Errorf("%d notification preferences, want 5", prefs) + } + + // A second boot seeds nothing new and succeeds. + if err := seedDefaults(ctx, db, ""); err != nil { + t.Fatalf("reboot: %v", err) + } +} diff --git a/cmd/server/jobs_test.go b/cmd/server/jobs_test.go new file mode 100644 index 0000000..8d02103 --- /dev/null +++ b/cmd/server/jobs_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job: +// a noop job enqueued before Start, as at boot, succeeds with its result. +func TestNewJobRunnerRunsNoop(t *testing.T) { + ctx := context.Background() + db, err := openStore(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{}) + if err != nil { + t.Fatal(err) + } + + jobsCtx, stopJobs := context.WithCancel(ctx) + if err := runner.Start(jobsCtx); err != nil { + stopJobs() + t.Fatal(err) + } + t.Cleanup(func() { + stopJobs() + runner.Wait() + }) + + deadline := time.Now().Add(5 * time.Second) + for { + job, err := runner.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil { + t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("noop job never finished; last %+v", job) + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4f46d29 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,227 @@ +// Command server runs the MealQuest HTTP service. +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// version is set at build time with -ldflags "-X main.version=". +var version = "dev" + +// jobWorkers is the number of background jobs run at once. +const jobWorkers = 2 + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil))) + if err := run(cfg, logger); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} + +func run(cfg config.Config, logger *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if _, err := openSecretBox(cfg.SecretKey); err != nil { + return err + } + + db, err := openStore(ctx, cfg.DataDir) + if err != nil { + return err + } + defer func() { + if err := db.Close(); err != nil { + logger.Error("close database", "error", err) + } + }() + + if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil { + return err + } + settings := household.NewSettingsService(db) + runner, _, err := newJobRunner(db, logger) + if err != nil { + return err + } + + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) + if err != nil { + return err + } + authFlow, err := auth.New(ctx, auth.Config{ + Issuer: cfg.OIDCIssuer.String(), + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret.Reveal(), + PublicURL: cfg.PublicURL, + SecretKey: []byte(cfg.SecretKey.Reveal()), + AllowedGroups: cfg.OIDCAllowedGroups, + }, sessions, logger) + if err != nil { + return err + } + + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets()) + if err != nil { + return err + } + + // Start fails the jobs a previous process left running before any + // worker runs. The workers stop, and are waited for, before the + // database closes. + jobsCtx, stopJobs := context.WithCancel(ctx) + defer func() { + stopJobs() + runner.Wait() + }() + if err := runner.Start(jobsCtx); err != nil { + return err + } + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: router, + ReadHeaderTimeout: 10 * time.Second, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + errc := make(chan error, 1) + go func() { + logListening(logger, cfg) + errc <- srv.ListenAndServe() + }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + + logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String()) + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + if err := <-errc; !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// openSecretBox builds the secret box from the configured key, naming the +// variable whose file holds a bad key. The key itself is never reported. +func openSecretBox(key config.Secret) (*secretbox.Box, error) { + box, err := secretbox.New(key.Reveal()) + if err != nil { + return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err) + } + return box, nil +} + +// bootMigrations is the migration set applied at boot. Tests replace it to +// prove a failing migration stops the boot. +var bootMigrations fs.FS = migrations.FS + +// openStore opens the database in dataDir and applies bootMigrations. On a +// migration failure it closes the database and returns the error. +func openStore(ctx context.Context, dataDir string) (*store.Store, error) { + db, err := store.Open(ctx, dataDir) + if err != nil { + return nil, err + } + if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil { + return nil, errors.Join(err, db.Close()) + } + return db, nil +} + +// seedSettings creates the household settings on first boot, naming the +// variable that supplies the zone when it is missing or invalid. Once the +// settings exist the variable is ignored. +func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error { + err := household.SeedSettings(ctx, db, defaultTZ) + if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) { + return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err) + } + return err +} + +// seedDefaults creates the household settings, AI settings and notification +// preferences that do not exist yet. Existing rows are never changed. +func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error { + if err := seedSettings(ctx, db, defaultTZ); err != nil { + return err + } + if err := household.SeedAISettings(ctx, db); err != nil { + return err + } + return household.SeedNotificationPrefs(ctx, db) +} + +// jobKinds enqueues every job kind the server runs. +type jobKinds struct { + builtins *jobs.Builtins +} + +// newJobRunner returns the job runner with every job kind registered, ready +// to Start. +func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) { + runner, err := jobs.NewRunner(db, logger, jobWorkers) + if err != nil { + return nil, nil, err + } + kinds, err := registerJobKinds(runner) + if err != nil { + return nil, nil, err + } + return runner, kinds, nil +} + +// registerJobKinds registers every job kind's handler on runner. Each +// feature's job kinds are registered here. +func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { + builtins, err := jobs.RegisterBuiltins(runner) + if err != nil { + return nil, err + } + return &jobKinds{builtins: builtins}, nil +} + +// logListening logs the startup line. Only the public URL's origin is logged +// so no credential-bearing URL component can reach the log. +func logListening(logger *slog.Logger, cfg config.Config) { + logger.Info("listening", + "addr", cfg.HTTPAddr, + "public_origin", cfg.PublicOrigin(), + "trusted_proxy", cfg.TrustedProxy, + "data_dir", cfg.DataDir, + "version", version, + ) +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..02748e8 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "bytes" + "log/slog" + "net/url" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" +) + +func TestLogListeningOmitsURLCredentials(t *testing.T) { + t.Parallel() + + // Load rejects such URLs; this proves the log stays safe even if one + // reached the Config some other way. + u, err := url.Parse("https://alice:hunter2@meals.example.com/app?token=t0ken") + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + logListening(slog.New(slog.NewJSONHandler(&buf, nil)), config.Config{HTTPAddr: ":8080", PublicURL: u}) + + out := buf.String() + for _, leak := range []string{"alice", "hunter2", "t0ken"} { + if strings.Contains(out, leak) { + t.Errorf("startup log leaks %q: %s", leak, out) + } + } + if !strings.Contains(out, `"public_origin":"https://meals.example.com"`) { + t.Errorf("startup log missing public origin: %s", out) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6f9eaab --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,244 @@ +# MealQuest architecture + +This mirrors the architecture section of `CONTEXT.MD` in the CTXD project +`mealquest`, which is the source of record; if the two disagree, CTXD wins and +this file should be updated. It describes the **planned** application. The +repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`, +`internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA +fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI. + +Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD. + +## Topology + +``` +PWA (phone / tablet / desktop) + │ HTTPS + ▼ +Traefik v3.6 — 10.60.1.33 + file route: mealquest-ccl.yml + │ + ▼ +docker compose project "mealquest" — 10.60.1.14 + ├── mealquest Go service + embedded PWA, host 8090 → container 8080 + ├── searxng sidecar for recipe search; internal compose network + │ only, no published port + └── Litestream SQLite replication → Synology NAS 10.60.1.50 +``` + +One Go service serves the API, the auth flow, media and the PWA. Its SQLite +database is replicated by Litestream to the NAS. + +Traefik terminates TLS. The service honours `X-Forwarded-For` and +`X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY` +(comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other +peer they are ignored. The client is the rightmost `X-Forwarded-For` address +that is not a trusted proxy. The resolved client IP is added as `client_ip` +to every log line written while handling a request. The OIDC redirect URI +and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request +headers, so sign-in works whichever peer the request arrives from. + +## Repository layout + +| Path | Purpose | +| --- | --- | +| `cmd/server` | Entry point; wiring only. | +| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | +| `internal/server` | HTTP server, router, middleware. | +| `internal/frontend` | Embedded PWA, SPA fallback. | +| `internal/auth` | Authentik OIDC, sessions, CSRF. | +| `internal/db`, `internal/store` | SQLite connections, sqlc queries. | +| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. | +| `internal/household`, `internal/dinners`, `internal/recipes` | Domain. | +| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | +| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | +| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | +| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | +| `migrations/` | SQL migrations (no goose or golang-migrate). | +| `web/` | React PWA. | +| `deploy/` | Deployment files. | +| `docs/` | Documentation. | +| `design/` | Requirements and UI handoff (read-only reference). | + +## Backend stack and conventions + +- Go 1.25, `CGO_ENABLED=0`, a single static binary. +- Router: `go-chi/chi/v5`. Logging: `log/slog` JSON. +- SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`, + `foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read + pool.** Queries generated by `sqlc`. +- IDs are ULIDs (`oklog/ulid/v2`). +- Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings + in the household timezone. +- Configuration is environment-only (`MEALQUEST_*`). Secrets come only from + files named by `*_FILE` variables. Missing or invalid config stops startup. +- Error responses always have the body + `{"error":{"code":"…","message":"…","retryable":false}}`. + +## Authentication + +- Authentik OIDC; the backend is a **confidential client** + (`coreos/go-oidc/v3`, `golang.org/x/oauth2`). +- Session cookie `__Host-mealquest_session` (HttpOnly, Secure, + SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute. +- Non-GET requests require the CSRF header `X-MealQuest-CSRF`. +- Access requires membership of the Authentik group `mealquest-admin`. + There is a **single role**: one shared household, no per-person profiles. + +## Data model + +Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). + +| Table | Holds | +| --- | --- | +| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. | +| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. | +| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. | +| `recipe_steps` | recipe_id, position, text. | +| `photos` | source_url, local_path, width, height, sha256, fetched_at. | +| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. | +| `suggestion_batches` | trigger, status, ai_run_id. | +| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. | +| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | +| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | +| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | +| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | +| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | +| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | +| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. | +| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | +| `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. | +| `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. | +| `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. | +| `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. | +| `jobs` | kind, status, payload, result (progressive), error, timestamps. | +| `schedule_runs` | schedule, period_key (unique). | +| `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). | +| `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. | + +`recipes` holds a fetched recipe page; `dinners` holds the household's meal +record and its lifecycle, optionally linked to a recipe and a photo. + +## Dinner lifecycle + +``` +suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried + │ ▲ │ + │ └──────── explicit decision ─────┤ + │ ├──▶ regular + │ └──▶ archived + └──Pass──▶ archived (name kept for dedupe) +``` + +From `tried`, an explicit decision moves the dinner to `regular`, to +`archived`, or back to `quest_log`. Promotion to `regular` is **never +automatic** (D-021). + +## API surface + +- JSON API under `/api/v1`, using the error body above for every failure. + It requires the session cookie and, for non-GET requests, the + `X-MealQuest-CSRF` header. +- `/auth/*` — the OIDC flow. +- `/media/*` — cached photos. +- `/health` — liveness. +- Everything else serves the embedded PWA with SPA fallback. + +## AI layer + +- **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible, + Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs. +- **Skill harness** (`ai/harness`): prompt layering, validation of the + structured output, and **one repair retry**; if the repair also fails the + run fails — no silent fallback. Every call is recorded in `ai_runs`. + +## Recipe grounding + +- Dinners are grounded in real recipe pages found through recipe search + (`searxng` or `brave`, per `recipe_search_settings`). +- Fetching is **SSRF-safe**. +- Recipe data is parsed from schema.org `Recipe` JSON-LD. +- Photos are cached locally (`photos`) and processed with + `golang.org/x/image`. + +## Scheduler + +Times are in the household timezone. The times of `day_of_reminder` +(`reminder_time`) and `rotation_check` (`review_time`), and the day and time +of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are +household settings in `household_settings` (`dinner_time` is also stored +there); the table shows their defaults. `week_rollover` and the `ai_runs` +purge are fixed and not configurable. + +| Schedule | When | Does | +| --- | --- | --- | +| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | +| `plan_draft` | Friday 09:00 | AI drafts next week's plan. | +| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | +| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | +| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | +| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. | +| `ai_runs` purge | Daily (fixed) | Deletes `ai_runs` rows past 30-day retention. | + +Runs are recorded in `schedule_runs` and executed through `jobs`. + +## Notifications + +- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, + `rotation_review`; each can go to Web Push and/or Google Chat per + `notification_prefs`. +- Delivery goes through `notification_outbox`. +- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming + webhook. + +## Google Calendar + +- One-way push: the app is the source of truth; edits in Google Calendar do + not come back. +- Connected once with Google OAuth in Settings; one event per planned + dinner. +- **Deterministic event ids**; each plan day records its + `calendar_event_id`. + +## Frontend + +- React 19, TypeScript, Vite, React Router 7, TanStack Query 5, + `vite-plugin-pwa`, `lucide-react` (stroke 2.75). +- CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no + hard-coded hex. Self-hosted Bagel Fat One and Figtree. +- Tests: Vitest, Testing Library, jsdom. +- Offline cache for the current plan, shopping list and recipes. + +| Route | Screen | +| --- | --- | +| `/` | Home | +| `/onboarding` | Add regular dinners | +| `/plan` | Weekly plan (Draft board) | +| `/plan/shopping` | Shopping list (Supply run) | +| `/plan/shopping/archive` | Supply archive | +| `/discover` | Swipe suggestions (Side quests) | +| `/quick` | Quick dinners | +| `/cook-from-pantry` | Cook from what I have | +| `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) | +| `/settings` | Settings home | +| `/settings/household` | Household settings | +| `/settings/regulars` | Regular dinners | +| `/settings/calendar` | Google Calendar connection | +| `/settings/notifications` | Notifications and Google Chat | +| `/settings/ai` | Admin · AI assistant | + +`/settings/household`, `/settings/regulars` and `/settings/calendar` come from +CTXD; the other routes are defined in the UI handoff. + +## Boundaries and gotchas + +- **Secrets never reach the client** — API keys, OAuth tokens and the Chat + webhook stay server-side, encrypted at rest. +- **The app is the source of truth for the plan**; Google Calendar is a + one-way mirror. +- **Never auto-promote to Regular** (D-021). +- **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table. +- **Weeks run Monday–Sunday** in the household timezone. +- **No silent fallbacks** — fail fast and report. +- **iOS Web Push requires the PWA to be installed to the Home Screen**; show + an install hint. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..39a6ce9 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest + +go 1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.21.0 + github.com/go-chi/chi/v5 v5.3.1 + github.com/oklog/ulid/v2 v2.1.2 + golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba3f090 --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/actor.go b/internal/auth/actor.go new file mode 100644 index 0000000..c781091 --- /dev/null +++ b/internal/auth/actor.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "slices" +) + +// Actor is the signed-in household member a request acts for. Handlers +// record Actor.Subject as updated_by on edits. +type Actor struct { + Subject string + Email string + Name string + Groups []string +} + +// Actor returns the household member s belongs to. +func (s Session) Actor() Actor { + return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)} +} + +type sessionKey struct{} + +// WithSession returns a copy of ctx carrying s. The session middleware calls +// it for every authenticated request. +func WithSession(ctx context.Context, s Session) context.Context { + return context.WithValue(ctx, sessionKey{}, s) +} + +// SessionFrom returns the session WithSession stored in ctx. ok is false when +// ctx has none, which behind the session middleware means a wiring bug. +func SessionFrom(ctx context.Context) (s Session, ok bool) { + s, ok = ctx.Value(sessionKey{}).(Session) + return s, ok +} + +// ActorFrom returns the actor of the session WithSession stored in ctx. ok is +// false when ctx has none. +func ActorFrom(ctx context.Context) (actor Actor, ok bool) { + s, ok := SessionFrom(ctx) + if !ok { + return Actor{}, false + } + return s.Actor(), true +} diff --git a/internal/auth/actor_test.go b/internal/auth/actor_test.go new file mode 100644 index 0000000..fff57cb --- /dev/null +++ b/internal/auth/actor_test.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "slices" + "testing" +) + +func TestActorContext(t *testing.T) { + t.Parallel() + if _, ok := ActorFrom(context.Background()); ok { + t.Error("ActorFrom found an actor in an empty context") + } + if _, ok := SessionFrom(context.Background()); ok { + t.Error("SessionFrom found a session in an empty context") + } + s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"} + ctx := WithSession(context.Background(), s) + gotSession, ok := SessionFrom(ctx) + if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken { + t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s) + } + got, ok := ActorFrom(ctx) + if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) { + t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok) + } +} + +func TestSessionActor(t *testing.T) { + t.Parallel() + s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"} + a := s.Actor() + if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) { + t.Errorf("Actor = %+v, want the session's identity", a) + } + a.Groups[0] = "changed" + if s.Groups[0] != "mealquest-admin" { + t.Error("Actor shares the session's groups slice") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..d82d33d --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,439 @@ +// Package auth signs the household in with Authentik over OpenID Connect. +// The service is a confidential client: the authorization code, tokens and +// client secret stay on the server and never reach JavaScript. +package auth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "slices" + "sync/atomic" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" +) + +// Routes owned by this package. +const ( + LoginPath = "/auth/login" + CallbackPath = "/auth/callback" + LogoutPath = "/auth/logout" +) + +// providerTimeout bounds every request to the identity provider. +const providerTimeout = 10 * time.Second + +// scopes requested at login. +var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess} + +// Claims are the verified ID token claims handed to the session layer. +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups"` +} + +// Sessions is the session layer. StartSession is called once per successful +// login with claims from a verified ID token; it must set the session cookie +// on w. EndSession is called on logout; it must end the session r carries, +// if any, and clear the cookie on w. A returned error fails the request with +// 500. +type Sessions interface { + StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error + EndSession(w http.ResponseWriter, r *http.Request) error +} + +// Config is what New needs to reach the identity provider. +type Config struct { + // Issuer is the exact issuer URL; discovery runs against it. + Issuer string + ClientID string + ClientSecret string + // PublicURL is the service's public URL; its origin prefixes CallbackPath + // to form the redirect URI. + PublicURL *url.URL + // SecretKey is the server key the login cookie's signing key derives from. + SecretKey []byte + // AllowedGroups are the household's groups; a sign-in is refused unless + // the account is in at least one of them. + AllowedGroups []string +} + +// Auth serves the login and callback routes. +type Auth struct { + oauth oauth2.Config + verifier *oidc.IDTokenVerifier + provider *oidc.Provider + client *http.Client + cookie loginCookie + sessions Sessions + allowed []string + // endSessionURL is the provider's end_session_endpoint; logout ends + // there so the provider's own session ends too. + endSessionURL string + logger *slog.Logger +} + +// New discovers the provider at cfg.Issuer and returns the login flow. It +// fails if discovery fails or the issuer the provider reports differs. +func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) { + switch { + case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "": + return nil, errors.New("auth: issuer, client ID and client secret are required") + case cfg.PublicURL == nil: + return nil, errors.New("auth: public URL is required") + case len(cfg.SecretKey) == 0: + return nil, errors.New("auth: secret key is required") + case sessions == nil: + return nil, errors.New("auth: session layer is required") + case len(cfg.AllowedGroups) == 0: + return nil, errors.New("auth: at least one allowed group is required") + } + + if err := checkEndpoint("issuer", cfg.Issuer); err != nil { + return nil, err + } + + client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect} + discovery := *client + recorder := &statusRecorder{next: http.DefaultTransport} + discovery.Transport = recorder + provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer) + if err != nil { + return nil, discoveryError(err, int(recorder.status.Load())) + } + endpoint := provider.Endpoint() + var discovered struct { + JWKSURI string `json:"jwks_uri"` + EndSessionURI string `json:"end_session_endpoint"` + } + if err := provider.Claims(&discovered); err != nil { + return nil, fmt.Errorf("auth: OIDC discovery: %w", err) + } + // The token endpoint receives the client secret and the key set decides + // which tokens verify, so every endpoint used must pass the same rule. + for _, e := range []struct{ name, raw string }{ + {"authorization endpoint", endpoint.AuthURL}, + {"token endpoint", endpoint.TokenURL}, + {"JWKS endpoint", discovered.JWKSURI}, + {"end session endpoint", discovered.EndSessionURI}, + // Groups come from userinfo when the ID token carries none, and the + // access token is sent there. + {"userinfo endpoint", provider.UserInfoEndpoint()}, + } { + if err := checkEndpoint(e.name, e.raw); err != nil { + return nil, err + } + } + // client_secret_basic, the OIDC default; no auto-detection retries. + endpoint.AuthStyle = oauth2.AuthStyleInHeader + + return &Auth{ + oauth: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: endpoint, + RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath, + Scopes: scopes, + }, + verifier: provider.Verifier(&oidc.Config{ + ClientID: cfg.ClientID, + SupportedSigningAlgs: []string{oidc.RS256}, + }), + provider: provider, + client: client, + allowed: slices.Clone(cfg.AllowedGroups), + cookie: newLoginCookie(cfg.SecretKey), + sessions: sessions, + endSessionURL: discovered.EndSessionURI, + logger: logger, + }, nil +} + +// statusRecorder records the status of the last response it carries. +type statusRecorder struct { + next http.RoundTripper + status atomic.Int32 +} + +func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := s.next.RoundTrip(req) + if err == nil { + s.status.Store(int32(resp.StatusCode)) + } + return resp, err +} + +// discoveryError reports a failed discovery. go-oidc puts the provider's +// response body in its error for a non-200 discovery response, so that +// error is replaced by the status alone. Transport errors carry no response +// content and keep their cause. +func discoveryError(err error, status int) error { + var urlErr *url.Error + if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK { + return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status) + } + return fmt.Errorf("auth: OIDC discovery: %w", err) +} + +// checkEndpoint applies secureurl.Check to the provider URL raw, naming the +// endpoint in the error. +func checkEndpoint(name, raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw) + } + if err := secureurl.Check(u); err != nil { + return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err) + } + return nil +} + +// maxRedirects matches net/http's default redirect limit. +const maxRedirects = 10 + +// checkRedirect is the provider client's redirect policy. On a 307 or 308 to +// the same host Go resends the body and Authorization header, which carry the +// code and client secret, so every target must pass secureurl.Check and an +// https:// request never follows a redirect to plain http://, even loopback. +func checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + if err := secureurl.Check(req.URL); err != nil { + return fmt.Errorf("redirect refused: %w", err) + } + if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" { + return errors.New("redirect refused: https:// to plain http:// downgrade") + } + return nil +} + +// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r. +func (a *Auth) Mount(r chi.Router) { + r.Get(LoginPath, a.login) + r.Get(CallbackPath, a.callback) + r.Post(LogoutPath, a.logout) +} + +// EndSessionURL is the provider's end_session_endpoint. +func (a *Auth) EndSessionURL() string { return a.endSessionURL } + +// login starts the authorization code flow with PKCE (S256). The optional +// "return" query parameter must be a same-origin path; anything else fails +// the login with 400. +func (a *Auth) login(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + returnTo, err := returnPath(r.URL.Query().Get("return")) + if err != nil { + http.Error(w, "invalid return path", http.StatusBadRequest) + return + } + state := loginState{ + State: rand.Text(), + Nonce: rand.Text(), + Verifier: oauth2.GenerateVerifier(), + Return: returnTo, + } + if err := a.cookie.set(w, state, time.Now()); err != nil { + a.logger.ErrorContext(r.Context(), "set login cookie", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, a.oauth.AuthCodeURL(state.State, + oidc.Nonce(state.Nonce), + oauth2.S256ChallengeOption(state.Verifier), + ), http.StatusFound) +} + +// callback completes the flow: it checks state against the login cookie, +// exchanges the code, verifies the ID token and nonce, then starts a session +// if the account is in an allowed group. Membership is checked on every +// login, so an account removed from the group is refused at its next one. +func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + ctx := r.Context() + // The login cookie is single-use. + a.cookie.clear(w) + + state, err := a.cookie.read(r, time.Now()) + if err != nil { + a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err) + http.Error(w, "sign-in expired; start again", http.StatusBadRequest) + return + } + q := r.URL.Query() + if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 { + a.logger.WarnContext(ctx, "login callback state mismatch") + http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest) + return + } + if q.Get("error") != "" { + // error and error_description are provider-supplied text; only the + // fixed category is logged. + a.logger.WarnContext(ctx, "identity provider returned an authorization error") + http.Error(w, "sign-in was refused", http.StatusUnauthorized) + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + + claims, err := a.verify(ctx, code, state) + if err != nil { + a.logger.WarnContext(ctx, "sign-in failed", "error", err) + http.Error(w, "sign-in failed", http.StatusUnauthorized) + return + } + if !a.member(claims.Groups) { + if claims.Groups == nil { + a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject) + } else { + a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject) + } + // A session this browser already holds ends too, so a refused + // sign-in never leaves it signed in. + if err := a.sessions.EndSession(w, r); err != nil { + a.logger.ErrorContext(ctx, "end session", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + a.refuse(w, r) + return + } + if err := a.sessions.StartSession(w, r, claims); err != nil { + a.logger.ErrorContext(ctx, "start session", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, state.Return, http.StatusSeeOther) +} + +// logout ends the session, clears the cookie and sends the browser to the +// provider's end_session_endpoint to end the provider session as well. +func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if err := a.sessions.EndSession(w, r); err != nil { + a.logger.ErrorContext(r.Context(), "end session", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther) +} + +// verify exchanges code for tokens and returns the verified ID token claims. +// The verifier enforces RS256, issuer, audience and expiry; nonce is checked +// here. When the ID token has no groups claim, Groups comes from userinfo; +// it stays nil when neither has one. +func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) { + token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code, + oauth2.VerifierOption(state.Verifier)) + if err != nil { + return Claims{}, exchangeError(err) + } + raw, ok := token.Extra("id_token").(string) + if !ok || raw == "" { + return Claims{}, errors.New("token response has no id_token") + } + idToken, err := a.verifier.Verify(ctx, raw) + if err != nil { + return Claims{}, verifyError(err) + } + if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 { + return Claims{}, errors.New("ID token nonce mismatch") + } + var claims Claims + if err := idToken.Claims(&claims); err != nil { + return Claims{}, fmt.Errorf("decode ID token claims: %w", err) + } + if claims.Subject == "" { + return Claims{}, errors.New("ID token has no subject") + } + if claims.Groups == nil { + groups, err := a.userinfoGroups(ctx, token, claims.Subject) + if err != nil { + return Claims{}, err + } + claims.Groups = groups + } + return claims, nil +} + +// userinfoGroups returns the groups claim from the userinfo endpoint, nil if +// it has none. The userinfo subject must match the ID token's subject. +func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) { + info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token)) + if err != nil { + return nil, userinfoError(err) + } + if info.Subject != subject { + return nil, errors.New("userinfo subject does not match the ID token") + } + var claims struct { + Groups []string `json:"groups"` + } + if err := info.Claims(&claims); err != nil { + return nil, fmt.Errorf("decode userinfo claims: %w", err) + } + return claims.Groups, nil +} + +// userinfoError reduces a failed userinfo request to a fixed category, like +// exchangeError: go-oidc's error text can carry the response body. +func userinfoError(err error) error { + var transport *url.Error + if errors.As(err, &transport) { + return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err) + } + return errors.New("userinfo: endpoint returned an unusable response") +} + +// verifyError reduces a failed ID token verification to a fixed category. +// go-oidc's error text can carry the JWKS endpoint's response body or +// token-supplied values, so only the expiry of an expired token is kept. +func verifyError(err error) error { + var expired *oidc.TokenExpiredError + if errors.As(err, &expired) { + return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339)) + } + return errors.New("verify ID token: token or provider key set rejected") +} + +// member reports whether groups contains an allowed group. +func (a *Auth) member(groups []string) bool { + return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) }) +} + +// exchangeError reduces a failed code exchange to a fixed category and safe +// metadata. The oauth2 error text can carry the token endpoint's response +// body or error_description, which may echo credentials, so it is dropped. +func exchangeError(err error) error { + var retrieve *oauth2.RetrieveError + var transport *url.Error + switch { + case errors.As(err, &retrieve): + return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode) + case errors.As(err, &transport): + // Request failures never carry the response. The URL is dropped: after + // a refused redirect it is the provider-supplied Location. + return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err) + default: + return errors.New("exchange code: token endpoint returned an unusable response") + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..407bbb8 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,1075 @@ +package auth + +import ( + "bytes" + "context" + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + testClientID = "mealquest" + testClientSecret = "client-s3cret" + testKID = "test-key" +) + +var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + +// testAllowedGroups are the household's groups in tests. +var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"} + +// fakeProvider is an OIDC provider serving discovery, JWKS and a token +// endpoint. Each issued code maps to the ID token the token endpoint returns. +type fakeProvider struct { + t *testing.T + srv *httptest.Server + key *rsa.PrivateKey + + mu sync.Mutex + codes map[string]issuedCode + // discoveryOverrides replace fields of the discovery document. + discoveryOverrides map[string]any + // tokenReply, when set, is returned by the token endpoint instead of a + // token. + tokenReply *tokenReply + // userinfo is the userinfo endpoint's response; userinfoHits counts + // requests to it. + userinfo map[string]any + userinfoHits int + // userinfoReply, when set, is returned by the userinfo endpoint instead. + userinfoReply *tokenReply + // jwksReply, when set, is returned by the JWKS endpoint instead. + jwksReply *tokenReply +} + +type tokenReply struct { + status int + body string +} + +type issuedCode struct { + challenge string + idToken string +} + +func newFakeProvider(t *testing.T) *fakeProvider { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}} + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery) + mux.HandleFunc("GET /jwks", p.jwks) + mux.HandleFunc("POST /token", p.token) + mux.HandleFunc("GET /userinfo", p.userinfoEndpoint) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +func (p *fakeProvider) issuer() string { return p.srv.URL } + +func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) { + doc := map[string]any{ + "issuer": p.issuer(), + "authorization_endpoint": p.issuer() + "/authorize", + "token_endpoint": p.issuer() + "/token", + "jwks_uri": p.issuer() + "/jwks", + "userinfo_endpoint": p.issuer() + "/userinfo", + "end_session_endpoint": p.issuer() + "/end-session/", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256", "HS256"}, + } + p.mu.Lock() + maps.Copy(doc, p.discoveryOverrides) + p.mu.Unlock() + writeJSON(w, doc) +} + +func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) { + p.mu.Lock() + reply := p.jwksReply + p.mu.Unlock() + if reply != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(reply.status) + io.WriteString(w, reply.body) + return + } + writeJSON(w, map[string]any{"keys": []map[string]string{{ + "kty": "RSA", + "kid": testKID, + "use": "sig", + "alg": "RS256", + "n": b64(p.key.N.Bytes()), + "e": b64(big.NewInt(int64(p.key.E)).Bytes()), + }}}) +} + +// token checks client authentication, the code and the PKCE verifier before +// returning the code's ID token. +func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + reply := p.tokenReply + p.mu.Unlock() + if reply != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(reply.status) + io.WriteString(w, reply.body) + return + } + id, secret, ok := r.BasicAuth() + if !ok || id != testClientID || secret != testClientSecret { + http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest) + return + } + p.mu.Lock() + issued, ok := p.codes[r.PostForm.Get("code")] + delete(p.codes, r.PostForm.Get("code")) + p.mu.Unlock() + if r.PostForm.Get("grant_type") != "authorization_code" || !ok || + challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":"invalid_grant"}`) + return + } + writeJSON(w, map[string]any{ + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": issued.idToken, + }) +} + +// userinfoEndpoint checks the access token and returns p.userinfo, which +// defaults to the test user without a groups claim. +func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer access-token" { + http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized) + return + } + p.mu.Lock() + p.userinfoHits++ + info, reply := p.userinfo, p.userinfoReply + p.mu.Unlock() + if reply != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(reply.status) + io.WriteString(w, reply.body) + return + } + if info == nil { + info = map[string]any{"sub": "user-123", "email": "cook@example.com"} + } + writeJSON(w, info) +} + +func (p *fakeProvider) setUserinfo(info map[string]any) { + p.mu.Lock() + defer p.mu.Unlock() + p.userinfo = info +} + +func (p *fakeProvider) userinfoRequests() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.userinfoHits +} + +// authorize stands in for the user signing in at the provider: it records a +// code bound to the PKCE challenge and returns it. +func (p *fakeProvider) authorize(challenge, idToken string) string { + code := rand.Text() + p.mu.Lock() + p.codes[code] = issuedCode{challenge: challenge, idToken: idToken} + p.mu.Unlock() + return code +} + +// claims returns valid ID token claims for nonce. +func (p *fakeProvider) claims(nonce string) map[string]any { + now := time.Now() + return map[string]any{ + "iss": p.issuer(), + "sub": "user-123", + "aud": testClientID, + "iat": now.Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "nonce": nonce, + "email": "cook@example.com", + "email_verified": true, + "name": "Home Cook", + "preferred_username": "cook", + "groups": []string{"mealquest-admin"}, + } +} + +func (p *fakeProvider) signRS256(claims map[string]any) string { + return signRS256(p.t, p.key, claims) +} + +func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string { + t.Helper() + signing := jwtSigningInput(t, "RS256", claims) + digest := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return signing + "." + b64(sig) +} + +// signHS256 signs with the client secret, as in an algorithm-confusion attack. +func (p *fakeProvider) signHS256(claims map[string]any) string { + signing := jwtSigningInput(p.t, "HS256", claims) + mac := hmac.New(sha256.New, []byte(testClientSecret)) + mac.Write([]byte(signing)) + return signing + "." + b64(mac.Sum(nil)) +} + +func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string { + t.Helper() + header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID}) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return b64(header) + "." + b64(body) +} + +func challengeS256(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return b64(sum[:]) +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// memorySessions is an in-memory Sessions that records the claims it was +// handed and how many sessions were ended. +type memorySessions struct { + mu sync.Mutex + claims []Claims + ended int + err error +} + +func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return m.err + } + m.claims = append(m.claims, claims) + http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject}) + return nil +} + +func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return m.err + } + m.ended++ + return nil +} + +func (m *memorySessions) started() []Claims { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.claims) +} + +type harness struct { + auth *Auth + provider *fakeProvider + sessions *memorySessions + router http.Handler + logs *syncBuffer +} + +// syncBuffer is a bytes.Buffer safe for concurrent log writes. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func newHarness(t *testing.T) *harness { + t.Helper() + p := newFakeProvider(t) + sessions := &memorySessions{} + logs := &syncBuffer{} + a, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("server-secret-key"), + AllowedGroups: testAllowedGroups, + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + a.Mount(r) + return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs} +} + +func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response { + req := httptest.NewRequest(http.MethodGet, target, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec.Result() +} + +// login is a started login: the authorize URL's query and the login cookie. +type login struct { + query url.Values + cookie *http.Cookie +} + +func (h *harness) login(t *testing.T, target string) login { + t.Helper() + resp := h.get(target) + if resp.StatusCode != http.StatusFound { + t.Fatalf("GET %s status = %d, want 302", target, resp.StatusCode) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if got, want := loc.Scheme+"://"+loc.Host+loc.Path, h.provider.issuer()+"/authorize"; got != want { + t.Fatalf("redirect to %s, want %s", got, want) + } + var cookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == loginCookieName { + cookie = c + } + } + if cookie == nil { + t.Fatalf("login set no %s cookie", loginCookieName) + } + return login{query: loc.Query(), cookie: cookie} +} + +// callback completes l at the provider with idToken and calls the callback. +func (h *harness) callback(l login, state, idToken string) *http.Response { + code := h.provider.authorize(l.query.Get("code_challenge"), idToken) + q := url.Values{"code": {code}, "state": {state}} + return h.get(CallbackPath+"?"+q.Encode(), l.cookie) +} + +func TestLoginRedirect(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath+"?return=/plan") + + want := map[string]string{ + "client_id": testClientID, + "response_type": "code", + "scope": "openid profile email groups offline_access", + "redirect_uri": "https://meals.example.com/auth/callback", + "code_challenge_method": "S256", + } + for k, v := range want { + if got := l.query.Get(k); got != v { + t.Errorf("authorize %s = %q, want %q", k, got, v) + } + } + for _, k := range []string{"state", "nonce", "code_challenge"} { + if l.query.Get(k) == "" { + t.Errorf("authorize URL has no %s", k) + } + } + + c := l.cookie + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { + t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /", + c.HttpOnly, c.Secure, c.SameSite, c.Path) + } + if c.MaxAge <= 0 || c.MaxAge > 600 { + t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge) + } +} + +func TestLoginStateIsFreshEachTime(t *testing.T) { + t.Parallel() + h := newHarness(t) + a, b := h.login(t, LoginPath), h.login(t, LoginPath) + for _, k := range []string{"state", "nonce", "code_challenge"} { + if a.query.Get(k) == b.query.Get(k) { + t.Errorf("%s repeated across logins", k) + } + } +} + +func TestCallbackSuccess(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40")) + + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" { + t.Errorf("callback redirect = %q, want the return path", got) + } + started := h.sessions.started() + want := Claims{ + Subject: "user-123", + Email: "cook@example.com", + EmailVerified: true, + Name: "Home Cook", + PreferredUsername: "cook", + Groups: []string{"mealquest-admin"}, + } + if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email || + started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername || + !started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) { + t.Errorf("session started with %+v, want [%+v]", started, want) + } + assertLoginCookieCleared(t, resp) +} + +func TestCallbackDefaultReturnPath(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" { + t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location")) + } +} + +func TestCallbackRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // state returns the state sent to the callback. + state func(l login) string + // token returns the ID token the provider issues. + token func(p *fakeProvider, l login) string + wantStatus int + }{ + { + name: "mismatched state", + state: func(login) string { return "attacker-state" }, + wantStatus: http.StatusBadRequest, + }, + { + name: "wrong nonce", + token: func(p *fakeProvider, _ login) string { + return p.signRS256(p.claims("other-nonce")) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "missing nonce", + token: func(p *fakeProvider, _ login) string { + c := p.claims("") + delete(c, "nonce") + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "HS256-signed token", + token: func(p *fakeProvider, l login) string { + return p.signHS256(p.claims(l.query.Get("nonce"))) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "expired token", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["iat"] = time.Now().Add(-2 * time.Hour).Unix() + c["exp"] = time.Now().Add(-time.Hour).Unix() + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong issuer", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["iss"] = "https://evil.example.com" + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong audience", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["aud"] = "another-client" + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "signed by an unknown key", + token: func(p *fakeProvider, l login) string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + p.t.Fatal(err) + } + return signRS256(p.t, key, p.claims(l.query.Get("nonce"))) + }, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + state := l.query.Get("state") + if tc.state != nil { + state = tc.state(l) + } + token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))) + if tc.token != nil { + token = tc.token(h.provider, l) + } + + resp := h.callback(l, state, token) + + if resp.StatusCode != tc.wantStatus { + t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus) + } + if n := len(h.sessions.started()); n != 0 { + t.Errorf("%d sessions started, want none", n) + } + assertLoginCookieCleared(t, resp) + }) + } +} + +func TestCallbackWithoutLoginCookie(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + l.cookie = &http.Cookie{Name: "unrelated", Value: "x"} + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("callback status = %d, want 400", resp.StatusCode) + } + if n := len(h.sessions.started()); n != 0 { + t.Errorf("%d sessions started, want none", n) + } +} + +func TestCallbackTamperedLoginCookie(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + payload, sig, _ := strings.Cut(l.cookie.Value, ".") + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + t.Fatal(err) + } + var s loginState + if err := json.Unmarshal(data, &s); err != nil { + t.Fatal(err) + } + s.Return = "/somewhere-else" + data, err = json.Marshal(s) + if err != nil { + t.Fatal(err) + } + l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig} + + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("callback status = %d, want 400", resp.StatusCode) + } +} + +// The provider's error and error_description are untrusted text and never +// reach the logs; only the fixed category does. +func TestCallbackProviderError(t *testing.T) { + t.Parallel() + const ( + errorMarker = "provider-error-marker" + descriptionMarker = "description-marker-" + testClientSecret + ) + h := newHarness(t) + l := h.login(t, LoginPath) + q := url.Values{ + "error": {errorMarker}, + "error_description": {descriptionMarker}, + "state": {l.query.Get("state")}, + } + resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + assertLoginCookieCleared(t, resp) + if n := len(h.sessions.started()); n != 0 { + t.Errorf("started %d sessions, want 0", n) + } + logs := h.logs.String() + for _, marker := range []string{errorMarker, descriptionMarker} { + if strings.Contains(logs, marker) { + t.Errorf("logs contain provider text %q: %s", marker, logs) + } + } + if !strings.Contains(logs, "identity provider returned an authorization error") { + t.Errorf("logs = %q, want the authorization error category", logs) + } +} + +func TestCallbackSessionError(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.sessions.err = errors.New("database down") + l := h.login(t, LoginPath) + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("callback status = %d, want 500", resp.StatusCode) + } +} + +// A failed code exchange is logged by category and status only: the token +// endpoint's body and error_description may echo credentials. +func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) { + t.Parallel() + const leaked = "leaked-" + testClientSecret + tests := []struct { + name string + reply tokenReply + wantLogs string + }{ + { + name: "error response", + reply: tokenReply{http.StatusBadRequest, `{"error":"invalid_grant","error_description":"` + leaked + `"}`}, + wantLogs: "HTTP 400", + }, + { + name: "unparseable error response", + reply: tokenReply{http.StatusInternalServerError, leaked}, + wantLogs: "HTTP 500", + }, + { + name: "success without access token", + reply: tokenReply{http.StatusOK, `{"id_token":"` + leaked + `"}`}, + wantLogs: "unusable response", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.mu.Lock() + h.provider.tokenReply = &tc.reply + h.provider.mu.Unlock() + l := h.login(t, LoginPath) + + resp := h.callback(l, l.query.Get("state"), "unused") + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + logs := h.logs.String() + if strings.Contains(logs, leaked) { + t.Errorf("logs contain the provider response: %s", logs) + } + if !strings.Contains(logs, tc.wantLogs) { + t.Errorf("logs = %q, want %q", logs, tc.wantLogs) + } + }) + } +} + +func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) { + t.Parallel() + const sentinel = "sentinel-jwks-body-5c1e" + tests := []struct { + name string + reply tokenReply + }{ + {"error response", tokenReply{http.StatusInternalServerError, sentinel}}, + {"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.mu.Lock() + h.provider.jwksReply = &tc.reply + h.provider.mu.Unlock() + + // The returned error, checked through verify directly. + l := h.login(t, LoginPath) + req := httptest.NewRequest(http.MethodGet, CallbackPath, nil) + req.AddCookie(l.cookie) + state, err := h.auth.cookie.read(req, time.Now()) + if err != nil { + t.Fatal(err) + } + code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + _, err = h.auth.verify(context.Background(), code, state) + if err == nil { + t.Fatal("verify succeeded with an unusable key set") + } + if strings.Contains(err.Error(), sentinel) { + t.Errorf("verify error contains the JWKS response: %v", err) + } + + // The logs and response, through the callback. + l = h.login(t, LoginPath) + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), sentinel) { + t.Errorf("response contains the JWKS response: %s", body) + } + logs := h.logs.String() + if strings.Contains(logs, sentinel) { + t.Errorf("logs contain the JWKS response: %s", logs) + } + if want := "token or provider key set rejected"; !strings.Contains(logs, want) { + t.Errorf("logs = %q, want %q", logs, want) + } + if len(h.sessions.started()) != 0 { + t.Error("a session started") + } + }) + } +} + +func TestLoginCookieExpires(t *testing.T) { + t.Parallel() + c := newLoginCookie([]byte("server-secret-key")) + now := time.Now() + rec := httptest.NewRecorder() + if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, CallbackPath, nil) + req.AddCookie(rec.Result().Cookies()[0]) + + if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil { + t.Errorf("read before expiry: %v", err) + } + if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil { + t.Error("read at expiry succeeded") + } +} + +func TestLoginRejectsOffOriginReturn(t *testing.T) { + t.Parallel() + h := newHarness(t) + for _, ret := range []string{ + "https://evil.example.com/", + "http://meals.example.com/plan", + "//evil.example.com/", + "/\\evil.example.com", + "\\\\evil.example.com", + "javascript:alert(1)", + "plan", + "/plan\r\nSet-Cookie: x=y", + } { + resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret)) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "" { + t.Errorf("return=%q: redirected to %q", ret, loc) + } + if len(resp.Cookies()) != 0 { + t.Errorf("return=%q: set cookies %v", ret, resp.Cookies()) + } + } +} + +func TestNewFailsWhenDiscoveryFails(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + _, err := New(context.Background(), Config{ + Issuer: srv.URL, + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("New succeeded without a discovery document") + } +} + +func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) { + t.Parallel() + const sentinel = "sentinel-discovery-body-7f3a" + for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + io.WriteString(w, sentinel) + })) + t.Cleanup(srv.Close) + logs := &syncBuffer{} + _, err := New(context.Background(), Config{ + Issuer: srv.URL, + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil))) + if err == nil { + t.Fatal("New succeeded without a discovery document") + } + // cmd/server logs the returned error at startup. + slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err) + if strings.Contains(logs.String(), sentinel) { + t.Errorf("logs contain the provider response body: %s", logs) + } + if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } + }) + } +} + +func TestNewFailsOnIssuerMismatch(t *testing.T) { + t.Parallel() + p := newFakeProvider(t) + // Discovery trims the trailing slash, so this fetches the provider's + // document, which reports the issuer without it. + _, err := New(context.Background(), Config{ + Issuer: p.issuer() + "/", + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("New accepted a provider reporting a different issuer") + } +} + +func assertLoginCookieCleared(t *testing.T, resp *http.Response) { + t.Helper() + for _, c := range resp.Cookies() { + if c.Name == loginCookieName && c.MaxAge < 0 { + return + } + } + t.Errorf("callback did not clear the %s cookie", loginCookieName) +} + +func TestNewRejectsRemoteHTTPIssuer(t *testing.T) { + t.Parallel() + for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} { + _, err := New(context.Background(), Config{ + Issuer: issuer, + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), "issuer") { + t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err) + } + } +} + +func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + field, name string + }{ + {"authorization_endpoint", "authorization endpoint"}, + {"token_endpoint", "token endpoint"}, + {"jwks_uri", "JWKS endpoint"}, + {"end_session_endpoint", "end session endpoint"}, + {"userinfo_endpoint", "userinfo endpoint"}, + } + for _, tc := range tests { + t.Run(tc.field, func(t *testing.T) { + t.Parallel() + p := newFakeProvider(t) + p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field} + _, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), tc.name) { + t.Errorf("New error = %v, want %s rejected", err, tc.name) + } + }) + } +} + +// A token endpoint on https:// that redirects the code exchange to plain +// http:// must not receive a follow-up request: Go would resend the client +// secret in the Authorization header. The plaintext target is loopback, so +// only the downgrade rule stops it. +func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) { + t.Parallel() + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + var plaintextHits atomic.Int32 + plaintext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + plaintextHits.Add(1) + http.Error(w, "unreachable", http.StatusTeapot) + })) + t.Cleanup(plaintext.Close) + var tlsHits atomic.Int32 + tlsToken := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsHits.Add(1) + http.Redirect(w, r, plaintext.URL+"/token", status) + })) + t.Cleanup(tlsToken.Close) + + p := newFakeProvider(t) + p.discoveryOverrides = map[string]any{"token_endpoint": tlsToken.URL + "/token"} + sessions := &memorySessions{} + logs := &syncBuffer{} + a, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("server-secret-key"), + AllowedGroups: testAllowedGroups, + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + // Trust the test server's certificate; the redirect policy stays. + a.client.Transport = tlsToken.Client().Transport + r := chi.NewRouter() + a.Mount(r) + h := &harness{provider: p, sessions: sessions, router: r, logs: logs} + l := h.login(t, LoginPath) + + resp := h.callback(l, l.query.Get("state"), "unused") + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + if n := tlsHits.Load(); n != 1 { + t.Errorf("https token endpoint hit %d times, want 1", n) + } + if n := plaintextHits.Load(); n != 0 { + t.Errorf("plaintext endpoint hit %d times, want 0", n) + } + if n := len(sessions.started()); n != 0 { + t.Errorf("started %d sessions, want 0", n) + } + if got := logs.String(); !strings.Contains(got, "downgrade") { + t.Errorf("logs = %q, want the downgrade refusal", got) + } + }) + } +} + +func TestCheckRedirect(t *testing.T) { + t.Parallel() + request := func(raw string) *http.Request { + req, err := http.NewRequest(http.MethodPost, raw, nil) + if err != nil { + t.Fatal(err) + } + return req + } + tests := []struct { + from, to string + ok bool + }{ + {"https://auth.example.com/token", "https://auth2.example.com/token", true}, + {"http://127.0.0.1:9000/token", "http://localhost:9001/token", true}, + {"http://127.0.0.1:9000/token", "https://auth.example.com/token", true}, + {"https://auth.example.com/token", "http://auth.example.com/token", false}, + {"https://127.0.0.1:9000/token", "http://127.0.0.1:9001/token", false}, + {"http://127.0.0.1:9000/token", "http://auth.example.com/token", false}, + {"https://auth.example.com/token", "ftp://auth.example.com/token", false}, + } + for _, tc := range tests { + err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)}) + if (err == nil) != tc.ok { + t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok) + } + } + via := make([]*http.Request, maxRedirects) + for i := range via { + via[i] = request("https://auth.example.com/token") + } + if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil { + t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects) + } +} diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..4be2d7d --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,124 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + loginCookieName = "__Host-mealquest_login" + loginCookieTTL = 10 * time.Minute + // loginCookieKeyLabel separates the login cookie's signing key from other + // keys derived from the same server secret. + loginCookieKeyLabel = "mealquest auth login cookie v1" +) + +// loginState travels from login to callback in the signed login cookie. +type loginState struct { + State string `json:"s"` + Nonce string `json:"n"` + Verifier string `json:"v"` + Return string `json:"r"` + Expires int64 `json:"e"` // Unix seconds +} + +// loginCookie signs and verifies the login cookie with HMAC-SHA256. +type loginCookie struct { + key []byte +} + +func newLoginCookie(secretKey []byte) loginCookie { + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(loginCookieKeyLabel)) + return loginCookie{key: mac.Sum(nil)} +} + +func (c loginCookie) sign(payload string) string { + mac := hmac.New(sha256.New, c.key) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error { + s.Expires = now.Add(loginCookieTTL).Unix() + data, err := json.Marshal(s) + if err != nil { + return err + } + payload := base64.RawURLEncoding.EncodeToString(data) + http.SetCookie(w, &http.Cookie{ + Name: loginCookieName, + Value: payload + "." + c.sign(payload), + Path: "/", + MaxAge: int(loginCookieTTL / time.Second), + Secure: true, + HttpOnly: true, + // Lax so the cookie rides the provider's top-level redirect back. + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +func (c loginCookie) clear(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: loginCookieName, + Path: "/", + MaxAge: -1, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) { + cookie, err := r.Cookie(loginCookieName) + if err != nil { + return loginState{}, errors.New("login cookie missing") + } + payload, sig, ok := strings.Cut(cookie.Value, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) { + return loginState{}, errors.New("login cookie signature invalid") + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return loginState{}, fmt.Errorf("login cookie encoding: %w", err) + } + var s loginState + if err := json.Unmarshal(data, &s); err != nil { + return loginState{}, fmt.Errorf("login cookie payload: %w", err) + } + if now.Unix() >= s.Expires { + return loginState{}, errors.New("login cookie expired") + } + return s, nil +} + +// returnPath validates the optional post-login return path. Empty means "/". +// Only same-origin absolute paths are allowed: no scheme, host, protocol- +// relative "//" prefix, backslashes or control characters. +func returnPath(raw string) (string, error) { + if raw == "" { + return "/", nil + } + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') { + return "", errors.New("return path must be a same-origin path") + } + for _, c := range raw { + if c < 0x20 || c == 0x7f { + return "", errors.New("return path contains a control character") + } + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil { + return "", errors.New("return path must be a same-origin path") + } + return raw, nil +} diff --git a/internal/auth/gate_test.go b/internal/auth/gate_test.go new file mode 100644 index 0000000..0f18362 --- /dev/null +++ b/internal/auth/gate_test.go @@ -0,0 +1,264 @@ +package auth + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "testing" +) + +// signInWith completes a login whose ID token has claims edited by edit. +func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response { + t.Helper() + l := h.login(t, LoginPath) + c := h.provider.claims(l.query.Get("nonce")) + edit(c) + return h.callback(l, l.query.Get("state"), h.provider.signRS256(c)) +} + +// assertRefused checks resp is the refused-sign-in page and starts no +// session. +func assertRefused(t *testing.T, resp *http.Response) { + t.Helper() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("callback status = %d, want 403", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "This kitchen is for the household only", + `
`, + "Sign out", + } { + if !strings.Contains(string(body), want) { + t.Errorf("refused page lacks %q:\n%s", want, body) + } + } + for _, c := range resp.Cookies() { + // Clearing a session cookie (negative Max-Age) is expected. + if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 { + t.Errorf("refused sign-in set session cookie %s", c.Name) + } + } + assertLoginCookieCleared(t, resp) +} + +func TestGroupGateMember(t *testing.T) { + t.Parallel() + // Any one allowed group is enough, alongside groups that are not allowed. + h := newHarness(t) + resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} }) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) { + t.Errorf("sessions started = %+v, want one with the ID token groups", started) + } + if n := h.provider.userinfoRequests(); n != 0 { + t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n) + } +} + +func TestGroupGateRefused(t *testing.T) { + t.Parallel() + tests := []struct { + name string + groups any // ID token groups claim; nil removes it + userinfo map[string]any + }{ + {name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}}, + {name: "empty groups claim", groups: []string{}}, + {name: "groups claim missing from ID token and userinfo", groups: nil}, + { + name: "non-member in userinfo", + groups: nil, + userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + if tc.userinfo != nil { + h.provider.setUserinfo(tc.userinfo) + } + resp := h.signInWith(t, func(c map[string]any) { + if tc.groups == nil { + delete(c, "groups") + } else { + c["groups"] = tc.groups + } + }) + assertRefused(t, resp) + if started := h.sessions.started(); len(started) != 0 { + t.Errorf("sessions started = %+v, want none", started) + } + if !strings.Contains(h.logs.String(), "sign-in refused") { + t.Errorf("refusal not logged:\n%s", h.logs.String()) + } + }) + } +} + +func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}}) + resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) { + t.Errorf("sessions started = %+v, want one with the userinfo groups", started) + } + if n := h.provider.userinfoRequests(); n != 1 { + t.Errorf("userinfo requested %d times, want 1", n) + } +} + +func TestGroupGateUserinfoFailures(t *testing.T) { + t.Parallel() + tests := []struct { + name string + userinfo map[string]any + }{ + {"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}}, + {"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.setUserinfo(tc.userinfo) + resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + if started := h.sessions.started(); len(started) != 0 { + t.Errorf("sessions started = %+v, want none", started) + } + }) + } +} + +// A failed userinfo request fails the sign-in without logging the +// endpoint's response, which may echo credentials. +func TestGroupGateUserinfoErrorNotLogged(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.mu.Lock() + h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`} + h.provider.mu.Unlock() + resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") }) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + if started := h.sessions.started(); len(started) != 0 { + t.Errorf("sessions started = %+v, want none", started) + } + logs := h.logs.String() + if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") { + t.Errorf("logs = %s, want the userinfo failure without the response body", logs) + } +} + +// Membership is checked at every login: after removal from the group, the +// next login is refused and starts no session. +func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + h.signIn(t) + if n := len(h.rows(t)); n != 1 { + t.Fatalf("sessions after first login = %d, want 1", n) + } + + resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} }) + assertRefused(t, resp) + if n := len(h.rows(t)); n != 1 { + t.Errorf("sessions after refused login = %d, want still 1", n) + } +} + +// A refused sign-in from a browser that still holds a session ends that +// session and clears its cookie. +func TestGroupGateRefusalEndsExistingSession(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + l := h.login(t, LoginPath) + c := h.provider.claims(l.query.Get("nonce")) + c["groups"] = []string{"former-household"} + code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c)) + q := url.Values{"code": {code}, "state": {l.query.Get("state")}} + resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie) + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("callback status = %d, want 403", resp.StatusCode) + } + if n := len(h.rows(t)); n != 0 { + t.Errorf("sessions after refused login = %d, want 0", n) + } + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("old session lookup error = %v, want ErrNoSession", err) + } + cleared := false + for _, c := range resp.Cookies() { + cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0) + } + if !cleared { + t.Error("refused sign-in did not clear the session cookie") + } +} + +func TestGroupGateRefusalEndSessionError(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.sessions.err = errors.New("database down") + resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} }) + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("callback status = %d, want 500", resp.StatusCode) + } +} + +func TestNewRequiresAllowedGroups(t *testing.T) { + t.Parallel() + p := newFakeProvider(t) + _, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), "allowed group") { + t.Errorf("New error = %v, want missing allowed groups named", err) + } +} + +func TestNewRequiresUserinfoEndpoint(t *testing.T) { + t.Parallel() + p := newFakeProvider(t) + p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil} + _, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") { + t.Errorf("New error = %v, want the missing userinfo endpoint named", err) + } +} diff --git a/internal/auth/refused.go b/internal/auth/refused.go new file mode 100644 index 0000000..399e705 --- /dev/null +++ b/internal/auth/refused.go @@ -0,0 +1,37 @@ +package auth + +import ( + "io" + "net/http" +) + +// refusedPage is shown when a sign-in succeeds at the provider but the +// account is not in the household's group. Signing out ends the provider +// session too, so another account can sign in. +const refusedPage = ` + + + + +MealQuest + + +
+

This kitchen is for the household only

+

The account you signed in with is not part of our kitchen.

+ + + +
+ + +` + +// refuse writes the refused-sign-in page with 403. +func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + if _, err := io.WriteString(w, refusedPage); err != nil { + a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..89d061e --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,238 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// SessionCookieName is the session cookie. The __Host- prefix makes the +// browser insist on Secure, Path=/ and no Domain. +const SessionCookieName = "__Host-mealquest_session" + +// sessionIDBytes is the size of the random session ID: 256 bits. +const sessionIDBytes = 32 + +// ErrNoSession means the request carries no live session: the cookie is +// missing or malformed, or its session is unknown, ended or expired. +var ErrNoSession = errors.New("auth: no valid session") + +// Session is a signed-in household member's server-side session. +type Session struct { + Subject string + Email string + Name string + Groups []string + CSRFToken string + CreatedAt time.Time + // LastSeenAt is the last time the session was used. + LastSeenAt time.Time + // ExpiresAt is when the session ends unless used again first. + ExpiresAt time.Time +} + +// SessionManager keeps sessions in the sessions table. The cookie holds only +// the random session ID; the table holds only its SHA-256 hash. A session +// ends after idle without use and always max after it was created. +type SessionManager struct { + db *store.Store + idle time.Duration + max time.Duration + // now is the clock; tests replace it. + now func() time.Time +} + +// NewSessionManager returns a SessionManager over db. idle and max must be +// positive with max >= idle. +func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) { + switch { + case db == nil: + return nil, errors.New("auth: session store is required") + case idle <= 0 || max <= 0: + return nil, errors.New("auth: session idle and max lifetimes must be positive") + case max < idle: + return nil, errors.New("auth: session max lifetime must be at least the idle lifetime") + } + return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil +} + +// StartSession creates a session for claims and sets the session cookie. +func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error { + if claims.Subject == "" { + return errors.New("auth: session needs a subject") + } + id, csrf := randomToken(), randomToken() + groups := claims.Groups + if groups == nil { + groups = []string{} + } + groupsJSON, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("auth: encode session groups: %w", err) + } + + now := m.timestamp() + if _, err := m.db.Writer.ExecContext(r.Context(), ` + INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf, + now, now, m.expiry(now.Time, now.Time), + ); err != nil { + return fmt.Errorf("auth: create session: %w", err) + } + + // The cookie outlives idle gaps so a returning device presents its ID; + // the server decides whether the session is still live. Max-Age is whole + // seconds, rounded up so the cookie never ends before the absolute limit. + http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max))) + return nil +} + +// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the +// quotient rather than adding to lifetime, which would overflow near the +// largest time.Duration. +func cookieMaxAge(lifetime time.Duration) int { + seconds := lifetime / time.Second + if lifetime%time.Second != 0 { + seconds++ + } + return int(seconds) +} + +// SessionFor returns the live session r carries and slides its last use to +// now. It returns ErrNoSession when there is none; an expired session is +// deleted. +func (m *SessionManager) SessionFor(r *http.Request) (Session, error) { + idHash, err := sessionIDHash(r) + if err != nil { + return Session{}, err + } + now := m.timestamp() + + var s Session + live := false + err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error { + var groupsJSON string + var created, lastSeen, expires store.Timestamp + err := tx.QueryRowContext(r.Context(), ` + SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at + FROM sessions WHERE id_hash = ?`, idHash, + ).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("auth: read session: %w", err) + } + + if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max { + if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil { + return fmt.Errorf("auth: delete expired session: %w", err) + } + return nil + } + + if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil { + return fmt.Errorf("auth: decode session groups: %w", err) + } + expiresAt := m.expiry(created.Time, now.Time) + if _, err := tx.ExecContext(r.Context(), ` + UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`, + now, expiresAt, idHash, + ); err != nil { + return fmt.Errorf("auth: slide session: %w", err) + } + s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time + live = true + return nil + }) + if err != nil { + return Session{}, err + } + if !live { + return Session{}, ErrNoSession + } + return s, nil +} + +// EndSession deletes the session r carries, if any, and clears the cookie. +func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error { + http.SetCookie(w, sessionCookie("", -1)) + idHash, err := sessionIDHash(r) + if errors.Is(err, ErrNoSession) { + return nil + } + if err != nil { + return err + } + if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil { + return fmt.Errorf("auth: delete session: %w", err) + } + return nil +} + +// timestamp is the clock's current instant at stored precision. +func (m *SessionManager) timestamp() store.Timestamp { + return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)} +} + +// expiry is when a session created at created and last used at lastSeen +// ends: idle after lastSeen, but never later than max after created. +func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp { + idle, absolute := lastSeen.Add(m.idle), created.Add(m.max) + if absolute.Before(idle) { + return store.Timestamp{Time: absolute} + } + return store.Timestamp{Time: idle} +} + +// sessionCookie is the session cookie carrying value, with maxAge seconds; +// a negative maxAge clears it. +func sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: value, + Path: "/", + MaxAge: maxAge, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession +// when the cookie is missing or is not a well-formed ID. +func sessionIDHash(r *http.Request) (string, error) { + c, err := r.Cookie(SessionCookieName) + if err != nil { + return "", ErrNoSession + } + raw, err := base64.RawURLEncoding.DecodeString(c.Value) + if err != nil || len(raw) != sessionIDBytes { + return "", ErrNoSession + } + return hashSessionID(c.Value), nil +} + +// hashSessionID is the SHA-256 hex digest of the cookie value. +func hashSessionID(id string) string { + sum := sha256.Sum256([]byte(id)) + return hex.EncodeToString(sum[:]) +} + +// randomToken returns 256 random bits, base64url-encoded without padding. +// crypto/rand.Read never returns an error; it crashes the program instead. +func randomToken() string { + b := make([]byte, sessionIDBytes) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..3f86f02 --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,523 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + testIdle = 30 * 24 * time.Hour + testMax = 180 * 24 * time.Hour +) + +// testEpoch is the fake clock's starting instant. +var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC) + +// sessionHarness runs the real login flow against a fake provider with a +// SessionManager over a migrated database and a fake clock. +type sessionHarness struct { + *harness + db *store.Store + sessions *SessionManager + auth *Auth + clock time.Time +} + +func newSessionHarness(t *testing.T) *sessionHarness { + t.Helper() + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + sessions, err := NewSessionManager(db, testIdle, testMax) + if err != nil { + t.Fatal(err) + } + h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch} + sessions.now = func() time.Time { return h.clock } + + p := newFakeProvider(t) + logs := &syncBuffer{} + a, err := New(ctx, Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("server-secret-key"), + AllowedGroups: testAllowedGroups, + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + a.Mount(r) + h.harness = &harness{provider: p, router: r, logs: logs} + h.auth = a + return h +} + +func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) } + +// signIn completes a login and returns the callback response and the +// session cookie it set. +func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) { + t.Helper() + l := h.login(t, LoginPath) + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + for _, c := range resp.Cookies() { + if c.Name == SessionCookieName { + return resp, c + } + } + t.Fatalf("callback set no %s cookie", SessionCookieName) + return nil, nil +} + +// use looks up the session cookie c names. +func (h *sessionHarness) use(c *http.Cookie) (Session, error) { + req := httptest.NewRequest(http.MethodGet, "/api/anything", nil) + if c != nil { + req.AddCookie(c) + } + return h.sessions.SessionFor(req) +} + +type sessionRow struct { + idHash, subject, email, name, groups, csrf string + created, lastSeen, expires store.Timestamp +} + +func (h *sessionHarness) rows(t *testing.T) []sessionRow { + t.Helper() + rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token, + created_at, last_seen_at, expires_at FROM sessions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + var out []sessionRow + for rs.Next() { + var r sessionRow + if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf, + &r.created, &r.lastSeen, &r.expires); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// sessionSetCookie returns the raw Set-Cookie header for the session cookie. +func sessionSetCookie(t *testing.T, resp *http.Response) string { + t.Helper() + for _, v := range resp.Header.Values("Set-Cookie") { + if strings.HasPrefix(v, SessionCookieName+"=") { + return v + } + } + t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie")) + return "" +} + +// assertSessionCookieAttributes checks the attributes the __Host- prefix +// and D-004 require, on the raw header so nothing is lost in parsing. +func assertSessionCookieAttributes(t *testing.T, raw string) { + t.Helper() + attrs := map[string]bool{} + for _, a := range strings.Split(raw, ";")[1:] { + name, _, _ := strings.Cut(strings.TrimSpace(a), "=") + attrs[strings.ToLower(name)] = true + } + for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} { + if !slices.Contains(strings.Split(raw, "; "), want) { + t.Errorf("session Set-Cookie %q lacks %s", raw, want) + } + } + if attrs["domain"] { + t.Errorf("session Set-Cookie %q has a Domain", raw) + } +} + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func TestCallbackStartsSession(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + resp, cookie := h.signIn(t) + + assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) + if cookie.MaxAge != int(testMax/time.Second) { + t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second)) + } + + rows := h.rows(t) + if len(rows) != 1 { + t.Fatalf("sessions has %d rows, want 1", len(rows)) + } + r := rows[0] + if len(cookie.Value) != 43 { + t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value)) + } + if r.idHash != sha256Hex(cookie.Value) { + t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash) + } + if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" || + r.groups != `["mealquest-admin"]` { + t.Errorf("session row = %+v, want the ID token claims", r) + } + if len(r.csrf) != 43 || r.csrf == cookie.Value { + t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf) + } + if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) { + t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v", + r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle)) + } + + s, err := h.use(cookie) + if err != nil { + t.Fatalf("SessionFor = %v, want the new session", err) + } + if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) { + t.Errorf("SessionFor = %+v", s) + } +} + +func TestStartSessionStoresOnlyTheHash(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + var n int + err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE + id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`, + cookie.Value).Scan(&n) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("the raw session ID is stored in the sessions table") + } +} + +func TestSessionLastSeenSlides(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + // Used every 20 days, the session outlives several idle periods. + for range 4 { + h.advance(20 * 24 * time.Hour) + s, err := h.use(cookie) + if err != nil { + t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err) + } + if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) { + t.Errorf("at %v session last seen %v, expires %v, want %v, %v", + h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle)) + } + r := h.rows(t)[0] + if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) { + t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires) + } + } +} + +func TestSessionIdleExpiry(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + h.advance(testIdle - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err) + } + h.advance(testIdle) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err) + } + if n := len(h.rows(t)); n != 0 { + t.Errorf("expired session left %d rows, want 0", n) + } + // Turning the clock back cannot revive it. + h.clock = testEpoch + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err) + } +} + +func TestSessionAbsoluteExpiry(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) { + h.advance(20 * 24 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err) + } + } + // Last used 20 days before the absolute limit: the idle window would run + // past it, so expiry is capped there. + if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) { + t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax)) + } + + h.clock = testEpoch.Add(testMax - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(testMax) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err) + } + if n := len(h.rows(t)); n != 0 { + t.Errorf("expired session left %d rows, want 0", n) + } +} + +// A max with fractional seconds rounds the cookie Max-Age up, so the browser +// keeps the cookie until the server-side absolute limit. +func TestSessionCookieOutlivesFractionalMax(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond + _, cookie := h.signIn(t) + if cookie.MaxAge != 3 { + t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge) + } + if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) { + t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond)) + } + + h.advance(time.Second) + if _, err := h.use(cookie); err != nil { + t.Fatalf("at 1s SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(2500 * time.Millisecond) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err) + } +} + +// A lifetime near the largest accepted duration still yields a positive, +// rounded-up cookie Max-Age rather than overflowing. +func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) { + t.Parallel() + largest, err := time.ParseDuration("2562047h47m16.854s") + if err != nil { + t.Fatal(err) + } + h := newSessionHarness(t) + h.sessions.max = largest + _, cookie := h.signIn(t) + if want := 9223372037; cookie.MaxAge != want { + t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want) + } +} + +// A lifetime shortened by configuration applies to existing sessions even +// though their stored expires_at is later. +func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + h.sessions.idle = 24 * time.Hour + h.advance(24 * time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err) + } + + _, cookie = h.signIn(t) + h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour + for range 3 { + h.advance(12 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("SessionFor = %v, want the session", err) + } + } + h.advance(12 * time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err) + } + + // Used recently and with expires_at still ahead, but older than a max + // shortened after that use: only the absolute check ends it. + h.sessions.idle, h.sessions.max = testIdle, testMax + _, cookie = h.signIn(t) + h.advance(10 * 24 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("SessionFor = %v, want the session", err) + } + h.sessions.max = 10 * 24 * time.Hour + h.advance(time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err) + } +} + +func TestSessionForRejectsBadCookies(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + h.signIn(t) + for name, c := range map[string]*http.Cookie{ + "missing": nil, + "empty": {Name: SessionCookieName, Value: ""}, + "not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)}, + "short": {Name: SessionCookieName, Value: "AAAA"}, + "unknown ID": {Name: SessionCookieName, Value: randomToken()}, + "other cookie": {Name: "mealquest_session", Value: randomToken()}, + } { + if _, err := h.use(c); !errors.Is(err, ErrNoSession) { + t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err) + } + } + if n := len(h.rows(t)); n != 1 { + t.Errorf("sessions has %d rows, want the signed-in session only", n) + } +} + +func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response { + req := httptest.NewRequest(http.MethodPost, LogoutPath, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec.Result() +} + +func TestLogout(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + _, other := h.signIn(t) + + resp := h.logout(cookie) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("logout status = %d, want 303", resp.StatusCode) + } + want := h.provider.issuer() + "/end-session/" + if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want { + t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want) + } + raw := sessionSetCookie(t, resp) + assertSessionCookieAttributes(t, raw) + if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") { + t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw) + } + + rows := h.rows(t) + if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) { + t.Errorf("after logout sessions = %+v, want only the other session", rows) + } + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err) + } + if _, err := h.use(other); err != nil { + t.Errorf("other session: SessionFor = %v, want it untouched", err) + } +} + +func TestLogoutWithoutSession(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + resp := h.logout() + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" { + t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location")) + } + assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) +} + +func TestLogoutIsPostOnly(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode) + } + if _, err := h.use(cookie); err != nil { + t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err) + } +} + +func TestLogoutSessionError(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.sessions.err = errors.New("database down") + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil)) + if rec.Code != http.StatusInternalServerError { + t.Errorf("logout status = %d, want 500", rec.Code) + } +} + +func TestNewRequiresEndSessionEndpoint(t *testing.T) { + t.Parallel() + p := newFakeProvider(t) + p.discoveryOverrides = map[string]any{"end_session_endpoint": nil} + _, err := New(context.Background(), Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("k"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), "end session endpoint") { + t.Errorf("New error = %v, want the missing end session endpoint named", err) + } +} + +func TestNewSessionManagerValidates(t *testing.T) { + t.Parallel() + db := &store.Store{} + for _, tc := range []struct { + name string + db *store.Store + idle, max time.Duration + }{ + {"nil store", nil, time.Hour, time.Hour}, + {"zero idle", db, 0, time.Hour}, + {"negative max", db, time.Hour, -time.Hour}, + {"max below idle", db, 2 * time.Hour, time.Hour}, + } { + if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil { + t.Errorf("%s: NewSessionManager succeeded", tc.name) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1c1dfd7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,373 @@ +// Package config loads MealQuest configuration from MEALQUEST_* environment +// variables. Secrets are read only from files named by *_FILE variables. +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" +) + +// Environment variable names. +const ( + EnvHTTPAddr = "MEALQUEST_HTTP_ADDR" + EnvPublicURL = "MEALQUEST_PUBLIC_URL" + EnvDataDir = "MEALQUEST_DATA_DIR" + EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT" + EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE" + + EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER" + EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID" + EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE" + EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS" + + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" + EnvSessionMax = "MEALQUEST_SESSION_MAX" + + EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" + + EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" +) + +// Session lifetime defaults (D-004): long enough that the kitchen tablet +// stays signed in. +const ( + DefaultSessionIdle = 720 * time.Hour // 30 days + DefaultSessionMax = 4320 * time.Hour // 180 days +) + +// Config is the validated service configuration. +type Config struct { + HTTPAddr string + PublicURL *url.URL + DataDir string + ShutdownTimeout time.Duration + SecretKey Secret + + // OIDCIssuer is the Authentik issuer URL used for discovery. + OIDCIssuer *url.URL + OIDCClientID string + OIDCClientSecret Secret + // OIDCAllowedGroups lists the groups whose members may sign in. + OIDCAllowedGroups []string + + // SessionIdle ends a session left unused this long. + SessionIdle time.Duration + // SessionMax ends every session this long after sign-in, used or not. + SessionMax time.Duration + + // TrustedProxy lists the reverse proxies whose X-Forwarded-For and + // X-Forwarded-Proto headers are honoured. Empty trusts no peer. + TrustedProxy forwarded.Trusted + + // DefaultTZ is the IANA zone the household settings are created with on + // first boot. It is optional and not validated here: boot requires and + // validates it only while the settings do not exist yet, and ignores it + // once they do. Empty when unset. + DefaultTZ string +} + +// PublicOrigin returns the scheme and host of PublicURL, the only parts of it +// that are safe to log. +func (c Config) PublicOrigin() string { + return c.PublicURL.Scheme + "://" + c.PublicURL.Host +} + +// Load reads and validates configuration from the process environment. +// It reports every missing or invalid variable in a single *Error. +func Load() (Config, error) { + return load(os.LookupEnv) +} + +func load(lookup func(string) (string, bool)) (Config, error) { + r := reader{lookup: lookup} + cfg := Config{ + HTTPAddr: r.listenAddr(EnvHTTPAddr), + PublicURL: r.secureURL(EnvPublicURL), + DataDir: r.dir(EnvDataDir), + ShutdownTimeout: r.duration(EnvShutdownTimeout), + SecretKey: r.secretFile(EnvSecretKeyFile), + + OIDCIssuer: r.secureURL(EnvOIDCIssuer), + OIDCClientID: r.str(EnvOIDCClientID), + OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile), + OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups), + + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), + SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), + + TrustedProxy: r.trustedProxy(EnvTrustedProxy), + + DefaultTZ: r.optional(EnvDefaultTZ), + } + if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { + r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) + } + if len(r.problems) > 0 { + return Config{}, &Error{Problems: r.problems} + } + return cfg, nil +} + +// Problem describes one missing or invalid variable. +type Problem struct { + Var string + Reason string +} + +// Error lists every configuration problem found by Load. +type Error struct { + Problems []Problem +} + +func (e *Error) Error() string { + var b strings.Builder + b.WriteString("invalid configuration:") + for _, p := range e.Problems { + fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason) + } + return b.String() +} + +// Secret holds a sensitive value. It never prints its contents. +type Secret struct { + value string +} + +// Reveal returns the secret value. Call it only where the value is used. +func (s Secret) Reveal() string { return s.value } + +// String redacts the secret so it cannot leak through fmt or logs. +func (s Secret) String() string { return "[REDACTED]" } + +// GoString redacts the secret for %#v. +func (s Secret) GoString() string { return "[REDACTED]" } + +// reader reads variables and collects every problem instead of stopping at +// the first one. +type reader struct { + lookup func(string) (string, bool) + problems []Problem +} + +func (r *reader) fail(name, format string, args ...any) { + r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)}) +} + +// required returns the variable's value, recording a problem when it is +// unset or empty. +func (r *reader) required(name string) (string, bool) { + v, ok := r.lookup(name) + if !ok || strings.TrimSpace(v) == "" { + r.fail(name, "required but not set") + return "", false + } + return v, true +} + +func (r *reader) str(name string) string { + v, _ := r.required(name) + return v +} + +// list parses a comma-separated list. Every entry must be non-empty after +// trimming spaces. +func (r *reader) list(name string) []string { + v, ok := r.required(name) + if !ok { + return nil + } + items := strings.Split(v, ",") + for i, item := range items { + items[i] = strings.TrimSpace(item) + if items[i] == "" { + r.fail(name, "empty entry in comma-separated list %q", v) + return nil + } + } + return items +} + +// trustedProxy parses an optional comma-separated list of IP addresses and +// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer. +func (r *reader) trustedProxy(name string) forwarded.Trusted { + v, _ := r.lookup(name) + t, err := forwarded.ParseTrusted(v) + if err != nil { + r.fail(name, "%v", err) + return nil + } + return t +} + +func (r *reader) listenAddr(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + host, port, err := net.SplitHostPort(v) + if err != nil { + r.fail(name, "invalid listen address %q (want host:port or :port)", v) + return "" + } + // An empty host listens on all interfaces. + if host != "" && net.ParseIP(host) == nil && !validHostname(host) { + r.fail(name, "invalid host %q in listen address (want IP address or hostname)", host) + return "" + } + if _, err := strconv.ParseUint(port, 10, 16); err != nil { + r.fail(name, "invalid port %q in listen address (want 0-65535)", port) + return "" + } + return v +} + +// validHostname reports whether h is a DNS hostname: dot-separated labels of +// 1-63 letters, digits and hyphens, not starting or ending with a hyphen. +func validHostname(h string) bool { + if len(h) > 253 { + return false + } + for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") { + if len(label) < 1 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + } + return true +} + +// secureURL reads an absolute URL that must pass secureurl.Check: https://, +// or http:// to a loopback host only. +func (r *reader) secureURL(name string) *url.URL { + v, ok := r.required(name) + if !ok { + return nil + } + // The value is never echoed: it may carry credentials. + u, err := url.Parse(v) + if err != nil { + r.fail(name, "invalid URL (want absolute https:// URL)") + return nil + } + if err := secureurl.Check(u); err != nil { + r.fail(name, "invalid URL: %v", err) + return nil + } + if u.User != nil { + r.fail(name, "URL must not contain user credentials") + return nil + } + return u +} + +func (r *reader) dir(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + info, err := os.Stat(v) + if err != nil { + r.fail(name, "cannot access directory %q: %v", v, err) + return "" + } + if !info.IsDir() { + r.fail(name, "%q is not a directory", v) + return "" + } + return v +} + +func (r *reader) duration(name string) time.Duration { + v, ok := r.required(name) + if !ok { + return 0 + } + return r.parseDuration(name, v) +} + +// optionalDuration returns def when name is unset. A set value, even an empty +// one, must be a valid positive duration. +func (r *reader) optionalDuration(name string, def time.Duration) time.Duration { + v, ok := r.lookup(name) + if !ok { + return def + } + return r.parseDuration(name, v) +} + +// sessionDuration is optionalDuration restricted to whole milliseconds: +// session expiry is stored as a store.Timestamp, which keeps millisecond +// precision, so a finer duration could not be honoured. +func (r *reader) sessionDuration(name string, def time.Duration) time.Duration { + d := r.optionalDuration(name, def) + if d%time.Millisecond != 0 { + r.fail(name, "duration must be a whole number of milliseconds, got %s", d) + return 0 + } + return d +} + +func (r *reader) parseDuration(name, v string) time.Duration { + d, err := time.ParseDuration(v) + if err != nil { + r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v) + return 0 + } + if d <= 0 { + r.fail(name, "duration must be positive, got %q", v) + return 0 + } + return d +} + +// optional returns the variable's value, or "" when it is unset. +func (r *reader) optional(name string) string { + v, _ := r.lookup(name) + return v +} + +// secretFile reads a secret from the file named by name, which must end in +// _FILE. Setting the secret inline (name without _FILE) is rejected. +func (r *reader) secretFile(name string) Secret { + inline := strings.TrimSuffix(name, "_FILE") + if _, set := r.lookup(inline); set { + r.fail(inline, "secrets must not be set inline; use %s", name) + } + path, ok := r.required(name) + if !ok { + return Secret{} + } + data, err := os.ReadFile(path) + if err != nil { + // Report the cause without echoing any file contents. + r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err)) + return Secret{} + } + value := strings.TrimRight(string(data), "\r\n") + if strings.TrimSpace(value) == "" { + r.fail(name, "secret file %q is empty", path) + return Secret{} + } + return Secret{value: value} +} + +func unwrapPathError(err error) error { + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err + } + return err +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..937474d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,530 @@ +package config + +import ( + "errors" + "fmt" + "net/netip" + "net/url" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" +) + +func TestLoad(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretPath := filepath.Join(dir, "secret_key") + if err := os.WriteFile(secretPath, []byte("s3cret-value\n"), 0o600); err != nil { + t.Fatal(err) + } + unreadablePath := filepath.Join(dir, "missing_secret") + clientSecretPath := filepath.Join(dir, "oidc_client_secret") + if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { + t.Fatal(err) + } + emptyPath := filepath.Join(dir, "empty_secret") + if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + + valid := func() map[string]string { + return map[string]string{ + EnvHTTPAddr: ":8080", + EnvPublicURL: "https://meals.example.com", + EnvDataDir: dir, + EnvShutdownTimeout: "15s", + EnvSecretKeyFile: secretPath, + + EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/", + EnvOIDCClientID: "mealquest", + EnvOIDCClientSecretFile: clientSecretPath, + EnvOIDCAllowedGroups: "mealquest-admin", + } + } + with := func(changes map[string]string, unset ...string) map[string]string { + env := valid() + for k, v := range changes { + env[k] = v + } + for _, k := range unset { + delete(env, k) + } + return env + } + + tests := []struct { + name string + env map[string]string + badVars []string // variables the error must name; nil means success + }{ + { + name: "all required vars present", + env: valid(), + }, + { + name: "default time zone set", + env: with(map[string]string{EnvDefaultTZ: "America/New_York"}), + }, + { + // Validated only when seeding the household settings, so a + // stale value cannot stop a boot that no longer uses it. + name: "default time zone not an IANA zone", + env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}), + }, + { + name: "one missing", + env: with(nil, EnvHTTPAddr), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "empty value counts as missing", + env: with(map[string]string{EnvDataDir: ""}), + badVars: []string{EnvDataDir}, + }, + { + name: "all missing are collected", + env: map[string]string{}, + badVars: []string{ + EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, + EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, + }, + }, + { + name: "invalid duration", + env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}), + badVars: []string{EnvShutdownTimeout}, + }, + { + name: "non-positive duration", + env: with(map[string]string{EnvShutdownTimeout: "0s"}), + badVars: []string{EnvShutdownTimeout}, + }, + { + name: "invalid listen address", + env: with(map[string]string{EnvHTTPAddr: "8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "non-numeric port", + env: with(map[string]string{EnvHTTPAddr: ":notaport"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "empty port", + env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "port out of range", + env: with(map[string]string{EnvHTTPAddr: ":65536"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "listen address with IP host", + env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:8080"}), + }, + { + name: "listen address with IPv6 host", + env: with(map[string]string{EnvHTTPAddr: "[::1]:8080"}), + }, + { + name: "listen address with hostname", + env: with(map[string]string{EnvHTTPAddr: "localhost:8080"}), + }, + { + name: "listen host with space", + env: with(map[string]string{EnvHTTPAddr: "bad host:8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "listen host with leading hyphen label", + env: with(map[string]string{EnvHTTPAddr: "-bad.example.com:8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "listen host with empty label", + env: with(map[string]string{EnvHTTPAddr: "bad..example.com:8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "listen host with label over 63 chars", + env: with(map[string]string{EnvHTTPAddr: strings.Repeat("a", 64) + ".example.com:8080"}), + badVars: []string{EnvHTTPAddr}, + }, + { + name: "public URL without hostname", + env: with(map[string]string{EnvPublicURL: "http://:8080"}), + badVars: []string{EnvPublicURL}, + }, + { + name: "plain HTTP public URL on a remote host", + env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}), + badVars: []string{EnvPublicURL}, + }, + { + name: "plain HTTP public URL on a non-loopback IP", + env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}), + badVars: []string{EnvPublicURL}, + }, + { + name: "plain HTTP public URL on 127.0.0.1", + env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}), + }, + { + name: "plain HTTP public URL on [::1]", + env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}), + }, + { + name: "relative public URL", + env: with(map[string]string{EnvPublicURL: "meals.example.com"}), + badVars: []string{EnvPublicURL}, + }, + { + name: "data dir does not exist", + env: with(map[string]string{EnvDataDir: filepath.Join(dir, "nope")}), + badVars: []string{EnvDataDir}, + }, + { + name: "unreadable secret file", + env: with(map[string]string{EnvSecretKeyFile: unreadablePath}), + badVars: []string{EnvSecretKeyFile}, + }, + { + name: "secret file path is a directory", + env: with(map[string]string{EnvSecretKeyFile: dir}), + badVars: []string{EnvSecretKeyFile}, + }, + { + name: "empty secret file", + env: with(map[string]string{EnvSecretKeyFile: emptyPath}), + badVars: []string{EnvSecretKeyFile}, + }, + { + name: "inline secret rejected", + env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}), + badVars: []string{"MEALQUEST_SECRET_KEY"}, + }, + { + name: "inline OIDC client secret rejected", + env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}), + badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"}, + }, + { + name: "unreadable OIDC client secret file", + env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}), + badVars: []string{EnvOIDCClientSecretFile}, + }, + { + name: "relative OIDC issuer", + env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}), + badVars: []string{EnvOIDCIssuer}, + }, + { + name: "non-http OIDC issuer", + env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}), + badVars: []string{EnvOIDCIssuer}, + }, + { + name: "plain HTTP OIDC issuer on a remote host", + env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}), + badVars: []string{EnvOIDCIssuer}, + }, + { + name: "plain HTTP OIDC issuer on localhost", + env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}), + }, + { + name: "several allowed groups", + env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}), + }, + { + name: "empty allowed group entry", + env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}), + badVars: []string{EnvOIDCAllowedGroups}, + }, + { + name: "trailing comma in allowed groups", + env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}), + badVars: []string{EnvOIDCAllowedGroups}, + }, + { + name: "blank OIDC client ID", + env: with(map[string]string{EnvOIDCClientID: " "}), + badVars: []string{EnvOIDCClientID}, + }, + { + name: "session lifetimes set", + env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}), + }, + { + name: "session max equal to idle", + env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}), + }, + { + name: "invalid session idle", + env: with(map[string]string{EnvSessionIdle: "a month"}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "empty session idle", + env: with(map[string]string{EnvSessionIdle: ""}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "non-positive session max", + env: with(map[string]string{EnvSessionMax: "-1h"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "whole-millisecond session lifetimes", + env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}), + }, + { + name: "sub-millisecond session idle", + env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "sub-millisecond session max", + env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "session max below idle", + env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "session max below default idle", + env: with(map[string]string{EnvSessionMax: "719h"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "trusted proxy IP", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}), + }, + { + name: "trusted proxy IPs and CIDRs", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}), + }, + { + name: "blank trusted proxy trusts no peer", + env: with(map[string]string{EnvTrustedProxy: " "}), + }, + { + name: "trusted proxy not an IP", + env: with(map[string]string{EnvTrustedProxy: "traefik"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "trusted proxy invalid CIDR", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "trusted proxy CIDR with host bits", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "trusted proxy empty entry", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "trusted proxy trusting everyone", + env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "trusted proxy with port", + env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}), + badVars: []string{EnvTrustedProxy}, + }, + { + name: "several invalid values are all named", + env: with(map[string]string{ + EnvShutdownTimeout: "soon", + EnvSecretKeyFile: unreadablePath, + }, EnvPublicURL), + badVars: []string{EnvPublicURL, EnvShutdownTimeout, EnvSecretKeyFile}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lookup := func(k string) (string, bool) { + v, ok := tc.env[k] + return v, ok + } + cfg, err := load(lookup) + + if tc.badVars == nil { + if err != nil { + t.Fatalf("load() error = %v, want nil", err) + } + return + } + + var cerr *Error + if !errors.As(err, &cerr) { + t.Fatalf("load() error = %v, want *Error", err) + } + var got []string + for _, p := range cerr.Problems { + got = append(got, p.Var) + } + if !slices.Equal(got, tc.badVars) { + t.Errorf("problem vars = %v, want %v", got, tc.badVars) + } + for _, v := range tc.badVars { + if !strings.Contains(err.Error(), v) { + t.Errorf("error message %q does not name %s", err.Error(), v) + } + } + if !reflect.ValueOf(cfg).IsZero() { + t.Errorf("load() returned non-zero Config alongside error: %+v", cfg) + } + }) + } +} + +func TestLoadValues(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + secretPath := filepath.Join(dir, "secret_key") + if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil { + t.Fatal(err) + } + clientSecretPath := filepath.Join(dir, "oidc_client_secret") + if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{ + EnvHTTPAddr: "127.0.0.1:9000", + EnvPublicURL: "http://localhost:9000", + EnvDataDir: dir, + EnvShutdownTimeout: "1m30s", + EnvSecretKeyFile: secretPath, + + EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/", + EnvOIDCClientID: "mealquest", + EnvOIDCClientSecretFile: clientSecretPath, + EnvOIDCAllowedGroups: " mealquest-admin ,family", + + EnvDefaultTZ: "Europe/London", + } + cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + if err != nil { + t.Fatalf("load() error = %v", err) + } + + if cfg.HTTPAddr != "127.0.0.1:9000" { + t.Errorf("HTTPAddr = %q", cfg.HTTPAddr) + } + if cfg.PublicURL.String() != "http://localhost:9000" { + t.Errorf("PublicURL = %q", cfg.PublicURL) + } + if cfg.DataDir != dir { + t.Errorf("DataDir = %q, want %q", cfg.DataDir, dir) + } + if cfg.ShutdownTimeout != 90*time.Second { + t.Errorf("ShutdownTimeout = %v, want 1m30s", cfg.ShutdownTimeout) + } + if cfg.SecretKey.Reveal() != "s3cret-value" { + t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal()) + } + if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want { + t.Errorf("OIDCIssuer = %q, want %q", got, want) + } + if cfg.OIDCClientID != "mealquest" { + t.Errorf("OIDCClientID = %q", cfg.OIDCClientID) + } + if cfg.OIDCClientSecret.Reveal() != "client-s3cret" { + t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal()) + } + if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { + t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) + } + if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour { + t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax) + } + + if cfg.TrustedProxy != nil { + t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy) + } + + env[EnvSessionIdle] = "12h" + env[EnvSessionMax] = "36h" + env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12" + cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + if err != nil { + t.Fatalf("load() error = %v", err) + } + if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour { + t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax) + } + want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")} + if !slices.Equal(cfg.TrustedProxy, want) { + t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want) + } + if cfg.DefaultTZ != "Europe/London" { + t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) + } +} + +func TestSecretRedacted(t *testing.T) { + t.Parallel() + + s := Secret{value: "s3cret-value"} + for _, format := range []string{"%v", "%s", "%+v", "%#v"} { + cfg := Config{SecretKey: s, OIDCClientSecret: s} + for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { + if strings.Contains(out, "s3cret-value") { + t.Errorf("format %s leaked secret: %s", format, out) + } + } + } +} + +func TestPublicURLWithCredentialsRejected(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "https://alice:hunter2@example.com", + "https://alice@example.com", + "https://alice:hunter2@[bad", + } { + env := map[string]string{EnvPublicURL: raw} + _, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + + var cerr *Error + if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvPublicURL }) { + t.Errorf("load(%q) error = %v, want problem for %s", raw, err, EnvPublicURL) + } + if err != nil && (strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "alice")) { + t.Errorf("load(%q) error leaks credentials: %v", raw, err) + } + } +} + +func TestPublicOrigin(t *testing.T) { + t.Parallel() + + u, err := url.Parse("https://alice:hunter2@meals.example.com:8443/app?token=x#frag") + if err != nil { + t.Fatal(err) + } + if got, want := (Config{PublicURL: u}).PublicOrigin(), "https://meals.example.com:8443"; got != want { + t.Errorf("PublicOrigin() = %q, want %q", got, want) + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..bdbba38 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,564 @@ +package db_test + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + created = "2026-10-03T12:00:00.000Z" + updated = "2026-10-04T12:00:00.000Z" +) + +// openQueries returns queries on the writer of a freshly migrated database +// in a temp directory. +func openQueries(t *testing.T) (*store.Store, *db.Queries) { + t.Helper() + ctx := context.Background() + s, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatalf("Migrate: %v", err) + } + return s, db.New(s.Writer) +} + +func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } +func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} } + +func mustNoRows(t *testing.T, what string, err error) { + t.Helper() + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err) + } +} + +func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo { + t.Helper() + p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{ + ID: store.NewID(), + SourceUrl: "https://example.com/" + sha + ".jpg", + LocalPath: "photos/" + sha + ".jpg", + Width: 800, + Height: 600, + Sha256: sha, + FetchedAt: created, + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreatePhoto: %v", err) + } + return p +} + +func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe { + t.Helper() + r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{ + ID: id, + SourceUrl: url, + SiteName: str("Example"), + Title: title, + TotalMinutes: num(30), + PrepMinutes: num(10), + CookMinutes: num(20), + Yield: str("4 servings"), + RawJsonld: `{"@type":"Recipe"}`, + FetchedAt: at, + CreatedAt: at, + UpdatedAt: at, + }) + if err != nil { + t.Fatalf("UpsertRecipe: %v", err) + } + return r +} + +func TestDinners(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created) + photo := createPhoto(t, q, "aa11") + + create := db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Taco Tuesday", + NormalizedName: "taco tuesday", + Status: "regular", + Origin: "regular", + Method: str("stovetop"), + MinutesTotal: num(30), + Difficulty: str("easy"), + Cuisine: str("Mexican"), + IsFavorite: 0, + RecipeID: str(recipe.ID), + PhotoID: str(photo.ID), + GroundingStatus: "grounded", + UpdatedBy: str("alex@example.com"), + CreatedAt: created, + UpdatedAt: created, + } + want := db.Dinner(create) + got, err := q.CreateDinner(ctx, create) + if err != nil { + t.Fatalf("CreateDinner: %v", err) + } + if got != want { + t.Fatalf("CreateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want) + } + _, err = q.GetDinner(ctx, "missing") + mustNoRows(t, "GetDinner missing", err) + + // Second dinner sorts before the first by normalized name, in a + // different status so the status filter is exercised. + other, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Apple Pork Chops", + NormalizedName: "apple pork chops", + Status: "quest_log", + Origin: "side_quest", + GroundingStatus: "pending", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner other: %v", err) + } + third, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Beef Stew", + NormalizedName: "beef stew", + Status: "regular", + Origin: "manual", + GroundingStatus: "manual", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner third: %v", err) + } + listIDs := func(status string) []string { + t.Helper() + ds, err := q.ListDinnersByStatus(ctx, status) + if err != nil { + t.Fatalf("ListDinnersByStatus(%s): %v", status, err) + } + ids := []string{} + for _, d := range ds { + ids = append(ids, d.ID) + } + return ids + } + assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID) + assertIDs(t, "quest_log", listIDs("quest_log"), other.ID) + assertIDs(t, "tried", listIDs("tried")) + + update := db.UpdateDinnerParams{ + ID: create.ID, + Name: "Fish Tacos", + NormalizedName: "fish tacos", + Status: "tried", + Origin: "side_quest", + Method: str("grill"), + MinutesTotal: num(25), + Difficulty: str("medium"), + Cuisine: sql.NullString{}, + RecipeID: sql.NullString{}, + PhotoID: str(photo.ID), + GroundingStatus: "failed", + UpdatedBy: str("sam@example.com"), + UpdatedAt: updated, + } + got, err = q.UpdateDinner(ctx, update) + if err != nil { + t.Fatalf("UpdateDinner: %v", err) + } + want = db.Dinner{ + ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName, + Status: update.Status, Origin: update.Origin, Method: update.Method, + MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty, + Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID, + PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus, + UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated, + } + if got != want { + t.Fatalf("UpdateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want) + } + update.ID = "missing" + _, err = q.UpdateDinner(ctx, update) + mustNoRows(t, "UpdateDinner missing", err) + + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated, + }) + if err != nil { + t.Fatalf("SetDinnerFavorite: %v", err) + } + want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com") + if got != want { + t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want) + } + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated, + }) + if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") { + t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err) + } + _, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated}) + mustNoRows(t, "SetDinnerFavorite missing", err) + + const archivedAt = "2026-10-05T12:00:00.000Z" + got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ + ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt, + }) + if err != nil { + t.Fatalf("ArchiveDinner: %v", err) + } + want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt + if got != want { + t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want) + } + assertIDs(t, "archived", listIDs("archived"), create.ID) + assertIDs(t, "tried after archive", listIDs("tried")) + _, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt}) + mustNoRows(t, "ArchiveDinner missing", err) +} + +func assertIDs(t *testing.T, what string, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + } +} + +func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) { + ctx := context.Background() + s, q := openQueries(t) + const url = "https://example.com/chili" + + _, err := q.GetRecipeBySourceURL(ctx, url) + mustNoRows(t, "GetRecipeBySourceURL before insert", err) + + // saveRecipe is how a caller stores a fetched recipe: upsert by + // source_url, then replace the children, all in one transaction. + saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe { + t.Helper() + var r db.Recipe + err := s.WithTx(ctx, func(tx *sql.Tx) error { + qtx := q.WithTx(tx) + var err error + r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{ + ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title, + TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`, + FetchedAt: at, CreatedAt: at, UpdatedAt: at, + }) + if err != nil { + return err + } + if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil { + return err + } + if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil { + return err + } + for i, text := range ingredients { + if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text, + Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{}, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + for i, text := range steps { + if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatalf("save recipe %q: %v", title, err) + } + return r + } + assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) { + t.Helper() + r, err := q.GetRecipe(ctx, id) + if err != nil { + t.Fatalf("GetRecipe: %v", err) + } + if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt { + t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt) + } + byURL, err := q.GetRecipeBySourceURL(ctx, url) + if err != nil || byURL != r { + t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r) + } + ings, err := q.ListRecipeIngredients(ctx, id) + if err != nil { + t.Fatalf("ListRecipeIngredients: %v", err) + } + gotIngs := []string{} + for i, ing := range ings { + if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) { + t.Fatalf("ingredient %d = %+v", i, ing) + } + gotIngs = append(gotIngs, ing.RawText) + } + assertIDs(t, "ingredients", gotIngs, ingredients...) + sts, err := q.ListRecipeSteps(ctx, id) + if err != nil { + t.Fatalf("ListRecipeSteps: %v", err) + } + gotSteps := []string{} + for i, st := range sts { + if st.RecipeID != id || st.Position != int64(i) { + t.Fatalf("step %d = %+v", i, st) + } + gotSteps = append(gotSteps, st.Text) + } + assertIDs(t, "steps", gotSteps, steps...) + } + + first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + + // Re-fetching the same URL keeps the id and created_at and replaces the + // fields and children. + second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + if second.ID != first.ID { + t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID) + } + assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + + // A different recipe's children are not listed. + upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created) + var n int + if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 { + t.Fatalf("recipes count = %d, %v; want 2", n, err) + } + _, err = q.GetRecipe(ctx, "missing") + mustNoRows(t, "GetRecipe missing", err) +} + +func TestPhotos(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + + _, err := q.GetPhotoBySHA256(ctx, "bb22") + mustNoRows(t, "GetPhotoBySHA256 before insert", err) + + first := createPhoto(t, q, "bb22") + createPhoto(t, q, "cc33") + if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created { + t.Fatalf("CreatePhoto returned %+v", first) + } + got, err := q.GetPhotoBySHA256(ctx, "bb22") + if err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first) + } + + // sha256 is not unique in the schema; the earliest photo is returned. + createPhoto(t, q, "bb22") + if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first) + } +} + +func TestHouseholdSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetHouseholdSettings(ctx) + mustNoRows(t, "GetHouseholdSettings before upsert", err) + + p := db.UpsertHouseholdSettingsParams{ + Timezone: "America/New_York", DinnerTime: "17:30", ReminderTime: "16:30", + ReviewTime: "09:00", PlanDraftDow: 5, PlanDraftTime: "09:00", + SuggestionsDow: 0, SuggestionsTime: "08:00", ChatAutosendDow: 6, + ChatAutosendTime: "08:00", CreatedAt: created, UpdatedAt: created, + } + want := db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) + + p = db.UpsertHouseholdSettingsParams{ + Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00", + ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00", + SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0, + ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated), + UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) +} + +func TestAISettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetAISettings(ctx) + mustNoRows(t, "GetAISettings before upsert", err) + + p := db.UpsertAISettingsParams{ + Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3", + Temperature: 0.7, CreatedAt: created, UpdatedAt: created, + } + want := db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want) + + p = db.UpsertAISettingsParams{ + Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude", + Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt, + ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want) +} + +func TestRecipeSearchSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetRecipeSearchSettings(ctx) + mustNoRows(t, "GetRecipeSearchSettings before upsert", err) + + p := db.UpsertRecipeSearchSettingsParams{ + Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created, + } + want := db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) + + p = db.UpsertRecipeSearchSettingsParams{ + Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated, + } + want = db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) +} + +func TestIntegrationsGoogle(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetIntegrationsGoogle(ctx) + mustNoRows(t, "GetIntegrationsGoogle before upsert", err) + + p := db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + want := db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) + + p = db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"), + CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"), + CreatedAt: updated, UpdatedAt: updated, + } + want = db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID, + CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) +} + +func TestChatSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetChatSettings(ctx) + mustNoRows(t, "GetChatSettings before upsert", err) + + p := db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created, + } + want := db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want) + + p = db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated), + LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated, + } + want = db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt, + LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want) +} + +// assertSettings upserts p and checks that both the returned row and a +// fresh Get equal want. On update, want keeps the original created_at. +func assertSettings[P, R any]( + t *testing.T, what string, + upsert func(context.Context, P) (R, error), get func(context.Context) (R, error), + p P, want R, +) { + t.Helper() + ctx := context.Background() + got, err := upsert(ctx, p) + if err != nil { + t.Fatalf("%s upsert: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s upsert returned %+v, want %+v", what, got, want) + } + got, err = get(ctx) + if err != nil { + t.Fatalf("%s get: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s get = %+v, want %+v", what, got, want) + } +} diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go new file mode 100644 index 0000000..3b7b213 --- /dev/null +++ b/internal/db/dinners.sql.go @@ -0,0 +1,321 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dinners.sql + +package db + +import ( + "context" + "database/sql" +) + +const archiveDinner = `-- name: ArchiveDinner :one +UPDATE dinners +SET status = 'archived', + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type ArchiveDinnerParams struct { + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDinner = `-- name: CreateDinner :one +INSERT INTO dinners ( + id, name, normalized_name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, + updated_by, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type CreateDinnerParams struct { + ID string + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + IsFavorite int64 + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, createDinner, + arg.ID, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.IsFavorite, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDinner = `-- name: GetDinner :one +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners +WHERE id = ? +` + +func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) { + row := q.db.QueryRowContext(ctx, getDinner, id) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDinnersByStatus = `-- name: ListDinnersByStatus :many +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners +WHERE status = ? +ORDER BY normalized_name, id +` + +func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) { + rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Dinner + for rows.Next() { + var i Dinner + if err := rows.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDinnerFavorite = `-- name: SetDinnerFavorite :one +UPDATE dinners +SET is_favorite = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type SetDinnerFavoriteParams struct { + IsFavorite int64 + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, setDinnerFavorite, + arg.IsFavorite, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateDinner = `-- name: UpdateDinner :one +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at +` + +type UpdateDinnerParams struct { + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, updateDinner, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/jobs.sql.go b/internal/db/jobs.sql.go new file mode 100644 index 0000000..7835ceb --- /dev/null +++ b/internal/db/jobs.sql.go @@ -0,0 +1,166 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: jobs.sql + +package db + +import ( + "context" + "database/sql" +) + +const claimNextJob = `-- name: ClaimNextJob :one +UPDATE jobs +SET status = 'running', started_at = ?1, updated_at = ?1 +WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1) +RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at +` + +// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows +// when no job is queued. +func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) { + row := q.db.QueryRowContext(ctx, claimNextJob, now) + var i Job + err := row.Scan( + &i.ID, + &i.Kind, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createJob = `-- name: CreateJob :exec +INSERT INTO jobs (id, kind, status, payload, created_at, updated_at) +VALUES (?, ?, 'queued', ?, ?, ?) +` + +type CreateJobParams struct { + ID string + Kind string + Payload string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error { + _, err := q.db.ExecContext(ctx, createJob, + arg.ID, + arg.Kind, + arg.Payload, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const failJob = `-- name: FailJob :execrows +UPDATE jobs +SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2 +WHERE id = ?3 AND status = 'running' +` + +type FailJobParams struct { + Error sql.NullString + Now sql.NullString + ID string +} + +func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failRunningJobs = `-- name: FailRunningJobs :execrows +UPDATE jobs +SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2 +WHERE status = 'running' +` + +type FailRunningJobsParams struct { + Error sql.NullString + Now sql.NullString +} + +// Fails every job left running, at boot before any worker starts. +func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getJob = `-- name: GetJob :one +SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs +WHERE id = ? +` + +func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) { + row := q.db.QueryRowContext(ctx, getJob, id) + var i Job + err := row.Scan( + &i.ID, + &i.Kind, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const publishJobResult = `-- name: PublishJobResult :execrows +UPDATE jobs +SET result = ?, updated_at = ? +WHERE id = ? AND status = 'running' +` + +type PublishJobResultParams struct { + Result sql.NullString + UpdatedAt string + ID string +} + +// Stores a partial result of a running job. +func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const succeedJob = `-- name: SucceedJob :execrows +UPDATE jobs +SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2 +WHERE id = ?3 AND status = 'running' +` + +type SucceedJobParams struct { + Result sql.NullString + Now sql.NullString + ID string +} + +func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) { + result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..a2f8121 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,310 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type AiRun struct { + ID string + Skill string + SkillVersion string + Provider string + Model string + Temperature float64 + Status string + LatencyMs sql.NullInt64 + TokensIn sql.NullInt64 + TokensOut sql.NullInt64 + Error sql.NullString + RequestRedacted string + ResponseRedacted sql.NullString + CreatedAt string + UpdatedAt string +} + +type AiSetting struct { + ID int64 + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type ChatSetting struct { + ID int64 + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +type Dinner struct { + ID string + Name string + NormalizedName string + Status string + Origin string + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + Cuisine sql.NullString + IsFavorite int64 + RecipeID sql.NullString + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +type HouseholdSetting struct { + ID int64 + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + CreatedAt string + UpdatedAt string + UpdatedBy sql.NullString +} + +type IntegrationsGoogle struct { + ID int64 + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +type Job struct { + ID string + Kind string + Status string + Payload string + Result sql.NullString + Error sql.NullString + StartedAt sql.NullString + FinishedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationOutbox struct { + ID string + EventKey string + Channel string + Payload string + Status string + Attempts int64 + NextAttemptAt sql.NullString + LastError sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationPref struct { + Event string + Push int64 + Chat int64 + CreatedAt string + UpdatedAt string +} + +type Photo struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type PlanDay struct { + ID string + PlanID string + Date string + DinnerID sql.NullString + IsNew int64 + CalendarEventID sql.NullString + CreatedAt string + UpdatedAt string +} + +type PushSubscription struct { + ID string + Endpoint string + P256dh string + Auth string + UserAgent sql.NullString + FailureCount int64 + LastSuccessAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Recipe struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type RecipeIngredient struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +type RecipeSearchSetting struct { + ID int64 + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +type RecipeStep struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +type RotationReview struct { + ID string + DinnerID string + PlanDate string + Status string + Decision sql.NullString + DecidedAt sql.NullString + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +type ScheduleRun struct { + ID string + Schedule string + PeriodKey string + CreatedAt string +} + +type Session struct { + IDHash string + Subject string + Email string + Name string + Groups string + CsrfToken string + CreatedAt string + LastSeenAt string + ExpiresAt string +} + +type ShoppingGroup struct { + ID string + ListID string + DinnerID string + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingItem struct { + ID string + GroupID string + Name string + Qty sql.NullString + Needed int64 + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingList struct { + ID string + WeekStart string + PlanID string + Title string + Status string + SentToChatAt sql.NullString + FrozenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Suggestion struct { + ID string + DinnerID string + BatchID string + Reason string + CitedRegularIds string + Status string + DecidedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type SuggestionBatch struct { + ID string + Trigger string + Status string + AiRunID sql.NullString + CreatedAt string + UpdatedAt string +} + +type WeekPlan struct { + ID string + WeekStart string + Status string + AiNote sql.NullString + AiRunID sql.NullString + CalendarSyncedAt sql.NullString + CreatedAt string + UpdatedAt string +} diff --git a/internal/db/photos.sql.go b/internal/db/photos.sql.go new file mode 100644 index 0000000..884ba4d --- /dev/null +++ b/internal/db/photos.sql.go @@ -0,0 +1,86 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: photos.sql + +package db + +import ( + "context" +) + +const createPhoto = `-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at +` + +type CreatePhotoParams struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) { + row := q.db.QueryRowContext(ctx, createPhoto, + arg.ID, + arg.SourceUrl, + arg.LocalPath, + arg.Width, + arg.Height, + arg.Sha256, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one +SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1 +` + +// Finds stored image bytes by content hash. The schema does not make sha256 +// unique, so the earliest-created (lowest id) match is returned. +func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) { + row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..f283584 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type Querier interface { + // An unknown id returns sql.ErrNoRows. + ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + // Marks the oldest queued job running and returns it. Returns sql.ErrNoRows + // when no job is queued. + ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) + CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) + CreateJob(ctx context.Context, arg CreateJobParams) error + CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) + CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error + CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error + DeleteRecipeIngredients(ctx context.Context, recipeID string) error + DeleteRecipeSteps(ctx context.Context, recipeID string) error + FailJob(ctx context.Context, arg FailJobParams) (int64, error) + // Fails every job left running, at boot before any worker starts. + FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) + // Returns sql.ErrNoRows until the row exists. + GetAISettings(ctx context.Context) (AiSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetChatSettings(ctx context.Context) (ChatSetting, error) + GetDinner(ctx context.Context, id string) (Dinner, error) + // Returns sql.ErrNoRows until the row exists. + GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) + GetJob(ctx context.Context, id string) (Job, error) + // Finds stored image bytes by content hash. The schema does not make sha256 + // unique, so the earliest-created (lowest id) match is returned. + GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) + GetRecipe(ctx context.Context, id string) (Recipe, error) + GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) + // Returns sql.ErrNoRows until the row exists. + GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) + ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) + ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) + // Stores a partial result of a running job. + PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) + // An unknown id returns sql.ErrNoRows. + SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) + // An unknown id returns sql.ErrNoRows. + UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) + // A recipe is keyed by source_url: re-fetching the same URL updates the + // existing row and keeps its id and created_at, which the returned row + // carries. Replace its ingredients and steps in the same transaction with + // DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. + UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/recipes.sql.go b/internal/db/recipes.sql.go new file mode 100644 index 0000000..6255443 --- /dev/null +++ b/internal/db/recipes.sql.go @@ -0,0 +1,298 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recipes.sql + +package db + +import ( + "context" + "database/sql" +) + +const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +` + +type CreateRecipeIngredientParams struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error { + _, err := q.db.ExecContext(ctx, createRecipeIngredient, + arg.ID, + arg.RecipeID, + arg.Position, + arg.RawText, + arg.Name, + arg.Qty, + arg.Unit, + arg.Note, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createRecipeStep = `-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +) +` + +type CreateRecipeStepParams struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error { + _, err := q.db.ExecContext(ctx, createRecipeStep, + arg.ID, + arg.RecipeID, + arg.Position, + arg.Text, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID) + return err +} + +const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID) + return err +} + +const getRecipe = `-- name: GetRecipe :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE id = ? +` + +func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipe, id) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE source_url = ? +` + +func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listRecipeIngredients = `-- name: ListRecipeIngredients :many +SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) { + rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeIngredient + for rows.Next() { + var i RecipeIngredient + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.RawText, + &i.Name, + &i.Qty, + &i.Unit, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecipeSteps = `-- name: ListRecipeSteps :many +SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) { + rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeStep + for rows.Next() { + var i RecipeStep + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.Text, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertRecipe = `-- name: UpsertRecipe :one +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +` + +type UpsertRecipeParams struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +// A recipe is keyed by source_url: re-fetching the same URL updates the +// existing row and keeps its id and created_at, which the returned row +// carries. Replace its ingredients and steps in the same transaction with +// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) { + row := q.db.QueryRowContext(ctx, upsertRecipe, + arg.ID, + arg.SourceUrl, + arg.SiteName, + arg.Title, + arg.TotalMinutes, + arg.PrepMinutes, + arg.CookMinutes, + arg.Yield, + arg.RawJsonld, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go new file mode 100644 index 0000000..10c89fd --- /dev/null +++ b/internal/db/settings.sql.go @@ -0,0 +1,433 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: settings.sql + +package db + +import ( + "context" + "database/sql" +) + +const getAISettings = `-- name: GetAISettings :one +SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, getAISettings) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getChatSettings = `-- name: GetChatSettings :one +SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, getChatSettings) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getHouseholdSettings = `-- name: GetHouseholdSettings :one +SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, getHouseholdSettings) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.UpdatedBy, + ) + return i, err +} + +const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one +SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, getIntegrationsGoogle) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one +SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, getRecipeSearchSettings) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAISettings = `-- name: UpsertAISettings :one +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at +` + +type UpsertAISettingsParams struct { + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, upsertAISettings, + arg.Provider, + arg.BaseUrl, + arg.Model, + arg.Temperature, + arg.SystemPrompt, + arg.ApiKeyCt, + arg.ApiKeyLast4, + arg.KeyVerifiedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertChatSettings = `-- name: UpsertChatSettings :one +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at +` + +type UpsertChatSettingsParams struct { + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, upsertChatSettings, + arg.WebhookUrlCt, + arg.SpaceLabel, + arg.LastTestAt, + arg.LastTestStatus, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + updated_by, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_by = excluded.updated_by, + updated_at = excluded.updated_at +RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by +` + +type UpsertHouseholdSettingsParams struct { + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, upsertHouseholdSettings, + arg.Timezone, + arg.DinnerTime, + arg.ReminderTime, + arg.ReviewTime, + arg.PlanDraftDow, + arg.PlanDraftTime, + arg.SuggestionsDow, + arg.SuggestionsTime, + arg.ChatAutosendDow, + arg.ChatAutosendTime, + arg.OnboardingCompletedAt, + arg.UpdatesSeenAt, + arg.UpdatedBy, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.UpdatedBy, + ) + return i, err +} + +const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at +` + +type UpsertIntegrationsGoogleParams struct { + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle, + arg.RefreshTokenCt, + arg.CalendarID, + arg.CalendarName, + arg.ConnectedAt, + arg.LastSyncError, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, api_key_ct, created_at, updated_at +` + +type UpsertRecipeSearchSettingsParams struct { + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings, + arg.Provider, + arg.BaseUrl, + arg.ApiKeyCt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/forwarded/forwarded.go b/internal/forwarded/forwarded.go new file mode 100644 index 0000000..8ac429e --- /dev/null +++ b/internal/forwarded/forwarded.go @@ -0,0 +1,221 @@ +// Package forwarded resolves where a request really came from. The service +// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy; +// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only +// when the peer is a trusted proxy. From anyone else they are ignored, so a +// client cannot spoof its address or scheme. +package forwarded + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/netip" + "strings" +) + +// Header names honoured from trusted proxies. +const ( + HeaderFor = "X-Forwarded-For" + HeaderProto = "X-Forwarded-Proto" +) + +// ErrInvalidHeader marks a malformed forwarded header from a trusted peer. +var ErrInvalidHeader = errors.New("invalid forwarded header") + +// Trusted is the set of proxy addresses whose forwarded headers are honoured. +// The zero value trusts no peer. +type Trusted []netip.Prefix + +// ParseTrusted parses a comma-separated list of IP addresses and CIDR +// prefixes. A blank list trusts no peer. Each entry must be a plain address +// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and +// zero-length prefixes, which would trust every peer, are rejected. +func ParseTrusted(s string) (Trusted, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + var t Trusted + for _, entry := range strings.Split(s, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + return nil, fmt.Errorf("empty entry in comma-separated list %q", s) + } + p, err := parseEntry(entry) + if err != nil { + return nil, err + } + t = append(t, p) + } + return t, nil +} + +func parseEntry(entry string) (netip.Prefix, error) { + var p netip.Prefix + if strings.Contains(entry, "/") { + var err error + p, err = netip.ParsePrefix(entry) + if err != nil { + return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry) + } + if p != p.Masked() { + return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked()) + } + } else { + a, err := netip.ParseAddr(entry) + if err != nil { + return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry) + } + if a.Zone() != "" { + return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry) + } + p = netip.PrefixFrom(a, a.BitLen()) + } + switch { + case p.Addr().Is4In6(): + return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry) + case p.Bits() == 0: + return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry) + } + return p, nil +} + +// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6 +// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4 +// form. +func (t Trusted) Contains(a netip.Addr) bool { + a = normalize(a) + for _, p := range t { + if p.Contains(a) { + return true + } + } + return false +} + +func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") } + +// Origin is where a request came from. +type Origin struct { + // ClientIP is the client's address. + ClientIP netip.Addr + // Scheme is "https" or "http": the scheme the client used. + Scheme string +} + +// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in +// trusted, both forwarded headers are ignored: the client is the peer and the +// scheme is that of the connection. +// +// When the peer is trusted: +// - X-Forwarded-For entries (across all its header lines, in order) are +// walked right to left, skipping trusted proxy addresses; the first +// untrusted address is the client. Entries left of it are client-supplied +// and not examined. If every entry is trusted the leftmost is the client; +// with no X-Forwarded-For the peer is the client. +// - X-Forwarded-Proto, if present, must be a single "http" or "https" and +// is the scheme; without it the connection's scheme is used. +// +// A malformed header from a trusted peer is an error wrapping +// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port. +func Resolve(r *http.Request, trusted Trusted) (Origin, error) { + peer, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr) + } + o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"} + if r.TLS != nil { + o.Scheme = "https" + } + if !trusted.Contains(o.ClientIP) { + return o, nil + } + if values := r.Header.Values(HeaderFor); len(values) > 0 { + o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted) + if err != nil { + return Origin{}, err + } + } + switch values := r.Header.Values(HeaderProto); len(values) { + case 0: + case 1: + switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme { + case "http", "https": + o.Scheme = scheme + default: + return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0]) + } + default: + return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto) + } + return o, nil +} + +// clientFromChain returns the rightmost untrusted address in chain, or the +// leftmost address when all are trusted. +func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) { + var client netip.Addr + for i := len(chain) - 1; i >= 0; i-- { + a, err := netip.ParseAddr(strings.TrimSpace(chain[i])) + if err != nil { + return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i])) + } + client = normalize(a) + if !trusted.Contains(client) { + break + } + } + return client, nil +} + +type contextKey struct{} + +// NewContext returns ctx carrying o. +func NewContext(ctx context.Context, o Origin) context.Context { + return context.WithValue(ctx, contextKey{}, o) +} + +// FromContext returns the Origin ctx carries; ok is false when the request +// was not resolved. +func FromContext(ctx context.Context) (o Origin, ok bool) { + o, ok = ctx.Value(contextKey{}).(Origin) + return o, ok +} + +// ClientIP returns the resolved client address ctx carries. +func ClientIP(ctx context.Context) (netip.Addr, bool) { + o, ok := FromContext(ctx) + return o.ClientIP, ok +} + +// Scheme returns the resolved request scheme ctx carries. +func Scheme(ctx context.Context) (string, bool) { + o, ok := FromContext(ctx) + return o.Scheme, ok +} + +// LogAttr is the log attribute key for the resolved client address. +const LogAttr = "client_ip" + +// LogHandler wraps h so every record logged with a resolved request's +// context carries the client address as LogAttr. +func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} } + +type logHandler struct{ slog.Handler } + +func (h logHandler) Handle(ctx context.Context, rec slog.Record) error { + if ip, ok := ClientIP(ctx); ok { + rec = rec.Clone() + rec.AddAttrs(slog.String(LogAttr, ip.String())) + } + return h.Handler.Handle(ctx, rec) +} + +func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return logHandler{h.Handler.WithAttrs(attrs)} +} + +func (h logHandler) WithGroup(name string) slog.Handler { + return logHandler{h.Handler.WithGroup(name)} +} diff --git a/internal/forwarded/forwarded_test.go b/internal/forwarded/forwarded_test.go new file mode 100644 index 0000000..298e9ab --- /dev/null +++ b/internal/forwarded/forwarded_test.go @@ -0,0 +1,260 @@ +package forwarded + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/netip" + "slices" + "strings" + "testing" +) + +func TestParseTrusted(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want []string + wantErr string + }{ + {in: "", want: nil}, + {in: " ", want: nil}, + {in: "10.60.1.33", want: []string{"10.60.1.33/32"}}, + {in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}}, + {in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}}, + {in: "traefik", wantErr: "invalid IP address"}, + {in: "10.60.1.33:443", wantErr: "invalid IP address"}, + {in: "10.60.1.0/33", wantErr: "invalid CIDR"}, + {in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"}, + {in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"}, + {in: "10.60.1.33,", wantErr: "empty entry"}, + {in: "fe80::1%eth0", wantErr: "zone"}, + {in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"}, + {in: "0.0.0.0/0", wantErr: "every peer"}, + {in: "::/0", wantErr: "every peer"}, + } + for _, tt := range tests { + got, err := ParseTrusted(tt.in) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr) + } + continue + } + if err != nil { + t.Errorf("ParseTrusted(%q) error = %v", tt.in, err) + continue + } + var gotStr []string + for _, p := range got { + gotStr = append(gotStr, p.String()) + } + if !slices.Equal(gotStr, tt.want) { + t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want) + } + } +} + +func TestTrustedContainsMappedIPv4(t *testing.T) { + t.Parallel() + trusted := mustTrusted(t, "10.60.1.33") + if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) { + t.Error("IPv4-mapped form of a trusted address is not trusted") + } + if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) { + t.Error("an empty set trusts a peer") + } +} + +func mustTrusted(t *testing.T, s string) Trusted { + t.Helper() + tr, err := ParseTrusted(s) + if err != nil { + t.Fatal(err) + } + return tr +} + +func TestResolve(t *testing.T) { + t.Parallel() + // 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier. + trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24") + tests := []struct { + name string + peer string + tls bool + xff []string + xfp []string + wantIP string + wantScheme string + }{ + { + name: "untrusted peer, no headers", + peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http", + }, + { + name: "untrusted peer over TLS", + peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https", + }, + { + name: "untrusted peer spoofing both headers is ignored", + peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"}, + wantIP: "198.51.100.9", wantScheme: "http", + }, + { + name: "untrusted peer's malformed headers are ignored, not rejected", + peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"}, + wantIP: "198.51.100.9", wantScheme: "http", + }, + { + name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto", + peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"}, + wantIP: "203.0.113.7", wantScheme: "https", + }, + { + name: "trusted peer seen as IPv4-mapped IPv6", + peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"}, + wantIP: "203.0.113.7", wantScheme: "https", + }, + { + name: "trusted peer without headers is itself the client", + peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http", + }, + { + name: "rightmost untrusted address wins over a client-supplied prefix", + peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"}, + wantIP: "203.0.113.7", wantScheme: "https", + }, + { + name: "trusted proxies in the chain are skipped", + peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"}, + wantIP: "203.0.113.7", wantScheme: "https", + }, + { + name: "several header lines form one chain", + peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"}, + wantIP: "203.0.113.7", wantScheme: "http", + }, + { + name: "garbage left of the client is not examined", + peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"}, + wantIP: "203.0.113.7", wantScheme: "http", + }, + { + name: "all trusted: leftmost is the client", + peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"}, + wantIP: "10.60.2.5", wantScheme: "http", + }, + { + name: "IPv6 client", + peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"}, + wantIP: "2001:db8::7", wantScheme: "https", + }, + { + name: "trusted peer may downgrade to http", + peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"}, + wantIP: "10.60.1.33", wantScheme: "http", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := request(tt.peer, tt.xff, tt.xfp) + if tt.tls { + r.TLS = &tls.ConnectionState{} + } + o, err := Resolve(r, trusted) + if err != nil { + t.Fatalf("Resolve error = %v", err) + } + if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme { + t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme) + } + }) + } +} + +func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) { + t.Parallel() + trusted := mustTrusted(t, "10.60.1.33") + for _, tt := range []struct { + name string + xff, xfp []string + }{ + {"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil}, + {"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil}, + {"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil}, + {"X-Forwarded-For empty", []string{""}, nil}, + {"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}}, + {"X-Forwarded-Proto list", nil, []string{"https, http"}}, + {"X-Forwarded-Proto repeated", nil, []string{"https", "https"}}, + } { + _, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted) + if !errors.Is(err, ErrInvalidHeader) { + t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err) + } + } +} + +func TestResolveBadPeerAddress(t *testing.T) { + t.Parallel() + r := request("@", nil, nil) + _, err := Resolve(r, nil) + if err == nil || errors.Is(err, ErrInvalidHeader) { + t.Errorf("Resolve error = %v, want a peer address error", err) + } +} + +func request(peer string, xff, xfp []string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = peer + for _, v := range xff { + r.Header.Add(HeaderFor, v) + } + for _, v := range xfp { + r.Header.Add(HeaderProto, v) + } + return r +} + +func TestContextHelpers(t *testing.T) { + t.Parallel() + if _, ok := ClientIP(context.Background()); ok { + t.Error("ClientIP ok on an unresolved context") + } + if _, ok := Scheme(context.Background()); ok { + t.Error("Scheme ok on an unresolved context") + } + ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"}) + if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" { + t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok) + } + if s, ok := Scheme(ctx); !ok || s != "https" { + t.Errorf("Scheme = %q %v, want https true", s, ok) + } +} + +func TestLogHandler(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test") + ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"}) + + logger.InfoContext(ctx, "resolved") + logger.InfoContext(context.Background(), "unresolved") + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 2 { + t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String()) + } + if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") { + t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0]) + } + if strings.Contains(lines[1], "client_ip") { + t.Errorf("unresolved line = %q, want no client_ip", lines[1]) + } +} diff --git a/internal/frontend/dist/index.html b/internal/frontend/dist/index.html new file mode 100644 index 0000000..9f2b2eb --- /dev/null +++ b/internal/frontend/dist/index.html @@ -0,0 +1,11 @@ + + + + + MealQuest + + + +

MealQuest web assets have not been built.

+ + diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go new file mode 100644 index 0000000..1a678fe --- /dev/null +++ b/internal/frontend/frontend.go @@ -0,0 +1,127 @@ +// Package frontend serves the embedded PWA build with an SPA fallback. +package frontend + +import ( + "embed" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" + "time" +) + +// embedded holds the web build. The build copies web/dist over the committed +// placeholder index.html. +// +//go:embed all:dist +var embedded embed.FS + +const ( + indexFile = "index.html" + + cacheImmutable = "public, max-age=31536000, immutable" + cacheNoCache = "no-cache" +) + +// noCacheFiles must be revalidated on every load: they name the hashed assets +// (or, for the service worker, control their caching), so a stale copy pins an +// old release. +var noCacheFiles = map[string]bool{ + indexFile: true, + "sw.js": true, + "manifest.webmanifest": true, +} + +// Assets returns the embedded web build rooted at its dist directory. +func Assets() fs.FS { + sub, err := fs.Sub(embedded, "dist") + if err != nil { + panic(err) // "dist" is a valid path, so Sub cannot fail + } + return sub +} + +// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a +// path that is not a file in fsys is answered with index.html so client-side +// routes load the app. Paths equal to or under a reserved prefix (for example +// "/api") are never answered with index.html, whether before or after the path +// is cleaned; they are passed to notFound. +// fsys must contain index.html. +func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) { + if _, err := fs.Stat(fsys, indexFile); err != nil { + return nil, fmt.Errorf("frontend assets: %w", err) + } + return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil +} + +type handler struct { + fsys fs.FS + logger *slog.Logger + notFound http.Handler + reserved []string +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + urlPath := path.Clean("/" + r.URL.Path) + // Check the uncleaned path too, so dot segments cannot escape a reserved + // prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42"). + if h.isReserved(r.URL.Path) || h.isReserved(urlPath) { + h.notFound.ServeHTTP(w, r) + return + } + + name := strings.TrimPrefix(urlPath, "/") + if name == "" || !h.isFile(name) { + name = indexFile + } + h.serveFile(w, r, name) +} + +func (h *handler) isReserved(urlPath string) bool { + for _, prefix := range h.reserved { + if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") { + return true + } + } + return false +} + +func (h *handler) isFile(name string) bool { + info, err := fs.Stat(h.fsys, name) + return err == nil && info.Mode().IsRegular() +} + +func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) { + f, err := h.fsys.Open(name) + if err != nil { + h.fail(w, r, name, err) + return + } + defer f.Close() + content, ok := f.(io.ReadSeeker) + if !ok { + h.fail(w, r, name, errors.New("file does not implement io.Seeker")) + return + } + + if noCacheFiles[name] { + w.Header().Set("Cache-Control", cacheNoCache) + } else { + w.Header().Set("Cache-Control", cacheImmutable) + } + if path.Ext(name) == ".webmanifest" { + w.Header().Set("Content-Type", "application/manifest+json") + } + // Embedded files carry no modification time; ServeContent omits + // Last-Modified for the zero time. + http.ServeContent(w, r, name, time.Time{}, content) +} + +func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) { + h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} diff --git a/internal/frontend/frontend_test.go b/internal/frontend/frontend_test.go new file mode 100644 index 0000000..b6aa8c0 --- /dev/null +++ b/internal/frontend/frontend_test.go @@ -0,0 +1,137 @@ +package frontend + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +const ( + indexBody = "fixture index" + assetBody = "console.log('fixture asset')" + robotsBody = "User-agent: *" + swBody = "self.addEventListener('fetch', () => {})" + manifestBody = `{"name":"MealQuest"}` + notFoundBody = "reserved not found" +) + +func fixture() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte(indexBody)}, + "sw.js": {Data: []byte(swBody)}, + "manifest.webmanifest": {Data: []byte(manifestBody)}, + "assets/index-AbC123.js": {Data: []byte(assetBody)}, + "robots.txt": {Data: []byte(robotsBody)}, + } +} + +func newTestHandler(t *testing.T) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, notFoundBody, http.StatusNotFound) + }) + h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health") + if err != nil { + t.Fatal(err) + } + return h +} + +func TestServe(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + wantBody string + wantCache string + }{ + {"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable}, + {"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, cacheImmutable}, + {"root", http.MethodGet, "/", indexBody, cacheNoCache}, + {"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache}, + {"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache}, + {"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache}, + {"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache}, + {"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache}, + {"directory", http.MethodGet, "/assets", indexBody, cacheNoCache}, + {"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache}, + {"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache}, + {"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable}, + } + h := newTestHandler(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + if got := rec.Header().Get("Cache-Control"); got != tt.wantCache { + t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache) + } + }) + } +} + +func TestManifestContentType(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)) + + if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" { + t.Errorf("Content-Type = %q, want application/manifest+json", got) + } +} + +func TestReservedPrefixesNotServed(t *testing.T) { + t.Parallel() + + h := newTestHandler(t) + for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown", "/auth/../quests/42", "/api%2F..%2Fquests/42"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if got := rec.Body.String(); got != notFoundBody+"\n" { + t.Errorf("body = %q, want the notFound handler's body", got) + } + }) + } +} + +func TestNewHandlerRequiresIndex(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}} + if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil { + t.Fatal("NewHandler without index.html: want error, got nil") + } +} + +func TestAssetsContainsIndex(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil { + t.Fatalf("embedded assets: %v", err) + } +} diff --git a/internal/household/defaults.go b/internal/household/defaults.go new file mode 100644 index 0000000..c74ff80 --- /dev/null +++ b/internal/household/defaults.go @@ -0,0 +1,93 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4). +// SeedAISettings stores it on first boot and the Admin reset restores it. +const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners +similar to their regular rotation, nudging them to try something a little new each +week. Prefer weeknight-friendly recipes with common grocery-store ingredients. +Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations +short, warm and specific — mention the household's own regular dinners when you +explain why something fits. Never suggest a dinner they already make or have +declined.` + +// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model +// are empty until the household picks a provider preset; there is no key. +const ( + SeedAIProvider = "anthropic" + SeedAIBaseURL = "" + SeedAIModel = "" + SeedAITemperature = 0.7 +) + +// seedNotificationPrefs are the notification preferences for a new +// household, one per event: push for every event, chat only for the plan +// and the shopping list. +var seedNotificationPrefs = []struct { + Event string + Push, Chat bool +}{ + {Event: "plan_ready", Push: true, Chat: true}, + {Event: "list_ready", Push: true, Chat: true}, + {Event: "day_of_reminder", Push: true, Chat: false}, + {Event: "new_suggestions", Push: true, Chat: false}, + {Event: "rotation_review", Push: true, Chat: false}, +} + +// SeedAISettings creates the AI settings row with the seed values and +// DefaultPersonaPrompt if it does not exist. An existing row is never +// changed. +func SeedAISettings(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + _, err := q.GetAISettings(ctx) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("household: read AI settings: %w", err) + } + now := store.Now().String() + _, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: SeedAIProvider, + BaseUrl: SeedAIBaseURL, + Model: SeedAIModel, + Temperature: SeedAITemperature, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("household: seed AI settings: %w", err) + } + return nil + }) +} + +// SeedNotificationPrefs creates the seed notification preference row of +// every event that has none. Existing rows are never changed. +func SeedNotificationPrefs(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + now := store.Now().String() + for _, p := range seedNotificationPrefs { + _, err := tx.ExecContext(ctx, + `INSERT INTO notification_prefs (event, push, chat, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (event) DO NOTHING`, + p.Event, p.Push, p.Chat, now, now) + if err != nil { + return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err) + } + } + return nil + }) +} diff --git a/internal/household/defaults_test.go b/internal/household/defaults_test.go new file mode 100644 index 0000000..ec44132 --- /dev/null +++ b/internal/household/defaults_test.go @@ -0,0 +1,220 @@ +package household + +import ( + "context" + "database/sql" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// notificationPrefs returns the stored notification preferences by event as +// [push, chat]. +func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 { + t.Helper() + rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := make(map[string][2]int64) + for rows.Next() { + var event string + var push, chat int64 + if err := rows.Scan(&event, &push, &chat); err != nil { + t.Fatal(err) + } + got[event] = [2]int64{push, chat} + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func TestSeedAISettingsCreatesRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedAISettings(ctx, st); err != nil { + t.Fatal(err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + want := db.AiSetting{ + ID: 1, + Provider: "anthropic", + BaseUrl: "", + Model: "", + Temperature: 0.7, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: got.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl || + got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt || + got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid { + t.Errorf("seeded AI settings = %+v\nwant %+v", got, want) + } + var created store.Timestamp + if err := created.Scan(got.CreatedAt); err != nil { + t.Errorf("created_at: %v", err) + } + if got.UpdatedAt != got.CreatedAt { + t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt) + } +} + +func TestDefaultPersonaPrompt(t *testing.T) { + t.Parallel() + const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" + + "similar to their regular rotation, nudging them to try something a little new each\n" + + "week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" + + "Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" + + "short, warm and specific — mention the household's own regular dinners when you\n" + + "explain why something fits. Never suggest a dinner they already make or have\n" + + "declined." + if DefaultPersonaPrompt != want { + t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want) + } +} + +func TestSeedAISettingsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then configures a provider. + first := migratedStore(t, dir) + if err := SeedAISettings(ctx, first); err != nil { + t.Fatal(err) + } + seeded, err := db.New(first.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: "ollama", + BaseUrl: "http://ollama:11434", + Model: "llama3", + Temperature: 0.2, + SystemPrompt: sql.NullString{String: "Be brief.", Valid: true}, + ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true}, + KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true}, + CreatedAt: seeded.CreatedAt, + UpdatedAt: store.Now().String(), + }) + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot reopens the same database and seeds again. + st := migratedStore(t, dir) + if err := SeedAISettings(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model || + got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt || + string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 || + got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt { + t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed) + } +} + +func TestSeedNotificationPrefsCreatesRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + want := map[string][2]int64{ + "plan_ready": {1, 1}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 0}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } + + var created, updated string + err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated) + if err != nil { + t.Fatal(err) + } + var ts store.Timestamp + if err := ts.Scan(created); err != nil { + t.Errorf("created_at: %v", err) + } + if updated != created { + t.Errorf("updated_at = %q, want created_at %q", updated, created) + } +} + +func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then changes two preferences, and one + // event's row goes missing. + first := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, first); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`, + `UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`, + `DELETE FROM notification_prefs WHERE event = 'new_suggestions'`, + } { + if _, err := first.Writer.ExecContext(ctx, stmt); err != nil { + t.Fatal(err) + } + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot keeps the changed rows and seeds only the missing one. + st := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + want := map[string][2]int64{ + "plan_ready": {0, 0}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 1}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } +} diff --git a/internal/household/settings.go b/internal/household/settings.go new file mode 100644 index 0000000..1e1df89 --- /dev/null +++ b/internal/household/settings.go @@ -0,0 +1,340 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "sync" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Seed values for a new household (CTXD CONTEXT.MD). Days count from +// Sunday = 0, as time.Weekday does. +const ( + SeedDinnerTime = "17:30" + SeedReminderTime = "16:30" + SeedReviewTime = "09:00" // rotation check + SeedPlanDraftDow = 5 // Friday + SeedPlanDraftTime = "09:00" + SeedSuggestionsDow = 0 // Sunday + SeedSuggestionsTime = "08:00" + SeedChatAutosendDow = 6 // Saturday + SeedChatAutosendTime = "08:00" +) + +// ErrNoDefaultZone is returned by SeedSettings when the household settings +// do not exist yet and no default zone was configured to create them with. +var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured") + +// ErrInvalidDefaultZone is returned by SeedSettings when the household +// settings do not exist yet and the configured default zone cannot be loaded. +var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid") + +// Settings are the shared household settings. Times are household-local +// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0. +type Settings struct { + Timezone string `json:"timezone"` + DinnerTime string `json:"dinner_time"` + ReminderTime string `json:"reminder_time"` + ReviewTime string `json:"review_time"` + PlanDraftDow int `json:"plan_draft_dow"` + PlanDraftTime string `json:"plan_draft_time"` + SuggestionsDow int `json:"suggestions_dow"` + SuggestionsTime string `json:"suggestions_time"` + ChatAutosendDow int `json:"chat_autosend_dow"` + ChatAutosendTime string `json:"chat_autosend_time"` + OnboardingCompletedAt *string `json:"onboarding_completed_at"` + UpdatesSeenAt *string `json:"updates_seen_at"` + UpdatedBy *string `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SettingsPatch changes the household settings. Nil fields are left as they +// are. +type SettingsPatch struct { + Timezone *string + DinnerTime *string + ReminderTime *string + ReviewTime *string + PlanDraftDow *int + PlanDraftTime *string + SuggestionsDow *int + SuggestionsTime *string + ChatAutosendDow *int + ChatAutosendTime *string +} + +// Problem is one invalid field of a SettingsPatch, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every invalid field of a SettingsPatch. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid household settings: " + strings.Join(parts, "; ") +} + +// SeedSettings creates the household settings row with zone defaultTZ and the +// seed times and days if it does not exist. An existing row is never changed, +// and defaultTZ is then ignored, valid or not. With no row, an empty +// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone. +func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + _, err := q.GetHouseholdSettings(ctx) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("household: read settings: %w", err) + } + if defaultTZ == "" { + return ErrNoDefaultZone + } + if _, err := LoadZone(defaultTZ); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err) + } + now := store.Now().String() + _, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{ + Timezone: defaultTZ, + DinnerTime: SeedDinnerTime, + ReminderTime: SeedReminderTime, + ReviewTime: SeedReviewTime, + PlanDraftDow: SeedPlanDraftDow, + PlanDraftTime: SeedPlanDraftTime, + SuggestionsDow: SeedSuggestionsDow, + SuggestionsTime: SeedSuggestionsTime, + ChatAutosendDow: SeedChatAutosendDow, + ChatAutosendTime: SeedChatAutosendTime, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("household: seed settings: %w", err) + } + return nil + }) +} + +// SettingsService reads and changes the household settings, and tells +// in-process subscribers (the scheduler) about every change. +type SettingsService struct { + st *store.Store + + // updateMu serialises Update with its publish, so subscribers see + // changes in commit order. + updateMu sync.Mutex + + subsMu sync.Mutex + subs map[int]func(Settings) + nextID int +} + +// NewSettingsService returns a service over st. Call SeedSettings first. +func NewSettingsService(st *store.Store) *SettingsService { + return &SettingsService{st: st, subs: make(map[int]func(Settings))} +} + +// Get returns the household settings. +func (s *SettingsService) Get(ctx context.Context) (Settings, error) { + row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx) + if err != nil { + return Settings{}, fmt.Errorf("household: read settings: %w", err) + } + return fromRow(row), nil +} + +// Update validates patch, applies it, records updatedBy (the signed-in +// member's subject, which must not be empty) and publishes the new settings +// to every subscriber. An invalid patch is a *ValidationError and changes +// nothing. +func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) { + if updatedBy == "" { + return Settings{}, errors.New("household: update settings: no member to record as updated_by") + } + if err := patch.validate(); err != nil { + return Settings{}, err + } + + s.updateMu.Lock() + defer s.updateMu.Unlock() + + var row db.HouseholdSetting + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + cur, err := q.GetHouseholdSettings(ctx) + if err != nil { + return fmt.Errorf("household: read settings: %w", err) + } + p := db.UpsertHouseholdSettingsParams{ + Timezone: cur.Timezone, + DinnerTime: cur.DinnerTime, + ReminderTime: cur.ReminderTime, + ReviewTime: cur.ReviewTime, + PlanDraftDow: cur.PlanDraftDow, + PlanDraftTime: cur.PlanDraftTime, + SuggestionsDow: cur.SuggestionsDow, + SuggestionsTime: cur.SuggestionsTime, + ChatAutosendDow: cur.ChatAutosendDow, + ChatAutosendTime: cur.ChatAutosendTime, + OnboardingCompletedAt: cur.OnboardingCompletedAt, + UpdatesSeenAt: cur.UpdatesSeenAt, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: cur.CreatedAt, + UpdatedAt: store.Now().String(), + } + setString(&p.Timezone, patch.Timezone) + setString(&p.DinnerTime, patch.DinnerTime) + setString(&p.ReminderTime, patch.ReminderTime) + setString(&p.ReviewTime, patch.ReviewTime) + setDow(&p.PlanDraftDow, patch.PlanDraftDow) + setString(&p.PlanDraftTime, patch.PlanDraftTime) + setDow(&p.SuggestionsDow, patch.SuggestionsDow) + setString(&p.SuggestionsTime, patch.SuggestionsTime) + setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) + setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + row, err = q.UpsertHouseholdSettings(ctx, p) + if err != nil { + return fmt.Errorf("household: update settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + + settings := fromRow(row) + s.publish(settings) + return settings, nil +} + +// Subscribe registers fn to receive the new settings after every successful +// Update. fn runs synchronously on the updating goroutine, so it must return +// promptly and must not call Update. The returned function unsubscribes fn. +func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) { + s.subsMu.Lock() + defer s.subsMu.Unlock() + id := s.nextID + s.nextID++ + s.subs[id] = fn + return func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + delete(s.subs, id) + } +} + +func (s *SettingsService) publish(settings Settings) { + s.subsMu.Lock() + fns := make([]func(Settings), 0, len(s.subs)) + for _, fn := range s.subs { + fns = append(fns, fn) + } + s.subsMu.Unlock() + for _, fn := range fns { + fn(settings) + } +} + +func (p SettingsPatch) validate() error { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + if p == (SettingsPatch{}) { + fail("settings", "no settings to change") + } + if p.Timezone != nil { + if _, err := LoadZone(*p.Timezone); err != nil { + fail("timezone", err.Error()) + } + } + for _, t := range []struct { + field string + value *string + }{ + {"dinner_time", p.DinnerTime}, + {"reminder_time", p.ReminderTime}, + {"review_time", p.ReviewTime}, + {"plan_draft_time", p.PlanDraftTime}, + {"suggestions_time", p.SuggestionsTime}, + {"chat_autosend_time", p.ChatAutosendTime}, + } { + if t.value != nil && !clockTime.MatchString(*t.value) { + fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value)) + } + } + for _, d := range []struct { + field string + value *int + }{ + {"plan_draft_dow", p.PlanDraftDow}, + {"suggestions_dow", p.SuggestionsDow}, + {"chat_autosend_dow", p.ChatAutosendDow}, + } { + if d.value != nil && (*d.value < 0 || *d.value > 6) { + fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value)) + } + } + if len(problems) > 0 { + return &ValidationError{Problems: problems} + } + return nil +} + +// clockTime matches a zero-padded 24-hour HH:MM time. +var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) + +func setString(dst *string, v *string) { + if v != nil { + *dst = *v + } +} + +func setDow(dst *int64, v *int) { + if v != nil { + *dst = int64(*v) + } +} + +func fromRow(r db.HouseholdSetting) Settings { + return Settings{ + Timezone: r.Timezone, + DinnerTime: r.DinnerTime, + ReminderTime: r.ReminderTime, + ReviewTime: r.ReviewTime, + PlanDraftDow: int(r.PlanDraftDow), + PlanDraftTime: r.PlanDraftTime, + SuggestionsDow: int(r.SuggestionsDow), + SuggestionsTime: r.SuggestionsTime, + ChatAutosendDow: int(r.ChatAutosendDow), + ChatAutosendTime: r.ChatAutosendTime, + OnboardingCompletedAt: nullable(r.OnboardingCompletedAt), + UpdatesSeenAt: nullable(r.UpdatesSeenAt), + UpdatedBy: nullable(r.UpdatedBy), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func nullable(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/household/settings_test.go b/internal/household/settings_test.go new file mode 100644 index 0000000..a01120c --- /dev/null +++ b/internal/household/settings_test.go @@ -0,0 +1,374 @@ +package household + +import ( + "context" + "errors" + "slices" + "sync" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// migratedStore opens a temp store in dir migrated with the embedded +// migrations, closed when the test ends. +func migratedStore(t *testing.T, dir string) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +// seededService returns a settings service over a migrated temp database +// seeded with zone America/New_York. +func seededService(t *testing.T) *SettingsService { + t.Helper() + st := migratedStore(t, t.TempDir()) + if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + return NewSettingsService(st) +} + +func ptr[T any](v T) *T { return &v } + +func TestSeedSettingsCreatesRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedSettings(ctx, st, "America/Chicago"); err != nil { + t.Fatal(err) + } + got, err := NewSettingsService(st).Get(ctx) + if err != nil { + t.Fatal(err) + } + want := Settings{ + Timezone: "America/Chicago", + DinnerTime: "17:30", + ReminderTime: "16:30", + ReviewTime: "09:00", + PlanDraftDow: 5, + PlanDraftTime: "09:00", + SuggestionsDow: 0, + SuggestionsTime: "08:00", + ChatAutosendDow: 6, + ChatAutosendTime: "08:00", + CreatedAt: got.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if !equalSettings(got, want) { + t.Errorf("seeded settings = %+v\nwant %+v", got, want) + } + var created store.Timestamp + if err := created.Scan(got.CreatedAt); err != nil { + t.Errorf("created_at: %v", err) + } + if got.UpdatedAt != got.CreatedAt { + t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt) + } +} + +func TestSeedSettingsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then changes a setting. + first := migratedStore(t, dir) + if err := SeedSettings(ctx, first, "America/Chicago"); err != nil { + t.Fatal(err) + } + changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam") + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // Later boots reopen the same database with a different default zone, + // an invalid one and none at all. + for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} { + st := migratedStore(t, dir) + if err := SeedSettings(ctx, st, defaultTZ); err != nil { + t.Fatalf("reboot with default zone %q: %v", defaultTZ, err) + } + got, err := NewSettingsService(st).Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(got, changed) { + t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestSeedSettingsRequiresDefaultZone(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + defaultTZ string + is error + }{ + {"unset", "", ErrNoDefaultZone}, + {"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone}, + {"Local", "Local", ErrInvalidDefaultZone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + st := migratedStore(t, t.TempDir()) + err := SeedSettings(ctx, st, tt.defaultTZ) + if err == nil { + t.Fatal("SeedSettings succeeded") + } + if !errors.Is(err, tt.is) { + t.Errorf("error = %v, want %v", err, tt.is) + } + if _, err := NewSettingsService(st).Get(ctx); err == nil { + t.Error("a settings row was created despite the error") + } + }) + } +} + +func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + before, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + + got, err := svc.Update(ctx, SettingsPatch{ + Timezone: ptr("Europe/Paris"), + DinnerTime: ptr("19:00"), + ReminderTime: ptr("00:00"), + ReviewTime: ptr("23:59"), + PlanDraftDow: ptr(4), + PlanDraftTime: ptr("10:30"), + SuggestionsDow: ptr(1), + SuggestionsTime: ptr("07:45"), + ChatAutosendDow: ptr(0), + ChatAutosendTime: ptr("12:00"), + }, "sam") + if err != nil { + t.Fatal(err) + } + want := Settings{ + Timezone: "Europe/Paris", + DinnerTime: "19:00", + ReminderTime: "00:00", + ReviewTime: "23:59", + PlanDraftDow: 4, + PlanDraftTime: "10:30", + SuggestionsDow: 1, + SuggestionsTime: "07:45", + ChatAutosendDow: 0, + ChatAutosendTime: "12:00", + UpdatedBy: ptr("sam"), + CreatedAt: before.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if !equalSettings(got, want) { + t.Errorf("Update = %+v\nwant %+v", got, want) + } + if got.UpdatedAt < before.UpdatedAt { + t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt) + } + stored, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(stored, got) { + t.Errorf("stored = %+v\nwant %+v", stored, got) + } + + // A partial patch changes only its fields and records its own member. + got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex") + if err != nil { + t.Fatal(err) + } + want.DinnerTime = "18:00" + want.UpdatedBy = ptr("alex") + want.UpdatedAt = got.UpdatedAt + if !equalSettings(got, want) { + t.Errorf("partial Update = %+v\nwant %+v", got, want) + } +} + +func TestUpdateRequiresMember(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + notified := 0 + svc.Subscribe(func(Settings) { notified++ }) + + if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil { + t.Fatal("Update with no member succeeded") + } + got, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 { + t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified) + } +} + +func TestUpdateValidation(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + patch SettingsPatch + fields []string + }{ + {"empty patch", SettingsPatch{}, []string{"settings"}}, + {"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}}, + {"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}}, + {"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}}, + {"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}}, + {"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}}, + {"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}}, + {"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}}, + {"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}}, + {"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}}, + {"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}}, + {"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}}, + {"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}}, + {"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}}, + { + "every problem is reported", + SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)}, + []string{"timezone", "dinner_time", "plan_draft_dow"}, + }, + { + "valid fields do not mask an invalid one", + SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)}, + []string{"chat_autosend_dow"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + svc := seededService(t) + before, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + published := 0 + svc.Subscribe(func(Settings) { published++ }) + + _, err = svc.Update(ctx, tt.patch, "sam") + var verr *ValidationError + if !errors.As(err, &verr) { + t.Fatalf("Update error = %v, want *ValidationError", err) + } + var fields []string + for _, p := range verr.Problems { + fields = append(fields, p.Field) + } + if !slices.Equal(fields, tt.fields) { + t.Errorf("problem fields = %v, want %v", fields, tt.fields) + } + + after, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(after, before) { + t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before) + } + if published != 0 { + t.Errorf("invalid patch published %d times", published) + } + }) + } +} + +func TestUpdatePublishesToSubscribers(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + + var mu sync.Mutex + var first, second []Settings + unsubscribe := svc.Subscribe(func(s Settings) { + mu.Lock() + defer mu.Unlock() + first = append(first, s) + }) + svc.Subscribe(func(s Settings) { + mu.Lock() + defer mu.Unlock() + second = append(second, s) + }) + + updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam") + if err != nil { + t.Fatal(err) + } + mu.Lock() + if len(first) != 1 || !equalSettings(first[0], updated) { + t.Errorf("first subscriber got %+v, want [%+v]", first, updated) + } + if len(second) != 1 || !equalSettings(second[0], updated) { + t.Errorf("second subscriber got %+v, want [%+v]", second, updated) + } + mu.Unlock() + + unsubscribe() + updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam") + if err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(first) != 1 { + t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first)) + } + if len(second) != 2 || !equalSettings(second[1], updated) { + t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated) + } +} + +func TestUpdateWithoutSeedFails(t *testing.T) { + t.Parallel() + svc := NewSettingsService(migratedStore(t, t.TempDir())) + if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil { + t.Fatal("Update succeeded with no settings row") + } +} + +// equalSettings compares settings by value, following the nullable pointers. +func equalSettings(a, b Settings) bool { + eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) } + if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) { + return false + } + a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil + b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil + return a == b +} diff --git a/internal/household/week.go b/internal/household/week.go new file mode 100644 index 0000000..8b014c1 --- /dev/null +++ b/internal/household/week.go @@ -0,0 +1,130 @@ +// Package household owns household-local calendar and clock logic. Every +// function works in the household's IANA zone, passed explicitly, and never +// in the process zone (time.Local). +// +// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period +// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01". +// +// Calendar arithmetic is done on UTC midnights, which have no DST, so a date +// plus seven days is always the same weekday a week later. +package household + +import ( + "errors" + "fmt" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Today returns the household-local date at instant now in zone loc. +func Today(now time.Time, loc *time.Location) store.LocalDate { + return store.LocalDateOf(now.In(loc)) +} + +// WeekStart returns the ISO Monday of the week containing d. It panics if d is +// the zero LocalDate. +func WeekStart(d store.LocalDate) store.LocalDate { + t := midnightUTC(d) + // Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6. + sinceMonday := (int(t.Weekday()) + 6) % 7 + return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday)) +} + +// WeekDays returns the seven dates Monday to Sunday of the week starting on +// weekStart. It is an error if weekStart is not a Monday. +func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) { + t, err := monday(weekStart) + if err != nil { + return [7]store.LocalDate{}, err + } + var days [7]store.LocalDate + for i := range days { + days[i] = store.LocalDateOf(t.AddDate(0, 0, i)) + } + return days, nil +} + +// WeekLabel returns the display label of the week starting on weekStart, such +// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week +// spans two months. It is an error if weekStart is not a Monday. +func WeekLabel(weekStart store.LocalDate) (string, error) { + start, err := monday(weekStart) + if err != nil { + return "", err + } + _, week := start.ISOWeek() + end := start.AddDate(0, 0, 6) + if end.Month() == start.Month() { + return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil + } + return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil +} + +// PeriodKey returns the ISO week key of the week containing d, such as +// "2026-W41". The year is the ISO week-numbering year, which differs from the +// calendar year around New Year. It panics if d is the zero LocalDate. +func PeriodKey(d store.LocalDate) string { + year, week := midnightUTC(d).ISOWeek() + return fmt.Sprintf("%04d-W%02d", year, week) +} + +// LocalTime returns the instant at which household clocks in zone loc read +// hhmm (24-hour "HH:MM") on date d. +// +// DST rule: a wall time that does not exist (spring forward) or occurs twice +// (fall back) is read with the UTC offset in effect before the transition. A +// skipped time therefore moves forward by the length of the gap (02:30 on a +// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first +// occurrence. The rule assumes at most one offset change in the 48 hours +// around the wall time, which holds for every real zone. +func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) { + if d.IsZero() { + return time.Time{}, errors.New("household: zero date") + } + clock, err := time.Parse("15:04", hhmm) + if err != nil || clock.Format("15:04") != hhmm { + return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm) + } + y, m, day := d.Date() + // wall is the wall-clock reading expressed as if it were UTC; the real + // instant is wall minus the zone's UTC offset at that instant. + wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC) + before := offset(wall.Add(-24*time.Hour), loc) + after := offset(wall.Add(24*time.Hour), loc) + early := wall.Add(-before) + late := wall.Add(-after) + // Use the post-transition offset only when the pre-transition reading is + // not a real wall time and the post-transition one is. + if offset(early, loc) != before && offset(late, loc) == after { + return late.In(loc), nil + } + return early.In(loc), nil +} + +// offset returns the UTC offset of zone loc at instant t. +func offset(t time.Time, loc *time.Location) time.Duration { + _, secs := t.In(loc).Zone() + return time.Duration(secs) * time.Second +} + +// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate. +func midnightUTC(d store.LocalDate) time.Time { + if d.IsZero() { + panic("household: zero date") + } + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) +} + +// monday returns weekStart as midnight UTC, or an error if it is not a Monday. +func monday(weekStart store.LocalDate) (time.Time, error) { + if weekStart.IsZero() { + return time.Time{}, errors.New("household: zero week start") + } + t := midnightUTC(weekStart) + if t.Weekday() != time.Monday { + return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday()) + } + return t, nil +} diff --git a/internal/household/week_test.go b/internal/household/week_test.go new file mode 100644 index 0000000..6b2b8c1 --- /dev/null +++ b/internal/household/week_test.go @@ -0,0 +1,311 @@ +package household + +import ( + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// TestMain runs the package with the process zone set to UTC, so any use of +// time.Local in place of the household zone shows up as a failure. +func TestMain(m *testing.M) { + time.Local = time.UTC + m.Run() +} + +func loadZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("LoadLocation(%q): %v", name, err) + } + return loc +} + +func date(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} + +func utc(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return ts +} + +func TestTodayUsesHouseholdZone(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + auckland := loadZone(t, "Pacific/Auckland") + for _, tc := range []struct { + now string + loc *time.Location + want string + }{ + // 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and + // Monday 17:30 in Auckland (NZDT, UTC+13). + {"2026-10-05T04:30:00Z", chicago, "2026-10-04"}, + {"2026-10-05T04:30:00Z", auckland, "2026-10-05"}, + // 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday + // 00:30 in Auckland. + {"2026-10-04T11:30:00Z", chicago, "2026-10-04"}, + {"2026-10-04T11:30:00Z", auckland, "2026-10-05"}, + } { + if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want) + } + } +} + +func TestSundayNightMondayMorningWeekAssignment(t *testing.T) { + for _, tc := range []struct { + zone, now string + wantDay string + wantWeekStart string + wantPeriodKey string + wantWeekday time.Weekday + }{ + // Sunday 23:59 local belongs to the week that started the previous + // Monday; one minute later, Monday 00:00 starts a new week. + {"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + {"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + } { + loc := loadZone(t, tc.zone) + now := utc(t, tc.now) + if wd := now.In(loc).Weekday(); wd != tc.wantWeekday { + t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday) + } + today := Today(now, loc) + if today.String() != tc.wantDay { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay) + } + if got := WeekStart(today).String(); got != tc.wantWeekStart { + t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart) + } + if got := PeriodKey(today); got != tc.wantPeriodKey { + t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey) + } + } +} + +func TestWeekStartEveryWeekday(t *testing.T) { + for _, d := range []string{ + "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", + "2026-10-09", "2026-10-10", "2026-10-11", + } { + if got := WeekStart(date(t, d)).String(); got != "2026-10-05" { + t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got) + } + } + if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" { + t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got) + } +} + +func TestWeekDays(t *testing.T) { + days, err := WeekDays(date(t, "2026-09-28")) + if err != nil { + t.Fatal(err) + } + want := []string{ + "2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", + "2026-10-02", "2026-10-03", "2026-10-04", + } + for i, d := range days { + if d.String() != want[i] { + t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i]) + } + } +} + +func TestWeekDaysAcrossDSTChange(t *testing.T) { + // Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on + // Sunday 2026-09-27; neither shifts the dates of those weeks. + for _, tc := range []struct{ start, last string }{ + {"2026-10-26", "2026-11-01"}, + {"2026-09-21", "2026-09-27"}, + {"2026-03-02", "2026-03-08"}, + } { + days, err := WeekDays(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if days[0].String() != tc.start || days[6].String() != tc.last { + t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last) + } + } +} + +func TestISOWeekYearBoundary(t *testing.T) { + for _, tc := range []struct{ day, weekStart, key string }{ + {"2026-12-27", "2026-12-21", "2026-W52"}, + {"2026-12-28", "2026-12-28", "2026-W53"}, + {"2026-12-31", "2026-12-28", "2026-W53"}, + {"2027-01-01", "2026-12-28", "2026-W53"}, + {"2027-01-03", "2026-12-28", "2026-W53"}, + {"2027-01-04", "2027-01-04", "2027-W01"}, + {"2027-01-10", "2027-01-04", "2027-W01"}, + {"2026-01-01", "2025-12-29", "2026-W01"}, + {"2026-10-07", "2026-10-05", "2026-W41"}, + } { + d := date(t, tc.day) + if got := WeekStart(d).String(); got != tc.weekStart { + t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart) + } + if got := PeriodKey(d); got != tc.key { + t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key) + } + } + days, err := WeekDays(date(t, "2026-12-28")) + if err != nil { + t.Fatal(err) + } + if days[6].String() != "2027-01-03" { + t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6]) + } +} + +func TestWeekLabel(t *testing.T) { + for _, tc := range []struct{ start, want string }{ + {"2026-10-05", "Week 41 · Oct 5 – 11"}, + {"2026-09-28", "Week 40 · Sep 28 – Oct 4"}, + {"2026-12-28", "Week 53 · Dec 28 – Jan 3"}, + {"2027-01-04", "Week 1 · Jan 4 – 10"}, + } { + got, err := WeekLabel(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want) + } + } +} + +func TestWeekStartRequired(t *testing.T) { + for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} { + if _, err := WeekDays(d); err == nil { + t.Errorf("WeekDays(%s) succeeded, want error", d) + } + if _, err := WeekLabel(d); err == nil { + t.Errorf("WeekLabel(%s) succeeded, want error", d) + } + } +} + +func TestZeroDatePanics(t *testing.T) { + for name, fn := range map[string]func(){ + "WeekStart": func() { WeekStart(store.LocalDate{}) }, + "PeriodKey": func() { PeriodKey(store.LocalDate{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s(zero) did not panic", name) + } + }() + fn() + }() + } +} + +func TestLocalTime(t *testing.T) { + for _, tc := range []struct { + zone, day, hhmm string + want string // UTC instant + wantWall string // local wall clock of the result + }{ + // Ordinary days. + {"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"}, + {"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"}, + {"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"}, + {"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"}, + + // Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT. + {"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"}, + {"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + {"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + + // Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so + // 01:00–01:59 happens twice; the first (CDT) occurrence is chosen. + {"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"}, + {"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"}, + {"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"}, + {"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"}, + + // Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT. + {"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"}, + {"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"}, + {"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"}, + + // Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so + // 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen. + {"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"}, + } { + loc := loadZone(t, tc.zone) + got, err := LocalTime(loc, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err) + } + if !got.Equal(utc(t, tc.want)) { + t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want) + } + if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall { + t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall) + } + if got.Location() != loc { + t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc) + } + } +} + +func TestLocalTimeDeterministic(t *testing.T) { + // The same ambiguous or skipped input always resolves to the same instant. + chicago := loadZone(t, "America/Chicago") + for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} { + first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + for range 100 { + again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + if !again.Equal(first) { + t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again) + } + } + } +} + +func TestLocalTimeRejectsInvalid(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + d := date(t, "2026-10-05") + for _, hhmm := range []string{ + "", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00", + } { + if got, err := LocalTime(chicago, d, hhmm); err == nil { + t.Errorf("LocalTime(%q) = %s, want error", hhmm, got) + } else if !strings.Contains(err.Error(), "HH:MM") { + t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err) + } + } + if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil { + t.Error("LocalTime(zero date) succeeded") + } +} diff --git a/internal/household/zone.go b/internal/household/zone.go new file mode 100644 index 0000000..23e34c0 --- /dev/null +++ b/internal/household/zone.go @@ -0,0 +1,28 @@ +package household + +import ( + "errors" + "fmt" + "time" + + // Embeds the IANA zone database in the binary, so zones load the same + // way in a minimal image with no /usr/share/zoneinfo. + _ "time/tzdata" +) + +// LoadZone loads the IANA zone name, such as "America/New_York". The empty +// name and "Local", which time.LoadLocation maps to UTC and the process zone, +// are refused: the household zone is never implied. +func LoadZone(name string) (*time.Location, error) { + if name == "" { + return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)") + } + if name == "Local" { + return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`) + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name) + } + return loc, nil +} diff --git a/internal/jobs/builtin.go b/internal/jobs/builtin.go new file mode 100644 index 0000000..754babe --- /dev/null +++ b/internal/jobs/builtin.go @@ -0,0 +1,44 @@ +package jobs + +import ( + "context" + "encoding/json" +) + +// KindNoop is the framework's built-in job kind: it does no work and +// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature +// 09's trivial test skill is the harness's, and the startup and readiness +// job: a noop job reaching succeeded proves the runner claims, runs and +// records jobs. +const KindNoop = "noop" + +// NoopPayload is the payload of a noop job. +type NoopPayload struct{} + +// NoopResult is the result of a noop job. +type NoopResult struct{} + +// PublicJSON returns {"ok":true}. +func (NoopResult) PublicJSON() ([]byte, error) { + return json.Marshal(struct { + OK bool `json:"ok"` + }{OK: true}) +} + +func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) { + return NoopResult{}, nil +} + +// Builtins enqueues the framework's built-in job kinds. +type Builtins struct { + Noop *Kind[NoopPayload] +} + +// RegisterBuiltins registers the framework's built-in job kinds on r. +func RegisterBuiltins(r *Runner) (*Builtins, error) { + noop, err := Register(r, KindNoop, runNoop) + if err != nil { + return nil, err + } + return &Builtins{Noop: noop}, nil +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..337addc --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,461 @@ +// Package jobs runs long work in the background. A job is a row in the jobs +// table: Enqueue inserts it queued, and a fixed pool of workers claims queued +// jobs oldest first, so the table itself is the queue and a job enqueued +// before a restart still runs after it. +// +// Each job kind has one handler, registered before Start. A handler takes a +// typed payload and returns a result implementing Public; it may publish +// partial results while it runs. The runner stores only what a result's +// PublicJSON returns and, for a failed job, only a failure code: either a +// *Failure code the kind declared at registration or one of the runner's own +// codes. Handler error text, which may quote a URL, token or response body +// from a dependency, is never stored, returned by the API or logged. +package jobs + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "regexp" + "sync" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Status is the state of a job. +type Status string + +// Job states. A job moves queued → running → succeeded or failed. +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" +) + +// Failure codes the runner stores itself. A kind cannot declare them. +const ( + // CodeInterrupted is the error of a job a previous process left running. + // It is fixed framework text, not a snake-case code, so no kind can + // declare it. + CodeInterrupted = "interrupted by restart" + // CodeInternal is the error of a job whose handler failed with anything + // but a *Failure with a code its kind declared, or whose payload or + // result could not be encoded. The underlying error is neither stored + // nor logged. + CodeInternal = "internal_error" + // CodeUnknownKind is the error of a job whose kind has no handler. + CodeUnknownKind = "unknown_kind" +) + +// Claim retry backoff. A worker whose claim fails with a database error +// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued +// when the error clears is claimed without waiting for a wake token. +const ( + claimRetryMin = 50 * time.Millisecond + claimRetryMax = 5 * time.Second +) + +// codePattern is the form of every failure code: lower-case snake case. +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// ErrNotFound is returned by Get for an unknown job id. +var ErrNotFound = errors.New("jobs: job not found") + +// ErrUnknownKind is returned by Runner.Enqueue for a kind with no +// registered handler. +var ErrUnknownKind = errors.New("jobs: unknown job kind") + +// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does +// not decode into the kind's payload type. +var ErrInvalidPayload = errors.New("jobs: payload does not decode") + +// Failure is a handler error reporting a failure code the household may +// see. Code must be one the job's kind declared at registration; the runner +// stores it as the job's error. Any other handler error, including a Failure +// with an undeclared code, is stored as CodeInternal. +type Failure struct { + Code string +} + +func (f *Failure) Error() string { return "jobs: failure " + f.Code } + +// Public is a job result. PublicJSON returns the JSON the runner stores and +// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its +// data is public; nothing else of the value is ever encoded. +type Public interface { + PublicJSON() ([]byte, error) +} + +// Handler runs one job with its payload. It may call publish any number of +// times with a partial result, which Get returns while the job runs; its +// return value is the final result. ctx is cancelled when the runner stops. +type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error) + +// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest +// partial or final result, and is null until the handler publishes one. +// Error is set only on a failed job. +type Job struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status Status `json:"status"` + Result json.RawMessage `json:"result"` + Error *string `json:"error"` +} + +// runFunc is a registered Handler with its payload and results as JSON. It +// returns the failure code to store, or "" on success; the logged reason +// never quotes an error. +type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string) + +// handler is a registered kind: run runs a job, and check reports whether +// a payload decodes into the kind's payload type. +type handler struct { + run runFunc + check func(payload []byte) bool +} + +// Runner owns the job handlers and the worker pool. +type Runner struct { + st *store.Store + logger *slog.Logger + workers int + // wake holds up to one token per worker; Enqueue adds one so an idle + // worker looks for the new job. + wake chan struct{} + // claim claims the oldest queued job, marking it running at now. + claim func(ctx context.Context, now sql.NullString) (db.Job, error) + + mu sync.Mutex + handlers map[string]handler + started bool + + wg sync.WaitGroup +} + +// NewRunner returns a runner over st with workers concurrent workers. +func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) { + if st == nil { + return nil, errors.New("jobs: nil store") + } + if logger == nil { + return nil, errors.New("jobs: nil logger") + } + if workers < 1 { + return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers) + } + return &Runner{ + st: st, + logger: logger, + workers: workers, + wake: make(chan struct{}, workers), + claim: db.New(st.Writer).ClaimNextJob, + handlers: make(map[string]handler), + }, nil +} + +// Kind enqueues jobs of one registered kind. +type Kind[P any] struct { + r *Runner + name string +} + +// Register makes h the handler for kind and returns the Kind that enqueues +// its jobs. failureCodes are the *Failure codes h may report. It must be +// called before Start, once per kind. +func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) { + if kind == "" { + return nil, errors.New("jobs: register: empty kind") + } + if h == nil { + return nil, fmt.Errorf("jobs: register %q: nil handler", kind) + } + declared := make(map[string]bool, len(failureCodes)) + for _, code := range failureCodes { + if !codePattern.MatchString(code) { + return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code) + } + if code == CodeInternal || code == CodeUnknownKind { + return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code) + } + declared[code] = true + } + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return nil, fmt.Errorf("jobs: register %q: runner already started", kind) + } + if _, ok := r.handlers[kind]; ok { + return nil, fmt.Errorf("jobs: register %q: kind already registered", kind) + } + run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) { + p, err := decodePayload[P]([]byte(payload)) + if err != nil { + return nil, CodeInternal, "payload does not decode" + } + result, err := h(ctx, p, func(partial R) error { + data, err := publicJSON(partial) + if err != nil { + return err + } + return publish(data) + }) + if err != nil { + var failure *Failure + if !errors.As(err, &failure) { + return nil, CodeInternal, "handler error" + } + if !declared[failure.Code] { + return nil, CodeInternal, "undeclared failure code" + } + return nil, failure.Code, "handler failure" + } + data, err := publicJSON(result) + if err != nil { + return nil, CodeInternal, "result does not encode" + } + return data, "", "" + } + check := func(payload []byte) bool { + _, err := decodePayload[P](payload) + return err == nil + } + r.handlers[kind] = handler{run: run, check: check} + return &Kind[P]{r: r, name: kind}, nil +} + +// decodePayload decodes payload into P, refusing unknown fields. +func decodePayload[P any](payload []byte) (P, error) { + var p P + dec := json.NewDecoder(bytes.NewReader(payload)) + dec.DisallowUnknownFields() + err := dec.Decode(&p) + return p, err +} + +// publicJSON returns v's PublicJSON, refusing anything that is not one JSON +// value. Its error never quotes PublicJSON's error. +func publicJSON(v Public) (json.RawMessage, error) { + data, err := v.PublicJSON() + if err != nil || !json.Valid(data) { + return nil, errors.New("jobs: result has no valid public JSON") + } + return data, nil +} + +// Enqueue stores a queued job with payload and returns its id. A job +// enqueued before Start waits for it. +func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err) + } + return k.r.enqueue(ctx, k.name, data) +} + +// Enqueue stores a queued job of kind with its JSON payload and returns its +// id, as Kind.Enqueue does for a kind known at compile time. It returns +// ErrUnknownKind for a kind with no registered handler and +// ErrInvalidPayload for a payload that does not decode into the kind's +// payload type; neither stores a job. +func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) { + r.mu.Lock() + h, ok := r.handlers[kind] + r.mu.Unlock() + if !ok { + return "", ErrUnknownKind + } + if !h.check(payload) { + return "", ErrInvalidPayload + } + return r.enqueue(ctx, kind, payload) +} + +// enqueue stores a queued job of kind with its encoded payload and wakes a +// worker. +func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) { + id := store.NewID() + now := store.Now().String() + if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{ + ID: id, + Kind: kind, + Payload: string(payload), + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err) + } + select { + case r.wake <- struct{}{}: + default: // every worker already has a token and will look again + } + return id, nil +} + +// Start fails every job a previous process left running with +// CodeInterrupted, then starts the workers. They stop when ctx is cancelled; +// Wait waits for them. A job whose handler returns an error after ctx is +// cancelled is left running, so the next Start fails it as interrupted. +func (r *Runner) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return errors.New("jobs: runner already started") + } + now := store.Now().String() + n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{ + Error: sql.NullString{String: CodeInterrupted, Valid: true}, + Now: sql.NullString{String: now, Valid: true}, + }) + if err != nil { + return fmt.Errorf("jobs: fail interrupted jobs: %w", err) + } + if n > 0 { + r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n) + } + r.started = true + for range r.workers { + r.wg.Add(1) + go r.work(ctx) + } + return nil +} + +// Wait blocks until every worker has stopped. +func (r *Runner) Wait() { + r.wg.Wait() +} + +// Get returns the job with id, or ErrNotFound. +func (r *Runner) Get(ctx context.Context, id string) (Job, error) { + row, err := db.New(r.st.Reader).GetJob(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Job{}, ErrNotFound + } + if err != nil { + return Job{}, fmt.Errorf("jobs: read job: %w", err) + } + job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)} + if row.Result.Valid { + job.Result = json.RawMessage(row.Result.String) + } + if row.Error.Valid { + job.Error = &row.Error.String + } + return job, nil +} + +// work claims and runs queued jobs until ctx is cancelled, waiting for a +// wake token whenever none is queued. A claim that fails with a database +// error is retried with backoff rather than waiting for a token, so a job +// already queued is not stranded by a transient error. +func (r *Runner) work(ctx context.Context) { + defer r.wg.Done() + backoff := claimRetryMin + for ctx.Err() == nil { + job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true}) + switch { + case err == nil: + backoff = claimRetryMin + r.run(ctx, job) + continue + case errors.Is(err, sql.ErrNoRows): + backoff = claimRetryMin + select { + case <-r.wake: + case <-ctx.Done(): + } + continue + case ctx.Err() != nil: + return + } + r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff) + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + } + backoff = min(2*backoff, claimRetryMax) + } +} + +// run runs a claimed job to completion. The final write is made even when +// ctx has been cancelled, so a handler that finishes during shutdown is +// recorded. +func (r *Runner) run(ctx context.Context, job db.Job) { + write := context.WithoutCancel(ctx) + logger := r.logger.With("job_id", job.ID, "kind", job.Kind) + q := db.New(r.st.Writer) + + h, ok := r.handlers[job.Kind] // read-only once started + if !ok { + logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind") + r.fail(write, logger, job.ID, CodeUnknownKind) + return + } + + publish := func(partial json.RawMessage) error { + n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{ + Result: sql.NullString{String: string(partial), Valid: true}, + UpdatedAt: store.Now().String(), + ID: job.ID, + }) + if err != nil { + return fmt.Errorf("jobs: publish result: %w", err) + } + if n != 1 { + return fmt.Errorf("jobs: publish result: job %s is not running", job.ID) + } + return nil + } + + result, code, reason := h.run(ctx, job.Payload, publish) + if code != "" { + if ctx.Err() != nil { + logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start") + return + } + level := slog.LevelInfo + if code == CodeInternal { + level = slog.LevelError + } + logger.Log(ctx, level, "job failed", "code", code, "reason", reason) + r.fail(write, logger, job.ID, code) + return + } + + n, err := q.SucceedJob(write, db.SucceedJobParams{ + Result: sql.NullString{String: string(result), Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: job.ID, + }) + if err == nil && n != 1 { + err = errors.New("job is not running") + } + if err != nil { + logger.ErrorContext(write, "record job success", "error", err) + } +} + +// fail records the job as failed with code. The record errors it logs come +// from the database, never from a handler. +func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) { + n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{ + Error: sql.NullString{String: code, Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: id, + }) + if err == nil && n != 1 { + err = errors.New("job is not running") + } + if err != nil { + logger.ErrorContext(ctx, "record job failure", "error", err) + } +} diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go new file mode 100644 index 0000000..1ab0822 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,606 @@ +package jobs + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// sentinelSecret stands in for a credential a handler holds while it runs. +const sentinelSecret = "sk-sentinel-5f2c9a" + +type countPayload struct { + Steps int `json:"steps"` +} + +type countResult struct { + Done int `json:"done"` +} + +func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) } + +func migratedStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +func newRunner(t *testing.T, st *store.Store, workers int) *Runner { + t.Helper() + return newRunnerLogging(t, st, workers, io.Discard) +} + +func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner { + t.Helper() + r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers) + if err != nil { + t.Fatal(err) + } + return r +} + +// logBuffer collects log output written by concurrent workers. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// start starts r and stops it when the test ends. The returned function +// stops it early and waits for its workers. +func start(t *testing.T, r *Runner) (stop func()) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := r.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + stop = func() { + cancel() + r.Wait() + } + t.Cleanup(stop) + return stop +} + +func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] { + t.Helper() + k, err := Register(r, kind, h, failureCodes...) + if err != nil { + t.Fatal(err) + } + return k +} + +func enqueue[P any](t *testing.T, k *Kind[P], payload P) string { + t.Helper() + id, err := k.Enqueue(context.Background(), payload) + if err != nil { + t.Fatal(err) + } + return id +} + +// waitFor polls Get until done accepts the job, failing after five seconds. +func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + job, err := r.Get(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if done(job) { + return job + } + if time.Now().After(deadline) { + t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result) + } + time.Sleep(5 * time.Millisecond) + } +} + +func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed } + +// rawRow returns every column of the job's row joined, for leak checks. +func rawRow(t *testing.T, st *store.Store, id string) string { + t.Helper() + var cols [10]any + ptrs := make([]any, len(cols)) + for i := range cols { + ptrs[i] = &cols[i] + } + if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil { + t.Fatal(err) + } + return fmt.Sprint(cols[:]...) +} + +func TestJobSucceeds(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 2) + count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + start(t, r) + + id := enqueue(t, count, countPayload{Steps: 3}) + job := waitFor(t, r, id, finished) + if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil { + t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id) + } + if string(job.Result) != `{"done":3}` { + t.Errorf("result = %s, want {\"done\":3}", job.Result) + } + + var startedAt, finishedAt, createdAt, updatedAt, payload string + if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id). + Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil { + t.Fatal(err) + } + for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} { + var ts store.Timestamp + if err := ts.Scan(v); err != nil { + t.Errorf("%s = %q: %v", name, v, err) + } + } + if payload != `{"steps":3}` { + t.Errorf("payload = %s, want {\"steps\":3}", payload) + } +} + +func TestJobFailsWithFailureCode(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"}) + }, "no_regulars") + start(t, r) + + job := waitFor(t, r, enqueue(t, k, countPayload{}), finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" { + t.Fatalf("job = %+v, want failed with code no_regulars", job) + } + if job.Result != nil { + t.Errorf("result = %s, want none", job.Result) + } + var stored string + if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != "no_regulars" { + t.Errorf("stored error = %q, want only the code", stored) + } +} + +func TestUndeclaredFailureCodeIsInternal(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, &Failure{Code: "not_declared"} + }, "no_regulars") + start(t, r) + + job := waitFor(t, r, enqueue(t, k, countPayload{}), finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal { + t.Fatalf("job = %+v, want failed with %q", job, CodeInternal) + } +} + +// credential holds a secret the way config.Secret does: unexported, so +// encoding/json cannot see it. +type credential struct{ value string } + +func (credential) String() string { return "[REDACTED]" } + +// leakyResult holds a secret in exported and unexported fields; only +// Dinners is public. +type leakyResult struct { + Dinners []string + Key credential + Token string + apiKey string +} + +func (l leakyResult) PublicJSON() ([]byte, error) { + return json.Marshal(struct { + Dinners []string `json:"dinners"` + }{l.Dinners}) +} + +// badResult has no valid public JSON; its encoding error quotes a secret. +type badResult struct{} + +func (badResult) PublicJSON() ([]byte, error) { + return nil, fmt.Errorf("encode with key %s", sentinelSecret) +} + +// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot +// surface it in the jobs row, Get's result or the log through its result, +// partial results, *Failure, error text or result encoding error. +func TestSecretsNeverStoredOrLogged(t *testing.T) { + t.Parallel() + st := migratedStore(t) + var logs logBuffer + r := newRunnerLogging(t, st, 1, &logs) + secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret} + typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) { + if err := publish(secretResult); err != nil { + return leakyResult{}, err + } + return secretResult, nil + }) + leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret) + }) + leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, &Failure{Code: sentinelSecret} + }, "quota_exceeded") + failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"}) + }, "quota_exceeded") + badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) { + return badResult{}, nil + }) + stop := start(t, r) + + cases := []struct { + kind *Kind[countPayload] + wantStatus Status + wantResult string + wantError string + }{ + {typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""}, + {leakyError, StatusFailed, "", CodeInternal}, + {leakyFailure, StatusFailed, "", CodeInternal}, + {failure, StatusFailed, "", "quota_exceeded"}, + {badEncoding, StatusFailed, "", CodeInternal}, + } + var ids []string + for _, c := range cases { + job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished) + ids = append(ids, job.ID) + gotError := "" + if job.Error != nil { + gotError = *job.Error + } + if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError { + t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError) + } + body, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), sentinelSecret) { + t.Errorf("%s job as served contains the secret: %s", job.Kind, body) + } + } + stop() + for _, id := range ids { + if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) { + t.Errorf("job %s row contains the secret: %s", id, row) + } + } + if strings.Contains(logs.String(), sentinelSecret) { + t.Errorf("log contains the secret:\n%s", logs.String()) + } + if !strings.Contains(logs.String(), "code="+CodeInternal) { + t.Errorf("log does not record the internal_error code:\n%s", logs.String()) + } +} + +func TestProgressiveResultsVisibleMidRun(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + release := make(chan struct{}) + k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + for i := 1; i < p.Steps; i++ { + if err := publish(countResult{Done: i}); err != nil { + return countResult{}, err + } + select { + case <-release: + case <-ctx.Done(): + return countResult{}, ctx.Err() + } + } + return countResult{Done: p.Steps}, nil + }) + start(t, r) + + id := enqueue(t, k, countPayload{Steps: 3}) + for step := 1; step < 3; step++ { + want := fmt.Sprintf(`{"done":%d}`, step) + job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want }) + if job.Status != StatusRunning || job.Error != nil { + t.Fatalf("mid-run job = %+v, want running with no error", job) + } + release <- struct{}{} + } + job := waitFor(t, r, id, finished) + if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } +} + +func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) { + t.Parallel() + st := migratedStore(t) + + first := newRunner(t, st, 1) + started := make(chan struct{}) + k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) { + if err := publish(countResult{Done: 1}); err != nil { + return countResult{}, err + } + close(started) + <-ctx.Done() + return countResult{}, ctx.Err() + }) + stop := start(t, first) + interrupted := enqueue(t, k, countPayload{}) + <-started + waiting := enqueue(t, k, countPayload{}) // the only worker is busy + stop() + + if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning { + t.Fatalf("after shutdown job = %+v, want left running", job) + } + + second := newRunner(t, st, 1) + register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{Done: 9}, nil + }) + start(t, second) + + job := waitFor(t, second, interrupted, finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" { + t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart") + } + if string(job.Result) != `{"done":1}` { + t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result) + } + if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded { + t.Errorf("queued job = %+v, want run by the new runner", job) + } +} + +func TestWorkerPoolIsBounded(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 2) + var running, peak atomic.Int32 + release := make(chan struct{}) + k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + n := running.Add(1) + for { + p := peak.Load() + if n <= p || peak.CompareAndSwap(p, n) { + break + } + } + <-release + running.Add(-1) + return countResult{}, nil + }) + start(t, r) + + ids := make([]string, 5) + for i := range ids { + ids[i] = enqueue(t, k, countPayload{}) + } + deadline := time.Now().Add(5 * time.Second) + for running.Load() < 2 { + if time.Now().After(deadline) { + t.Fatal("two workers never ran at once") + } + time.Sleep(5 * time.Millisecond) + } + close(release) + for _, id := range ids { + if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded { + t.Errorf("job = %+v, want succeeded", job) + } + } + if got := peak.Load(); got != 2 { + t.Errorf("peak concurrent jobs = %d, want 2", got) + } +} + +func TestJobWithoutHandlerFails(t *testing.T) { + t.Parallel() + st := migratedStore(t) + old := newRunner(t, st, 1) + k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, nil + }) + id := enqueue(t, k, countPayload{}) // never started + + r := newRunner(t, st, 1) + start(t, r) + job := waitFor(t, r, id, finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind { + t.Errorf("job = %+v, want failed for a missing handler", job) + } +} + +func TestGetUnknownID(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) { + t.Errorf("Get unknown id error = %v, want ErrNotFound", err) + } +} + +func TestRegisterRefusesBadRegistrations(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + h := func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, nil + } + register(t, r, "count", h) + + if _, err := Register(r, "count", h); err == nil { + t.Error("duplicate kind registered") + } + if _, err := Register(r, "", h); err == nil { + t.Error("empty kind registered") + } + if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil { + t.Error("nil handler registered") + } + for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} { + if _, err := Register(r, "code", h, code); err == nil { + t.Errorf("failure code %q accepted", code) + } + } + start(t, r) + if _, err := Register(r, "late", h); err == nil { + t.Error("kind registered after Start") + } + if err := r.Start(context.Background()); err == nil { + t.Error("second Start succeeded") + } +} + +func TestNewRunnerRefusesBadConfig(t *testing.T) { + t.Parallel() + st := migratedStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRunner(st, logger, 0); err == nil { + t.Error("zero workers accepted") + } + if _, err := NewRunner(nil, logger, 1); err == nil { + t.Error("nil store accepted") + } + if _, err := NewRunner(st, nil, 1); err == nil { + t.Error("nil logger accepted") + } +} + +func TestNoopSucceeds(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + builtins, err := RegisterBuiltins(r) + if err != nil { + t.Fatal(err) + } + start(t, r) + + job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished) + if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil { + t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result) + } +} + +func TestClaimErrorIsRetried(t *testing.T) { + st := migratedStore(t) + logs := &logBuffer{} + r := newRunnerLogging(t, st, 1, logs) + k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + id := enqueue(t, k, countPayload{Steps: 3}) + // Drop the wake token, as for a job queued before a restart, so only + // the retry can find the job. + <-r.wake + + var claims atomic.Int32 + claim := r.claim + r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) { + if claims.Add(1) == 1 { + return db.Job{}, errors.New("database is locked " + sentinelSecret) + } + return claim(ctx, now) + } + start(t, r) + + job := waitFor(t, r, id, finished) + if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } + if n := claims.Load(); n < 2 { + t.Fatalf("claims = %d, want a retry after the error", n) + } + out := logs.String() + if !strings.Contains(out, "claim job failed; retrying") { + t.Fatalf("claim error not logged: %s", out) + } + if strings.Contains(out, sentinelSecret) { + t.Fatalf("claim error text logged: %s", out) + } +} + +func TestRunnerEnqueueByKindName(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + ctx := context.Background() + + if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) { + t.Errorf("unknown kind error = %v, want ErrUnknownKind", err) + } + for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} { + if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) { + t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err) + } + } + var n int + if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("refused enqueues stored %d jobs, want 0", n) + } + + id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)) + if err != nil { + t.Fatal(err) + } + start(t, r) + job := waitFor(t, r, id, finished) + if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } +} diff --git a/internal/secretbox/secretbox.go b/internal/secretbox/secretbox.go new file mode 100644 index 0000000..a8ccb28 --- /dev/null +++ b/internal/secretbox/secretbox.go @@ -0,0 +1,81 @@ +// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only +// owner of secret encryption: every _ct column holds a token produced by +// Box.Encrypt, laid out as nonce ‖ ciphertext. +// +// Errors from this package never contain plaintext, ciphertext or key +// material. +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +// KeySize is the decoded key length in bytes (AES-256). +const KeySize = 32 + +var ( + // ErrKeyEncoding means the key is not valid standard base64. + ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64") + // ErrKeyLength means the decoded key is not exactly KeySize bytes. + ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes") + // ErrMalformed means a token is too short to hold a nonce and tag. + ErrMalformed = errors.New("secretbox: token is too short") + // ErrDecrypt means a token failed authentication: it was corrupted or + // sealed under a different key. + ErrDecrypt = errors.New("secretbox: token failed authentication") +) + +// Box seals and opens secrets under one key. It is safe for concurrent use. +type Box struct { + aead cipher.AEAD +} + +// New returns a Box for encodedKey, the standard-base64 encoding of a +// KeySize-byte key. +func New(encodedKey string) (*Box, error) { + key, err := base64.StdEncoding.Strict().DecodeString(encodedKey) + if err != nil { + // The decoder's error is not wrapped: it describes the key's content. + return nil, ErrKeyEncoding + } + if len(key) != KeySize { + return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("secretbox: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("secretbox: %w", err) + } + return &Box{aead: aead}, nil +} + +// Encrypt seals plaintext under a fresh random nonce and returns +// nonce ‖ ciphertext. +func (b *Box) Encrypt(plaintext []byte) ([]byte, error) { + nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("secretbox: generate nonce: %w", err) + } + return b.aead.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt authenticates and opens a token produced by Encrypt. +func (b *Box) Decrypt(token []byte) ([]byte, error) { + n := b.aead.NonceSize() + if len(token) < n+b.aead.Overhead() { + return nil, ErrMalformed + } + plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil) + if err != nil { + return nil, ErrDecrypt + } + return plaintext, nil +} diff --git a/internal/secretbox/secretbox_test.go b/internal/secretbox/secretbox_test.go new file mode 100644 index 0000000..a4dd30a --- /dev/null +++ b/internal/secretbox/secretbox_test.go @@ -0,0 +1,200 @@ +package secretbox + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" +) + +// newKey returns a fresh random key and its standard-base64 encoding. +func newKey(t *testing.T) ([]byte, string) { + t.Helper() + key := make([]byte, KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + return key, base64.StdEncoding.EncodeToString(key) +} + +func newBox(t *testing.T) (*Box, []byte, string) { + t.Helper() + key, encoded := newKey(t) + box, err := New(encoded) + if err != nil { + t.Fatal(err) + } + return box, key, encoded +} + +// assertNoLeak fails if err's message contains any of the secrets, either +// raw or base64-encoded. +func assertNoLeak(t *testing.T, err error, secrets ...[]byte) { + t.Helper() + msg := err.Error() + for _, s := range secrets { + if len(s) == 0 { + continue + } + for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} { + if strings.Contains(msg, form) { + t.Errorf("error %q leaks secret material %q", msg, form) + } + } + } +} + +func TestRoundTrip(t *testing.T) { + box, _, _ := newBox(t) + for _, plaintext := range [][]byte{ + []byte("ya29.oauth-refresh-token"), + {}, + bytes.Repeat([]byte{0x00, 0xff}, 4096), + } { + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if len(plaintext) > 0 && bytes.Contains(token, plaintext) { + t.Errorf("token contains the plaintext") + } + got, err := box.Decrypt(token) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("Decrypt = %q, want %q", got, plaintext) + } + } +} + +func TestTokenLayout(t *testing.T) { + box, _, _ := newBox(t) + plaintext := []byte("secret") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + // nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16). + if want := 12 + len(plaintext) + 16; len(token) != want { + t.Errorf("token length = %d, want %d", len(token), want) + } +} + +func TestEncryptUsesFreshNonce(t *testing.T) { + box, _, _ := newBox(t) + plaintext := []byte("same plaintext") + a, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + b, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a, b) { + t.Fatal("two encryptions of the same plaintext are identical") + } + if bytes.Equal(a[:12], b[:12]) { + t.Error("two encryptions reused the same nonce") + } +} + +func TestDecryptRejectsFlippedByte(t *testing.T) { + box, key, encoded := newBox(t) + plaintext := []byte("do-not-leak-me") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + // Flip one bit in every position: nonce, ciphertext and tag. + for i := range token { + tampered := bytes.Clone(token) + tampered[i] ^= 0x01 + got, err := box.Decrypt(tampered) + if !errors.Is(err, ErrDecrypt) { + t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err) + } + if got != nil { + t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i) + } + assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered) + } +} + +func TestDecryptRejectsWrongKey(t *testing.T) { + box, key, encoded := newBox(t) + other, otherKey, otherEncoded := newBox(t) + plaintext := []byte("do-not-leak-me") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + got, err := other.Decrypt(token) + if !errors.Is(err, ErrDecrypt) { + t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err) + } + assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token) +} + +func TestDecryptRejectsShortToken(t *testing.T) { + box, _, _ := newBox(t) + for _, n := range []int{0, 1, 12, 12 + 15} { + token := bytes.Repeat([]byte{0xab}, n) + if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) { + t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err) + } + } +} + +func TestNewRejectsBadKey(t *testing.T) { + key, _ := newKey(t) + tests := []struct { + name string + encoded string + want error + }{ + {"empty", "", ErrKeyLength}, + {"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding}, + {"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding}, + {"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding}, + {"raw key bytes", string(key), ErrKeyEncoding}, + {"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength}, + {"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength}, + {"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + box, err := New(tt.encoded) + if !errors.Is(err, tt.want) { + t.Fatalf("New error = %v, want %v", err, tt.want) + } + if box != nil { + t.Error("New returned a Box alongside the error") + } + assertNoLeak(t, err, key, []byte(tt.encoded)) + }) + } +} + +func TestNewAcceptsBase64Key(t *testing.T) { + key, encoded := newKey(t) + box, err := New(encoded) + if err != nil { + t.Fatal(err) + } + // A Box built from the same key opens tokens sealed by the first. + token, err := box.Encrypt([]byte("x")) + if err != nil { + t.Fatal(err) + } + again, err := New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + if _, err := again.Decrypt(token); err != nil { + t.Errorf("same key failed to decrypt: %v", err) + } +} diff --git a/internal/secureurl/secureurl.go b/internal/secureurl/secureurl.go new file mode 100644 index 0000000..09f3129 --- /dev/null +++ b/internal/secureurl/secureurl.go @@ -0,0 +1,32 @@ +// Package secureurl holds the one transport rule for URLs that carry +// credentials or session cookies: HTTPS, or plain HTTP to a loopback host for +// local development and tests. +package secureurl + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// loopbackHosts are the only hosts plain HTTP is allowed for, compared +// case-insensitively. url.URL's Hostname strips the brackets from "[::1]". +var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true} + +// Check reports an error unless u is an absolute https:// URL, or an http:// +// URL whose host is localhost, 127.0.0.1 or ::1. +func Check(u *url.URL) error { + switch { + case u.Hostname() == "": + return errors.New("URL has no host") + case u.Scheme == "https": + return nil + case u.Scheme == "http" && loopbackHosts[strings.ToLower(u.Hostname())]: + return nil + case u.Scheme == "http": + return errors.New("plain http:// is allowed only for localhost, 127.0.0.1 or [::1]; use https://") + default: + return fmt.Errorf("unsupported scheme %q (want https://)", u.Scheme) + } +} diff --git a/internal/secureurl/secureurl_test.go b/internal/secureurl/secureurl_test.go new file mode 100644 index 0000000..bebc707 --- /dev/null +++ b/internal/secureurl/secureurl_test.go @@ -0,0 +1,40 @@ +package secureurl + +import ( + "net/url" + "testing" +) + +func TestCheck(t *testing.T) { + t.Parallel() + tests := []struct { + raw string + ok bool + }{ + {"https://meals.example.com", true}, + {"https://auth.example.com/application/o/mealquest/", true}, + {"https://192.0.2.10:8443", true}, + {"http://localhost:8080", true}, + {"http://LOCALHOST:8080", true}, + {"http://127.0.0.1:8080/path", true}, + {"http://[::1]:8080", true}, + {"http://[::1]", true}, + {"http://meals.example.com", false}, + {"http://192.0.2.10", false}, + {"http://127.0.0.2", false}, + {"http://localhost.example.com", false}, + {"http://0.0.0.0:8080", false}, + {"ftp://localhost", false}, + {"https://", false}, + {"meals.example.com", false}, + } + for _, tc := range tests { + u, err := url.Parse(tc.raw) + if err != nil { + t.Fatal(err) + } + if err := Check(u); (err == nil) != tc.ok { + t.Errorf("Check(%q) error = %v, want ok=%v", tc.raw, err, tc.ok) + } + } +} diff --git a/internal/server/csrf.go b/internal/server/csrf.go new file mode 100644 index 0000000..65c21a3 --- /dev/null +++ b/internal/server/csrf.go @@ -0,0 +1,42 @@ +package server + +import ( + "crypto/subtle" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" +) + +// CSRFHeader carries the session's CSRF token, which the client reads from +// GET /api/v1/me. +const CSRFHeader = "X-MealQuest-CSRF" + +// requireCSRF passes GET requests to next; every other +// request must carry CSRFHeader equal to its session's CSRF token or get 403 +// csrf. It runs behind requireSession, which puts the session in the context. +func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler { + forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token") + internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error") + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + next.ServeHTTP(w, r) + return + } + s, ok := auth.SessionFrom(r.Context()) + if !ok || s.CSRFToken == "" { + // An empty token would match a missing header. + logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok) + internal(w, r) + return + } + if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 { + forbidden(w, r) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/csrf_test.go b/internal/server/csrf_test.go new file mode 100644 index 0000000..53a69b9 --- /dev/null +++ b/internal/server/csrf_test.go @@ -0,0 +1,167 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" +) + +const ( + csrfJSON = `{"error":{"code":"csrf","message":"missing or invalid CSRF token","retryable":false}}` + methodNotAllowedJSON = `{"error":{"code":"method_not_allowed","message":"method not allowed","retryable":false}}` +) + +func TestCSRFThroughRouter(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + for _, tc := range []struct { + name, method, path string + header []string // nil sends no CSRF header + wantStatus int + wantBody string + }{ + {"POST without header", http.MethodPost, "/api/v1/me", nil, http.StatusForbidden, csrfJSON}, + {"POST with wrong header", http.MethodPost, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON}, + {"POST with empty header", http.MethodPost, "/api/v1/me", []string{""}, http.StatusForbidden, csrfJSON}, + {"POST with token prefix", http.MethodPost, "/api/v1/me", []string{"csrf-token"}, http.StatusForbidden, csrfJSON}, + {"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON}, + {"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON}, + // The right token passes the check and reaches the router, which has + // no POST /api/v1/me, and the settings handler, which refuses the + // empty body. + {"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON}, + {"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body must be a JSON object","retryable":false}}`}, + {"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""}, + // Only GET is exempt; HEAD and OPTIONS need the header too. + {"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON}, + {"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(tc.method, tc.path, nil) + req.AddCookie(validSessionCookie) + if tc.header != nil { + req.Header.Set(CSRFHeader, tc.header[0]) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantBody != "" && rec.Body.String() != tc.wantBody { + t.Errorf("body = %s, want %s", rec.Body.String(), tc.wantBody) + } + }) + } +} + +func TestCSRFUnauthenticatedGets401(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil) + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { + t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON) + } +} + +func TestRequireCSRF(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + for _, tc := range []struct { + name string + method string + session *auth.Session + header string + wantStatus int + }{ + {"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent}, + {"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden}, + {"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent}, + {"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden}, + {"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden}, + {"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError}, + {"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + called := false + h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + ctx := context.Background() + if tc.session != nil { + ctx = auth.WithSession(ctx, *tc.session) + } + req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil) + if tc.header != "" { + req.Header.Set(CSRFHeader, tc.header) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus) + } + if called != (tc.wantStatus == http.StatusNoContent) { + t.Errorf("handler called = %v with status %d", called, rec.Code) + } + }) + } +} + +func TestMe(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + want := map[string]any{ + "name": "Home Cook", + "email": "cook@example.com", + "household": map[string]any{"label": "Our kitchen"}, + "csrfToken": "csrf-token-123", + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("body = %s, want %s", gotJSON, wantJSON) + } +} + +func TestMeNeedsSession(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { + t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON) + } +} diff --git a/internal/server/jobs.go b/internal/server/jobs.go new file mode 100644 index 0000000..9f33d16 --- /dev/null +++ b/internal/server/jobs.go @@ -0,0 +1,107 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// maxJobBody bounds a POST /jobs request body. +const maxJobBody = 64 << 10 + +// jobRequest is the POST /jobs body. Payload is required; a job kind that +// takes no input is sent the empty object {}. +type jobRequest struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// jobCreated is the POST /jobs response. +type jobCreated struct { + ID string `json:"id"` +} + +// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind +// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on. +func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload) + switch { + case errors.Is(err, jobs.ErrUnknownKind): + writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind") + return + case errors.Is(err, jobs.ErrInvalidPayload): + writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind") + return + case err != nil: + logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id}) + } +} + +// decodeJobRequest reads a single JSON object with a non-empty kind and a +// payload, refusing unknown fields, an omitted or null payload and trailing +// data. +func decodeJobRequest(body io.Reader) (jobRequest, error) { + data, err := io.ReadAll(body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit) + } + return jobRequest{}, errors.New("cannot read request body") + } + var req jobRequest + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return jobRequest{}, errors.New("request body must be a JSON object with kind and payload") + } + if _, err := dec.Token(); err != io.EOF { + return jobRequest{}, errors.New("request body must be a single JSON object") + } + if req.Kind == "" { + return jobRequest{}, errors.New("kind: required") + } + if req.Payload == nil { + return jobRequest{}, errors.New("payload: required") + } + if bytes.Equal(req.Payload, []byte("null")) { + return jobRequest{}, errors.New("payload: must not be null") + } + return req, nil +} + +// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest +// result and error. +func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + job, err := runner.Get(r.Context(), chi.URLParam(r, "id")) + if errors.Is(err, jobs.ErrNotFound) { + writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found") + return + } + if err != nil { + logger.ErrorContext(r.Context(), "get job", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, job) + } +} diff --git a/internal/server/jobs_test.go b/internal/server/jobs_test.go new file mode 100644 index 0000000..7e8005b --- /dev/null +++ b/internal/server/jobs_test.go @@ -0,0 +1,246 @@ +package server + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +type draftPayload struct{} + +type draftResult struct { + Days int `json:"days"` +} + +func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) } + +// startedRunner registers a "draft" kind that publishes {"days":1}, waits +// for release and returns {"days":7}, or fails with a *jobs.Failure when +// fail is closed first. It starts the runner and stops it when the test ends. +func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) { + t.Helper() + runner := newTestRunner(t) + kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) { + if err := publish(draftResult{Days: 1}); err != nil { + return draftResult{}, err + } + select { + case <-release: + return draftResult{Days: 7}, nil + case <-fail: + return draftResult{}, &jobs.Failure{Code: "no_regulars"} + case <-ctx.Done(): + return draftResult{}, ctx.Err() + } + }, "no_regulars") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + runner.Wait() + }) + return runner, kind +} + +// pollJob GETs the job until want accepts the body, failing after five +// seconds. +func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + if want(body) { + if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) { + t.Errorf("body fields = %v, want id, kind, status, result, error", keys) + } + return body + } + if time.Now().After(deadline) { + t.Fatalf("job %s never reached the expected state; last %v", id, body) + } + time.Sleep(5 * time.Millisecond) + } +} + +func resultDays(body map[string]any) float64 { + result, _ := body["result"].(map[string]any) + days, _ := result["days"].(float64) + return days +} + +func TestGetJobShowsProgressThenResult(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + runner, kind := startedRunner(t, release, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + id, err := kind.Enqueue(context.Background(), draftPayload{}) + if err != nil { + t.Fatal(err) + } + + mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 }) + if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil { + t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id) + } + + close(release) + done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" }) + if resultDays(done) != 7 || done["error"] != nil { + t.Errorf("finished body = %v, want result {\"days\":7} and no error", done) + } +} + +func TestGetJobShowsFailure(t *testing.T) { + t.Parallel() + fail := make(chan struct{}) + close(fail) + runner, kind := startedRunner(t, nil, fail) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + id, err := kind.Enqueue(context.Background(), draftPayload{}) + if err != nil { + t.Fatal(err) + } + + body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" }) + if body["error"] != "no_regulars" { + t.Errorf("error = %v, want the Failure code", body["error"]) + } +} + +func TestGetJobUnknownIDIsNotFound(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + + rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want { + t.Errorf("body = %+v, want %+v", body, want) + } +} + +func TestGetJobRequiresSession(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestPostJobThenGetProgressAndResult drives the whole flow over the +// router: POST enqueues a progressive "draft" job, GET reads its partial +// result while the handler is held mid-run, then its final result. +func TestPostJobThenGetProgressAndResult(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + runner, _ := startedRunner(t, release, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + + rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`))) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body) + } + id, _ := body["id"].(string) + if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" { + t.Fatalf("body = %v, want only a job id", body) + } + + mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 }) + if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil { + t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id) + } + + close(release) + done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" }) + if done["id"] != id || resultDays(done) != 7 || done["error"] != nil { + t.Errorf("finished body = %v, want result {\"days\":7} and no error", done) + } +} + +func TestPostJobRefusesBadRequests(t *testing.T) { + t.Parallel() + runner, _ := startedRunner(t, nil, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + + for _, tc := range []struct { + name, body, code, message string + }{ + {"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"}, + {"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"}, + {"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"}, + {"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"}, + {"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"}, + {"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"}, + {"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want { + t.Errorf("body = %+v, want %+v", body, want) + } + }) + } +} + +func TestPostJobNeedsSessionAndCSRF(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + const body = `{"kind":"noop","payload":{}}` + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { + t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON) + } + + for _, header := range []string{"", "wrong-token"} { + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)) + req.AddCookie(validSessionCookie) + if header != "" { + req.Header.Set(CSRFHeader, header) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON { + t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON) + } + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go new file mode 100644 index 0000000..88b60a5 --- /dev/null +++ b/internal/server/login_test.go @@ -0,0 +1,140 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// newDiscoveryProvider serves the OIDC discovery document auth.New needs; +// login only redirects to its authorization endpoint. +func newDiscoveryProvider(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "userinfo_endpoint": srv.URL + "/userinfo", + "end_session_endpoint": srv.URL + "/end-session/", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +// newLoginRouter wires the production router as cmd/server does: the real +// auth flow and session manager, trusting Traefik only. +func newLoginRouter(t *testing.T, issuer string) http.Handler { + t.Helper() + ctx := context.Background() + db, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + sessions, err := auth.NewSessionManager(db, 720*time.Hour, 4320*time.Hour) + if err != nil { + t.Fatal(err) + } + logger := slog.New(forwarded.LogHandler(slog.NewTextHandler(io.Discard, nil))) + flow, err := auth.New(ctx, auth.Config{ + Issuer: issuer, + ClientID: "mealquest", + ClientSecret: "client-s3cret", + PublicURL: &url.URL{Scheme: "https", Host: "meals.example.com"}, + SecretKey: []byte("server-secret-key"), + AllowedGroups: []string{"mealquest-admin"}, + }, sessions, logger) + if err != nil { + t.Fatal(err) + } + trusted, err := forwarded.ParseTrusted(traefik) + if err != nil { + t.Fatal(err) + } + r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +// TestLoginThroughRouter starts a login through the production router. The +// redirect URI and the Secure login cookie come from MEALQUEST_PUBLIC_URL +// whatever the direct peer: forwarded headers from an untrusted peer are +// ignored but never fail sign-in. +func TestLoginThroughRouter(t *testing.T) { + t.Parallel() + provider := newDiscoveryProvider(t) + router := newLoginRouter(t, provider.URL) + // Traefik reaches the service over plain http on its internal address; + // the host headers must not leak into the redirect URI. + headers := map[string]string{ + forwarded.HeaderFor: "203.0.113.7", + forwarded.HeaderProto: "https", + "X-Forwarded-Host": "evil.example.com", + } + tests := []struct { + name string + peer string + }{ + {"trusted proxy", traefik + ":41000"}, + {"untrusted peer", "172.17.0.1:5000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, originRequest("http://mealquest:8080"+auth.LoginPath, tt.peer, headers)) + resp := rec.Result() + if resp.StatusCode != http.StatusFound { + t.Fatalf("login status = %d, want 302", resp.StatusCode) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if got, want := loc.Scheme+"://"+loc.Host+loc.Path, provider.URL+"/authorize"; got != want { + t.Errorf("redirect to %s, want %s", got, want) + } + if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want { + t.Errorf("redirect_uri = %q, want %q from the public URL", got, want) + } + cookies := resp.Cookies() + if len(cookies) != 1 || cookies[0].Name != "__Host-mealquest_login" || !cookies[0].Secure || !cookies[0].HttpOnly { + t.Errorf("login cookies = %v, want one Secure, HttpOnly __Host-mealquest_login", cookies) + } + }) + } +} diff --git a/internal/server/me.go b/internal/server/me.go new file mode 100644 index 0000000..e9a8a26 --- /dev/null +++ b/internal/server/me.go @@ -0,0 +1,52 @@ +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" +) + +// householdLabel is the single shared household identity. +const householdLabel = "Our kitchen" + +type meResponse struct { + Name string `json:"name"` + Email string `json:"email"` + Household householdInfo `json:"household"` + CSRFToken string `json:"csrfToken"` +} + +type householdInfo struct { + Label string `json:"label"` +} + +// me answers GET /api/v1/me with the signed-in member, the household and the +// session's CSRF token for CSRFHeader. +func me(logger *slog.Logger) http.HandlerFunc { + internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error") + return func(w http.ResponseWriter, r *http.Request) { + s, ok := auth.SessionFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "GET /api/v1/me without a session") + internal(w, r) + return + } + body, err := json.Marshal(meResponse{ + Name: s.Name, + Email: s.Email, + Household: householdInfo{Label: householdLabel}, + CSRFToken: s.CSRFToken, + }) + if err != nil { + panic(err) // string fields cannot fail to marshal + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write me response", "error", err) + } + } +} diff --git a/internal/server/origin.go b/internal/server/origin.go new file mode 100644 index 0000000..9f711ad --- /dev/null +++ b/internal/server/origin.go @@ -0,0 +1,34 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" +) + +// resolveOrigin resolves every request's client address and scheme with +// forwarded.Resolve and puts them in the context (read them with +// forwarded.ClientIP and forwarded.Scheme). Forwarded headers are honoured +// only from a peer in trusted. A malformed forwarded header from a trusted +// peer gets 400 bad_request. +func resolveOrigin(logger *slog.Logger, trusted forwarded.Trusted) func(http.Handler) http.Handler { + badRequest := apiErrorHandler(logger, http.StatusBadRequest, "bad_request", "invalid forwarded headers") + internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error") + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + o, err := forwarded.Resolve(r, trusted) + switch { + case errors.Is(err, forwarded.ErrInvalidHeader): + logger.WarnContext(r.Context(), "rejected forwarded headers", "peer", r.RemoteAddr, "error", err) + badRequest(w, r) + case err != nil: + logger.ErrorContext(r.Context(), "resolve request origin", "error", err) + internal(w, r) + default: + next.ServeHTTP(w, r.WithContext(forwarded.NewContext(r.Context(), o))) + } + }) + } +} diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go new file mode 100644 index 0000000..b686164 --- /dev/null +++ b/internal/server/origin_test.go @@ -0,0 +1,145 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" +) + +// traefik is the production proxy's address. +const traefik = "10.60.1.33" + +// probeAuth mounts GET /auth/probe, which reports the request's resolved +// origin, in place of the real auth flow. +type probeAuth struct{} + +func (probeAuth) Mount(r chi.Router) { + r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) { + ip, ipOK := forwarded.ClientIP(r.Context()) + scheme, schemeOK := forwarded.Scheme(r.Context()) + if !ipOK || !schemeOK { + http.Error(w, "origin not resolved", http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "%s %s", ip, scheme) + }) +} + +// newOriginRouter builds the production router trusting Traefik, logging +// through forwarded.LogHandler as cmd/server does. +func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { + t.Helper() + trusted, err := forwarded.ParseTrusted(traefik) + if err != nil { + t.Fatal(err) + } + logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) + r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +func originRequest(target, peer string, headers map[string]string) *http.Request { + req := httptest.NewRequest(http.MethodGet, target, nil) + req.RemoteAddr = peer + for k, v := range headers { + req.Header.Set(k, v) + } + return req +} + +func TestRouterResolvesOrigin(t *testing.T) { + t.Parallel() + spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"} + tests := []struct { + name string + peer string + want string + }{ + {"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"}, + {"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed)) + if rec.Code != http.StatusOK || rec.Body.String() != tt.want { + t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want) + } + }) + } +} + +func TestRouterLogsResolvedClientIP(t *testing.T) { + t.Parallel() + spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"} + tests := []struct { + name string + peer string + want string + }{ + {"trusted proxy", traefik + ":41000", "203.0.113.7"}, + {"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var logs bytes.Buffer + // A failed session lookup is logged with the request's context. + req := originRequest("/api/v1/me", tt.peer, spoofed) + req.AddCookie(brokenSessionCookie) + rec := httptest.NewRecorder() + newOriginRouter(t, &logs).ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + var line map[string]any + if err := json.Unmarshal(logs.Bytes(), &line); err != nil { + t.Fatalf("log %q: %v", logs.String(), err) + } + if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want { + t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want) + } + }) + } +} + +func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) { + t.Parallel() + const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}` + for _, headers := range []map[string]string{ + {forwarded.HeaderFor: "not-an-ip"}, + {forwarded.HeaderProto: "ftp"}, + } { + var logs bytes.Buffer + rec := httptest.NewRecorder() + newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers)) + if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON { + t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON) + } + if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) { + t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String()) + } + + // The same headers from an untrusted peer are not examined at all. + rec = httptest.NewRecorder() + newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers)) + if rec.Code != http.StatusOK { + t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b1f3049 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,120 @@ +// Package server builds the MealQuest HTTP router. +package server + +import ( + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// reservedPrefixes are owned by the API router and are never answered with the +// SPA's index.html. +var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"} + +// RouteMounter registers its own routes on the router. +type RouteMounter interface { + Mount(r chi.Router) +} + +// NewRouter returns the HTTP handler for the service. Every request first +// has its client address and scheme resolved, honouring forwarded headers +// only from a peer in trusted (see resolveOrigin). version is reported by +// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and +// /media; settings serves /api/v1/settings/household; runner serves +// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build +// served for every other GET. /health, /auth and the web build are public. +// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) { + if settings == nil { + return nil, errors.New("server: nil household settings service") + } + if runner == nil { + return nil, errors.New("server: nil job runner") + } + notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found") + methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + + web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...) + if err != nil { + return nil, err + } + + r := chi.NewRouter() + r.Use(resolveOrigin(logger, trusted)) + r.MethodNotAllowed(methodNotAllowed) + r.NotFound(notFound) + + r.Get("/health", health(logger, version)) + auth.Mount(r) + // The session middleware wraps the whole /api/v1 and /media subrouters + // rather than being added with Use: chi skips a subrouter's middleware + // when it has no routes, which would leave it unguarded. + signedIn := requireSession(logger, sessions) + csrf := requireCSRF(logger) + apiV1, media := chi.NewRouter(), chi.NewRouter() + for _, sub := range []*chi.Mux{apiV1, media} { + sub.MethodNotAllowed(methodNotAllowed) + sub.NotFound(notFound) + } + apiV1.Get("/me", me(logger)) + apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) + apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) + apiV1.Post("/jobs", postJob(logger, runner)) + apiV1.Get("/jobs/{id}", getJob(logger, runner)) + r.Route("/api", func(r chi.Router) { + r.MethodNotAllowed(methodNotAllowed) + r.NotFound(notFound) + r.Mount("/v1", signedIn(csrf(apiV1))) + }) + r.Mount("/media", signedIn(media)) + // Catch-all for the PWA. Unknown /auth paths reach the frontend handler, + // which passes them to notFound rather than the SPA fallback. + r.Method(http.MethodGet, "/*", web) + r.Method(http.MethodHead, "/*", web) + return r, nil +} + +type errorBody struct { + Error apiError `json:"error"` +} + +type apiError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +// apiErrorHandler responds with status and a fixed, non-retryable API error. +func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeError(w, r, logger, status, code, message) + } +} + +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` +} + +func health(logger *slog.Logger, version string) http.HandlerFunc { + body, err := json.Marshal(healthResponse{Status: "ok", Version: version}) + if err != nil { + panic(err) // two string fields cannot fail to marshal + } + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write health response", "error", err) + } + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..136d9f8 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,290 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + fixtureIndex = "fixture index" + fixtureAsset = "console.log('fixture asset')" + notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}` +) + +func newTestRouter(t *testing.T, version string) http.Handler { + t.Helper() + return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t)) +} + +func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + assets := fstest.MapFS{ + "index.html": {Data: []byte(fixtureIndex)}, + "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, + } + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets) + if err != nil { + t.Fatal(err) + } + return r +} + +// stubAuth mounts a fixed GET /auth/login in place of the real auth flow. +type stubAuth struct{} + +func (stubAuth) Mount(r chi.Router) { + r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) { + io.WriteString(w, "stub login") + }) +} + +func TestAuthRoutesMounted(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != "stub login" { + t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /auth/login status = %d, want 405", rec.Code) + } +} + +// newTestStore returns a migrated temp database, closed when the test ends. +func newTestStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +// newTestSettings returns a settings service over a migrated temp database +// seeded with zone America/New_York. +func newTestSettings(t *testing.T) *household.SettingsService { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + return household.NewSettingsService(st) +} + +// newTestRunner returns an unstarted job runner over a migrated temp +// database. +func newTestRunner(t *testing.T) *jobs.Runner { + t.Helper() + runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1) + if err != nil { + t.Fatal(err) + } + return runner +} + +func TestNewRouterRequiresSettings(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil settings service") + } +} + +func TestNewRouterRequiresJobRunner(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil job runner") + } +} + +func TestHealth(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(newTestRouter(t, "1.2.3")) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var got map[string]string + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + want := map[string]string{"status": "ok", "version": "1.2.3"} + if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] { + t.Errorf("body = %v, want %v", got, want) + } +} + +func TestHealthRejectsOtherMethods(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil)) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var got struct { + Error map[string]any `json:"error"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatal(err) + } + want := map[string]any{ + "code": "method_not_allowed", + "message": "method not allowed", + "retryable": false, + } + if !maps.Equal(got.Error, want) { + t.Errorf("error body = %v, want %v", got.Error, want) + } +} + +func TestReservedPathsNotFound(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + tests := []struct { + method string + path string + }{ + {http.MethodGet, "/api"}, + {http.MethodGet, "/api/unknown"}, + {http.MethodGet, "/api/v1/unknown"}, + {http.MethodPost, "/api/v1/unknown"}, + {http.MethodPost, "/api/unknown"}, + {http.MethodGet, "/auth/unknown"}, + {http.MethodGet, "/media/photo.jpg"}, + {http.MethodGet, "/health/deep"}, + {http.MethodGet, "/auth/../quests/42"}, + {http.MethodGet, "/api%2F..%2Fquests/42"}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + t.Parallel() + + // Signed in with the CSRF token, so /api/v1 and /media reach + // their routers. + req := httptest.NewRequest(tt.method, tt.path, nil) + req.AddCookie(validSessionCookie) + req.Header.Set(CSRFHeader, testSession.CSRFToken) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if got := rec.Body.String(); got != notFoundJSON { + t.Errorf("body = %s, want %s", got, notFoundJSON) + } + }) + } +} + +func TestFrontend(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + tests := []struct { + path string + wantBody string + wantCache string + }{ + {"/", fixtureIndex, "no-cache"}, + {"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"}, + {"/quests/42", fixtureIndex, "no-cache"}, + {"/quests/42/edit", fixtureIndex, "no-cache"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + if got := rec.Header().Get("Cache-Control"); got != tt.wantCache { + t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache) + } + }) + } +} + +func TestHealthNotShadowed(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + + if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) { + t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body) + } +} + +func TestFrontendRejectsOtherMethods(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil)) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if strings.Contains(rec.Body.String(), fixtureIndex) { + t.Errorf("body = %q, want no index.html", rec.Body.String()) + } +} diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..e718389 --- /dev/null +++ b/internal/server/session.go @@ -0,0 +1,37 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" +) + +// SessionLookup finds the live session a request carries; it returns +// auth.ErrNoSession when there is none. *auth.SessionManager implements it. +type SessionLookup interface { + SessionFor(r *http.Request) (auth.Session, error) +} + +// requireSession is the RequireSession middleware. It passes requests with a +// live session to next with the session in the context (read it with +// auth.SessionFrom or auth.ActorFrom); others get 401 unauthenticated. +func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler { + unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required") + internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error") + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, err := sessions.SessionFor(r) + switch { + case errors.Is(err, auth.ErrNoSession): + unauthenticated(w, r) + case err != nil: + logger.ErrorContext(r.Context(), "look up session", "error", err) + internal(w, r) + default: + next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s))) + } + }) + } +} diff --git a/internal/server/session_test.go b/internal/server/session_test.go new file mode 100644 index 0000000..d221b4c --- /dev/null +++ b/internal/server/session_test.go @@ -0,0 +1,152 @@ +package server + +import ( + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" +) + +const ( + unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}` + internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}` +) + +var ( + validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"} + brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"} + testSession = auth.Session{ + Subject: "user-123", + Email: "cook@example.com", + Name: "Home Cook", + Groups: []string{"mealquest-admin"}, + CSRFToken: "csrf-token-123", + } +) + +// stubSessions knows one valid session cookie; the broken cookie fails the +// lookup as a database error would. +type stubSessions struct{} + +func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) { + c, err := r.Cookie(auth.SessionCookieName) + switch { + case err != nil: + return auth.Session{}, auth.ErrNoSession + case c.Value == validSessionCookie.Value: + return testSession, nil + case c.Value == brokenSessionCookie.Value: + return auth.Session{}, errors.New("database down") + default: + return auth.Session{}, auth.ErrNoSession + } +} + +func TestProtectedPathsRequireSession(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + for _, tc := range []struct { + method, path string + cookie *http.Cookie + }{ + {http.MethodGet, "/api/v1/settings/household", nil}, + {http.MethodPatch, "/api/v1/settings/household", nil}, + {http.MethodGet, "/api/v1", nil}, + {http.MethodGet, "/media/photo.jpg", nil}, + {http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}}, + } { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(tc.method, tc.path, nil) + if tc.cookie != nil { + req.AddCookie(tc.cookie) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if got := rec.Body.String(); got != unauthenticatedJSON { + t.Errorf("body = %s, want %s", got, unauthenticatedJSON) + } + }) + } +} + +func TestPublicPathsNeedNoSession(t *testing.T) { + t.Parallel() + + router := newTestRouter(t, "dev") + for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code) + } + }) + } +} + +func TestSessionLookupFailure(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil) + req.AddCookie(brokenSessionCookie) + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON { + t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON) + } +} + +func TestRequireSessionAttachesActor(t *testing.T) { + t.Parallel() + + var got auth.Actor + var ok bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, ok = auth.ActorFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want the handler's 204", rec.Code) + } + if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" || + !slices.Equal(got.Groups, []string{"mealquest-admin"}) { + t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok) + } +} + +func TestRequireSessionStopsUnauthenticated(t *testing.T) { + t.Parallel() + + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)) + + if called || rec.Code != http.StatusUnauthorized { + t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code) + } +} diff --git a/internal/server/settings.go b/internal/server/settings.go new file mode 100644 index 0000000..c29846a --- /dev/null +++ b/internal/server/settings.go @@ -0,0 +1,135 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// maxSettingsBody bounds a PATCH /settings/household request body. +const maxSettingsBody = 16 << 10 + +// settingsPatch is the PATCH /settings/household body. Every field is +// optional; unknown fields and null values are refused. +type settingsPatch struct { + Timezone *string `json:"timezone"` + DinnerTime *string `json:"dinner_time"` + ReminderTime *string `json:"reminder_time"` + ReviewTime *string `json:"review_time"` + PlanDraftDow *int `json:"plan_draft_dow"` + PlanDraftTime *string `json:"plan_draft_time"` + SuggestionsDow *int `json:"suggestions_dow"` + SuggestionsTime *string `json:"suggestions_time"` + ChatAutosendDow *int `json:"chat_autosend_dow"` + ChatAutosendTime *string `json:"chat_autosend_time"` +} + +func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get household settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// patchHouseholdSettings records the session's actor subject as updated_by. +func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := auth.ActorFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "PATCH /api/v1/settings/household without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.Subject) + var invalid *household.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "update household settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// decodeSettingsPatch reads a single JSON object, refusing unknown fields, +// null values and trailing data. +func decodeSettingsPatch(body io.Reader) (settingsPatch, error) { + data, err := io.ReadAll(body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit) + } + return settingsPatch{}, errors.New("cannot read request body") + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + return settingsPatch{}, errors.New("request body must be a JSON object") + } + for name, raw := range fields { + if bytes.Equal(raw, []byte("null")) { + return settingsPatch{}, fmt.Errorf("%s: must not be null", name) + } + } + + var patch settingsPatch + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&patch); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type) + } + return settingsPatch{}, fmt.Errorf("invalid settings: %v", err) + } + return patch, nil +} + +func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) { + body, err := json.Marshal(v) + if err != nil { + logger.ErrorContext(r.Context(), "encode response", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write response", "error", err) + } +} + +// writeError responds with status and a non-retryable API error. +func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) { + body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}}) + if err != nil { + panic(err) // string and bool fields cannot fail to marshal + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write error response", "code", code, "error", err) + } +} diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go new file mode 100644 index 0000000..e9da996 --- /dev/null +++ b/internal/server/settings_test.go @@ -0,0 +1,278 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const settingsPath = "/api/v1/settings/household" + +// signedIn returns a request carrying stubSessions' valid session cookie and +// its CSRF token. +func signedIn(method, target string, body io.Reader) *http.Request { + req := httptest.NewRequest(method, target, body) + req.AddCookie(validSessionCookie) + req.Header.Set(CSRFHeader, testSession.CSRFToken) + return req +} + +func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body %q: %v", rec.Body.String(), err) + } + return rec, body +} + +func TestGetHouseholdSettings(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodGet, settingsPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{ + "timezone": "America/New_York", + "dinner_time": "17:30", + "reminder_time": "16:30", + "review_time": "09:00", + "plan_draft_dow": float64(5), + "plan_draft_time": "09:00", + "suggestions_dow": float64(0), + "suggestions_time": "08:00", + "chat_autosend_dow": float64(6), + "chat_autosend_time": "08:00", + "onboarding_completed_at": nil, + "updates_seen_at": nil, + "updated_by": nil, + } + for k, v := range want { + got, ok := body[k] + if !ok || got != v { + t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v) + } + } + for _, k := range []string{"created_at", "updated_at"} { + if s, ok := body[k].(string); !ok || s == "" { + t.Errorf("%s = %#v, want a timestamp", k, body[k]) + } + } + if len(body) != len(want)+2 { + t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body) + } +} + +func TestPatchHouseholdSettings(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + var notified []household.Settings + settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) + + rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, + strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + for k, v := range map[string]any{ + "timezone": "Europe/Berlin", + "dinner_time": "18:00", + "plan_draft_dow": float64(4), + "reminder_time": "16:30", // unchanged + "updated_by": testSession.Subject, + } { + if body[k] != v { + t.Errorf("%s = %#v, want %#v", k, body[k], v) + } + } + if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" { + t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified) + } + + _, body = serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if body["timezone"] != "Europe/Berlin" || body["updated_by"] != testSession.Subject { + t.Errorf("GET after PATCH = %v", body) + } +} + +func TestPatchHouseholdSettingsRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + code string + in string // the error message must contain this + }{ + {"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"}, + {"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"}, + {"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"}, + {"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"}, + {"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"}, + {"empty object", `{}`, "invalid_settings", "no settings to change"}, + {"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"}, + {"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"}, + {"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"}, + {"null value", `{"timezone":null}`, "invalid_request", "timezone"}, + {"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"}, + {"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"}, + {"not an object", `["timezone"]`, "invalid_request", "JSON object"}, + {"null body", `null`, "invalid_request", "JSON object"}, + {"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"}, + {"malformed", `{"dinner_time":`, "invalid_request", "JSON object"}, + {"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + notified := 0 + settings.Subscribe(func(household.Settings) { notified++ }) + + rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(tt.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + apiErr, ok := body["error"].(map[string]any) + if !ok || len(body) != 1 || len(apiErr) != 3 { + t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body) + } + if apiErr["code"] != tt.code || apiErr["retryable"] != false { + t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code) + } + if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) { + t.Errorf("message %q does not contain %q", msg, tt.in) + } + if notified != 0 { + t.Errorf("rejected PATCH notified subscribers %d times", notified) + } + _, after := serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil { + t.Errorf("rejected PATCH changed settings: %v", after) + } + }) + } +} + +func TestHouseholdSettingsMethodNotAllowed(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPut, settingsPath, strings.NewReader(`{}`))) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" { + t.Errorf("body = %v", body) + } +} + +// TestHouseholdSettingsWithRealSession drives the settings API through the +// production router and session manager: a member signs in, reads the CSRF +// token from GET /api/v1/me and PATCHes; updated_by comes from the session. +// Without a session, or without the CSRF token, the PATCH is refused and +// nothing changes. +func TestHouseholdSettingsWithRealSession(t *testing.T) { + t.Parallel() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { + t.Fatal(err) + } + sessions, err := auth.NewSessionManager(st, time.Hour, 24*time.Hour) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + + // Sign in as the callback would. + login := httptest.NewRecorder() + if err := sessions.StartSession(login, httptest.NewRequest(http.MethodGet, "/auth/callback", nil), auth.Claims{ + Subject: "authentik-sub-42", + Email: "cook@example.com", + Name: "Home Cook", + Groups: []string{"mealquest-admin"}, + }); err != nil { + t.Fatal(err) + } + cookies := login.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != auth.SessionCookieName { + t.Fatalf("StartSession set cookies %v, want the session cookie", cookies) + } + cookie := cookies[0] + withCookie := func(req *http.Request) *http.Request { + req.AddCookie(cookie) + return req + } + + rec, me := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))) + token, _ := me["csrfToken"].(string) + if rec.Code != http.StatusOK || token == "" { + t.Fatalf("GET /api/v1/me = %d %v, want 200 with a CSRF token", rec.Code, me) + } + + patch := `{"dinner_time":"18:45"}` + refused := []struct { + name string + req *http.Request + status int + }{ + {"no session", httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)), http.StatusUnauthorized}, + {"no CSRF token", withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))), http.StatusForbidden}, + } + for _, tt := range refused { + if rec, body := serve(t, router, tt.req); rec.Code != tt.status { + t.Errorf("%s: PATCH status = %d, want %d: %v", tt.name, rec.Code, tt.status, body) + } + } + _, body := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, settingsPath, nil))) + if body["dinner_time"] != household.SeedDinnerTime || body["updated_by"] != nil { + t.Fatalf("refused PATCH changed settings: %v", body) + } + + req := withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))) + req.Header.Set(CSRFHeader, token) + rec, body = serve(t, router, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH status = %d, want 200: %v", rec.Code, body) + } + if body["dinner_time"] != "18:45" || body["updated_by"] != "authentik-sub-42" { + t.Errorf("PATCH = %v, want dinner_time 18:45 updated_by authentik-sub-42", body) + } +} diff --git a/internal/store/id.go b/internal/store/id.go new file mode 100644 index 0000000..28bafde --- /dev/null +++ b/internal/store/id.go @@ -0,0 +1,27 @@ +package store + +import ( + "crypto/rand" + "sync" + "time" + + "github.com/oklog/ulid/v2" +) + +var ( + idMu sync.Mutex + idEntropy = ulid.Monotonic(rand.Reader, 0) + idLastMS uint64 +) + +// NewID returns a new ULID as its canonical 26-character string. IDs from +// this process sort strictly in creation order, including within one +// millisecond and across a backwards wall-clock step. It panics if the +// monotonic entropy overflows within a millisecond. +func NewID() string { + idMu.Lock() + defer idMu.Unlock() + ms := max(ulid.Timestamp(time.Now()), idLastMS) + idLastMS = ms + return ulid.MustNew(ms, idEntropy).String() +} diff --git a/internal/store/id_test.go b/internal/store/id_test.go new file mode 100644 index 0000000..14f5f7d --- /dev/null +++ b/internal/store/id_test.go @@ -0,0 +1,25 @@ +package store + +import ( + "testing" + + "github.com/oklog/ulid/v2" +) + +func TestNewIDStrictlyIncreasing(t *testing.T) { + const n = 10000 + prev := NewID() + for range n { + id := NewID() + if len(id) != ulid.EncodedSize { + t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize) + } + if _, err := ulid.ParseStrict(id); err != nil { + t.Fatalf("NewID() = %q: %v", id, err) + } + if id <= prev { + t.Fatalf("NewID() = %q, not after previous %q", id, prev) + } + prev = id + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..2ac5dec --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,105 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "strings" + "time" +) + +const createSchemaMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +)` + +// Migrate applies every *.sql file at the root of migrations that is not yet +// recorded in schema_migrations, in filename order. Each migration runs in its +// own transaction together with the insert that records it, so a failing +// migration leaves the database at the previous version. A version is the +// file name without its .sql extension. +// +// The database must not hold a version unknown to migrations: that means it +// was migrated by a newer build, and Migrate refuses to continue. +func Migrate(ctx context.Context, db *sql.DB, migrations fs.FS) error { + files, err := fs.Glob(migrations, "*.sql") + if err != nil { + return fmt.Errorf("migrate: list migrations: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("migrate: no migrations found") + } + + if _, err := db.ExecContext(ctx, createSchemaMigrations); err != nil { + return fmt.Errorf("migrate: create schema_migrations: %w", err) + } + applied, err := appliedVersions(ctx, db) + if err != nil { + return err + } + + known := make(map[string]bool, len(files)) + for _, name := range files { + known[strings.TrimSuffix(name, ".sql")] = true + } + for version := range applied { + if !known[version] { + return fmt.Errorf("migrate: database has version %q, which this build does not know", version) + } + } + + for _, name := range files { // fs.Glob returns names in lexical order + version := strings.TrimSuffix(name, ".sql") + if applied[version] { + continue + } + body, err := fs.ReadFile(migrations, name) + if err != nil { + return fmt.Errorf("migrate: read %s: %w", name, err) + } + if err := apply(ctx, db, version, string(body)); err != nil { + return fmt.Errorf("migrate: apply %s: %w", name, err) + } + } + return nil +} + +func appliedVersions(ctx context.Context, db *sql.DB) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + defer rows.Close() + applied := make(map[string]bool) + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + applied[v] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("migrate: read schema_migrations: %w", err) + } + return applied, nil +} + +func apply(ctx context.Context, db *sql.DB, version, body string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() // no-op once committed + + if _, err := tx.ExecContext(ctx, body); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, + version, time.Now().UTC().Format(time.RFC3339), + ); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 0000000..e104996 --- /dev/null +++ b/internal/store/migrate_test.go @@ -0,0 +1,311 @@ +package store + +import ( + "context" + "io/fs" + "slices" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +var testMigrations = fstest.MapFS{ + "0001_first.sql": {Data: []byte("CREATE TABLE first (id INTEGER PRIMARY KEY);")}, + "0002_second.sql": {Data: []byte("CREATE TABLE second (id INTEGER PRIMARY KEY);\nCREATE TABLE third (id INTEGER PRIMARY KEY);")}, + "README.md": {Data: []byte("not a migration")}, +} + +type migrationRow struct { + version string + appliedAt string +} + +func migrationRows(t *testing.T, s *Store) []migrationRow { + t.Helper() + rows, err := s.Reader.Query("SELECT version, applied_at FROM schema_migrations ORDER BY version") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []migrationRow + for rows.Next() { + var r migrationRow + if err := rows.Scan(&r.version, &r.appliedAt); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func versions(rows []migrationRow) string { + vs := make([]string, len(rows)) + for i, r := range rows { + vs[i] = r.version + } + return strings.Join(vs, ",") +} + +func tableExists(t *testing.T, s *Store, name string) bool { + t.Helper() + var n int + if err := s.Reader.QueryRow("SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?", name).Scan(&n); err != nil { + t.Fatal(err) + } + return n == 1 +} + +func TestMigrateFreshDatabaseReachesLatest(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + rows := migrationRows(t, s) + if got := versions(rows); got != "0001_first,0002_second" { + t.Fatalf("versions = %q", got) + } + for _, r := range rows { + if _, err := time.Parse(time.RFC3339, r.appliedAt); err != nil || !strings.HasSuffix(r.appliedAt, "Z") { + t.Errorf("applied_at %q is not UTC RFC 3339", r.appliedAt) + } + } + for _, table := range []string{"first", "second", "third"} { + if !tableExists(t, s, table) { + t.Errorf("table %s missing", table) + } + } +} + +func TestMigrateSecondRunIsNoOp(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + before := migrationRows(t, s) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatalf("second run: %v", err) + } + after := migrationRows(t, s) + if len(after) != len(before) { + t.Fatalf("rows after second run = %v, want %v", after, before) + } + for i := range before { + if after[i] != before[i] { + t.Errorf("row %d changed: %v -> %v", i, before[i], after[i]) + } + } +} + +func TestMigrateBrokenMigrationRollsBack(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + + broken := fstest.MapFS{"0003_broken.sql": {Data: []byte( + // The first statement succeeds; the second is invalid, so the + // table created by the first must be rolled back with it. + "CREATE TABLE fourth (id INTEGER PRIMARY KEY);\nCREATE TABLE oops (;", + )}} + for name, f := range testMigrations { + broken[name] = f + } + err := Migrate(ctx, s.Writer, broken) + if err == nil { + t.Fatal("broken migration returned no error") + } + if !strings.Contains(err.Error(), "0003_broken.sql") { + t.Errorf("error %q does not name the failing migration", err) + } + if got := versions(migrationRows(t, s)); got != "0001_first,0002_second" { + t.Errorf("versions after failure = %q, want previous version intact", got) + } + if tableExists(t, s, "fourth") { + t.Error("table from the failed migration was not rolled back") + } + for _, table := range []string{"first", "second", "third"} { + if !tableExists(t, s, table) { + t.Errorf("previous table %s missing after failed migration", table) + } + } +} + +func TestMigrateAppliesInFilenameOrder(t *testing.T) { + s := openTestStore(t) + // 0002 depends on 0001; MapFS iteration order is random, so this only + // passes if migrations are sorted by name. + ordered := fstest.MapFS{ + "0002_child.sql": {Data: []byte("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id)); INSERT INTO child VALUES (1);")}, + "0001_parent.sql": {Data: []byte("CREATE TABLE parent (id INTEGER PRIMARY KEY); INSERT INTO parent VALUES (1);")}, + } + if err := Migrate(context.Background(), s.Writer, ordered); err != nil { + t.Fatal(err) + } +} + +func TestMigrateUnknownAppliedVersionFails(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, testMigrations); err != nil { + t.Fatal(err) + } + older := fstest.MapFS{"0001_first.sql": testMigrations["0001_first.sql"]} + if err := Migrate(ctx, s.Writer, older); err == nil { + t.Fatal("migrating a database newer than the build returned no error") + } +} + +func TestMigrateNoMigrationsFails(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, fstest.MapFS{}); err == nil { + t.Fatal("empty migrations returned no error") + } +} + +func TestMigrateEmbeddedMigrations(t *testing.T) { + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + } +} + +func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") + if err != nil { + t.Fatal(err) + } + // A database migrated by the bootstrap-only build upgrades to the + // embedded set, which adds the later migrations alongside the unchanged + // bootstrap. + if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + } + if !tableExists(t, s, "dinners") { + t.Error("table from 0001_init was not created") + } +} + +// schemaSQL returns every schema object's definition, so a test can prove a +// later Migrate created or altered nothing. +func schemaSQL(t *testing.T, s *Store) []string { + t.Helper() + rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var def string + if err := rows.Scan(&def); err != nil { + t.Fatal(err) + } + out = append(out, def) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + first, err := Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, first.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + before, beforeSchema := migrationRows(t, first), schemaSQL(t, first) + if err := first.Close(); err != nil { + t.Fatal(err) + } + + second, err := Open(ctx, dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }) + if err := Migrate(ctx, second.Writer, migrations.FS); err != nil { + t.Fatalf("migrate after reopen: %v", err) + } + if after := migrationRows(t, second); !slices.Equal(after, before) { + t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before) + } + if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) { + t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema) + } +} + +// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in +// every 0001 status and checks 0003 carries each row over with its status +// mapped and every other column unchanged. +func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"} + for old := range legacy { + if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at) + VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil { + t.Fatalf("insert %s job: %v", old, err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + for old, want := range legacy { + var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string + if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old). + Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil { + t.Fatalf("read %s job: %v", old, err) + } + if status != want { + t.Errorf("%s job status = %q, want %q", old, status, want) + } + got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt} + if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) { + t.Errorf("%s job columns = %v, want %v", old, got, wantCols) + } + } + if tableExists(t, s, "jobs_new") { + t.Error("jobs_new left behind") + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go new file mode 100644 index 0000000..8ae01b2 --- /dev/null +++ b/internal/store/repository_test.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "fmt" + "maps" + "reflect" + "slices" + "strings" + "testing" +) + +// The tables below have no public sqlc queries yet (they arrive with the +// planning, suggestion and Supply run features), so these tests drive them +// with raw SQL to prove the schema stores, returns and updates their rows. + +const repoTime = "2026-10-03T12:00:00Z" + +type repoRow struct { + table string + cols map[string]any +} + +// repoParents are the rows the tables under test reference, in insert order. +var repoParents = map[string]repoRow{ + "dinners": {"dinners", map[string]any{ + "id": "dinner-1", "name": "Chili", "normalized_name": "chili", + "status": "regular", "origin": "regular", "is_favorite": int64(0), + "grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime, + }}, + "suggestion_batches": {"suggestion_batches", map[string]any{ + "id": "batch-1", "trigger": "manual", "status": "done", + "created_at": repoTime, "updated_at": repoTime, + }}, + "week_plans": {"week_plans", map[string]any{ + "id": "plan-1", "week_start": "2026-10-05", "status": "draft", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_lists": {"shopping_lists", map[string]any{ + "id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_groups": {"shopping_groups", map[string]any{ + "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", + "position": int64(0), "created_at": repoTime, "updated_at": repoTime, + }}, +} + +// readRepoRow reads cols of the row with id through the reader. +func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any { + t.Helper() + quoted := make([]string, len(cols)) + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + ptrs[i] = &vals[i] + } + q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table) + if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil { + t.Fatalf("read %s %s: %v", table, id, err) + } + got := make(map[string]any, len(cols)) + for i, c := range cols { + got[c] = vals[i] + } + return got +} + +func TestRepositoryInsertReadUpdate(t *testing.T) { + cases := []struct { + parents []string + row repoRow + update map[string]any + }{ + { + row: repoRow{"week_plans", map[string]any{ + "id": "plan-2", "week_start": "2026-10-12", "status": "draft", + "ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans"}, + row: repoRow{"plan_days", map[string]any{ + "id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1", + "is_new": int64(0), "calendar_event_id": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"}, + }, + { + parents: []string{"dinners", "suggestion_batches"}, + row: repoRow{"suggestions", map[string]any{ + "id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1", + "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`, + "status": "pending", "decided_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"}, + }, + { + parents: []string{"week_plans"}, + row: repoRow{"shopping_lists", map[string]any{ + "id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists"}, + row: repoRow{"shopping_groups", map[string]any{ + "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"position": int64(3)}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"}, + row: repoRow{"shopping_items", map[string]any{ + "id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2", + "needed": int64(1), "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"needed": int64(0), "qty": "3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.row.table, func(t *testing.T) { + ctx := context.Background() + s := migratedStore(t) + for _, p := range tc.parents { + parent := repoParents[p] + if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil { + t.Fatalf("seed %s: %v", p, err) + } + } + + table, id := tc.row.table, tc.row.cols["id"].(string) + cols := slices.Sorted(maps.Keys(tc.row.cols)) + + if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil { + t.Fatalf("insert: %v", err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) { + t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols) + } + + want := maps.Clone(tc.row.cols) + maps.Copy(want, tc.update) + want["updated_at"] = "2026-10-04T12:00:00Z" + setCols := slices.Sorted(maps.Keys(tc.update)) + sets := make([]string, 0, len(setCols)+1) + args := make([]any, 0, len(setCols)+2) + for _, c := range append(setCols, "updated_at") { + sets = append(sets, `"`+c+`" = ?`) + args = append(args, want[c]) + } + args = append(args, id) + q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", ")) + res, err := s.Writer.ExecContext(ctx, q, args...) + if err != nil { + t.Fatalf("update: %v", err) + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + t.Fatalf("update affected %d rows (err %v), want 1", n, err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) { + t.Fatalf("read after update:\n got %v\nwant %v", got, want) + } + }) + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go new file mode 100644 index 0000000..2b36ad1 --- /dev/null +++ b/internal/store/schema_test.go @@ -0,0 +1,556 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// schemaTables is every table in the v1 data model, in insert order: parents +// before the children that reference them. +var schemaTables = []string{ + "ai_runs", + "recipes", + "recipe_ingredients", + "recipe_steps", + "photos", + "dinners", + "suggestion_batches", + "suggestions", + "week_plans", + "plan_days", + "rotation_reviews", + "shopping_lists", + "shopping_groups", + "shopping_items", + "notification_prefs", + "push_subscriptions", + "notification_outbox", + "jobs", + "schedule_runs", + "sessions", + // Single-row settings tables; row is always id 1. + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +var singleRowTables = []string{ + "household_settings", + "ai_settings", + "recipe_search_settings", + "integrations_google", + "chat_settings", +} + +const testTime = "2026-10-03T12:00:00Z" + +// rowID is the id of the i-th test row of table. The schema stores ids as +// opaque TEXT, so deterministic ids keep the fixtures readable. +func rowID(table string, i int) string { return fmt.Sprintf("%s-%d", table, i) } + +// testRow returns a valid row for table. Rows with different i differ in +// every unique column; references point at row 0 of the parent table. +func testRow(table string, i int) map[string]any { + ts := map[string]any{"created_at": testTime, "updated_at": testTime} + r := map[string]any{"id": rowID(table, i)} + switch table { + case "household_settings": + r = map[string]any{"id": 1, "timezone": "America/New_York"} + case "ai_runs": + r["skill"] = "suggest" + r["skill_version"] = "1" + r["provider"] = "anthropic" + r["model"] = "claude" + r["temperature"] = 0.7 + r["status"] = "ok" + r["request_redacted"] = "{}" + case "recipes": + r["source_url"] = fmt.Sprintf("https://example.com/recipe/%d", i) + r["title"] = "Chili" + r["raw_jsonld"] = "{}" + r["fetched_at"] = testTime + case "recipe_ingredients": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["raw_text"] = "1 onion" + case "recipe_steps": + r["recipe_id"] = rowID("recipes", 0) + r["position"] = i + r["text"] = "Chop the onion." + case "photos": + r["source_url"] = "https://example.com/chili.jpg" + r["local_path"] = "photos/chili.jpg" + r["width"] = 800 + r["height"] = 600 + r["sha256"] = "abc" + r["fetched_at"] = testTime + case "dinners": + r["name"] = "Chili" + r["normalized_name"] = "chili" + r["status"] = "regular" + r["origin"] = "regular" + r["method"] = "crockpot" + r["difficulty"] = "easy" + r["is_favorite"] = 0 + r["recipe_id"] = rowID("recipes", 0) + r["photo_id"] = rowID("photos", 0) + r["grounding_status"] = "grounded" + case "suggestion_batches": + r["trigger"] = "scheduled" + r["status"] = "done" + r["ai_run_id"] = rowID("ai_runs", 0) + case "suggestions": + r["dinner_id"] = rowID("dinners", 0) + r["batch_id"] = rowID("suggestion_batches", 0) + r["reason"] = "Like your chili." + r["cited_regular_ids"] = "[]" + r["status"] = "pending" + case "week_plans": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["status"] = "draft" + r["ai_run_id"] = rowID("ai_runs", 0) + case "plan_days": + r["plan_id"] = rowID("week_plans", 0) + r["date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["dinner_id"] = rowID("dinners", 0) + r["is_new"] = 0 + case "rotation_reviews": + r["dinner_id"] = rowID("dinners", 0) + r["plan_date"] = fmt.Sprintf("2026-01-%02d", i+5) + r["status"] = "decided" + r["decision"] = "keep" + case "shopping_lists": + r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1) + r["plan_id"] = rowID("week_plans", 0) + r["title"] = "Supply run" + r["status"] = "current" + case "shopping_groups": + r["list_id"] = rowID("shopping_lists", 0) + r["dinner_id"] = rowID("dinners", 0) + r["position"] = i + case "shopping_items": + r["group_id"] = rowID("shopping_groups", 0) + r["name"] = "Onion" + r["needed"] = 1 + r["position"] = i + case "ai_settings": + r = map[string]any{"id": 1, "provider": "ollama", "base_url": "http://ollama:11434", "model": "llama", "temperature": 0.7} + case "recipe_search_settings": + r = map[string]any{"id": 1, "provider": "searxng", "base_url": "http://searxng:8080"} + case "integrations_google": + r = map[string]any{"id": 1, "refresh_token_ct": []byte{1}, "connected_at": testTime} + case "chat_settings": + r = map[string]any{"id": 1, "webhook_url_ct": []byte{1}, "last_test_status": "ok"} + case "notification_prefs": + events := []string{"plan_ready", "list_ready", "day_of_reminder", "new_suggestions", "rotation_review"} + r = map[string]any{"event": events[i], "push": 1, "chat": 0} + case "push_subscriptions": + r["endpoint"] = fmt.Sprintf("https://push.example.com/%d", i) + r["p256dh"] = "key" + r["auth"] = "auth" + r["failure_count"] = 0 + case "notification_outbox": + r["event_key"] = fmt.Sprintf("plan_ready:2026-W%02d", i+1) + r["channel"] = "push" + r["payload"] = "{}" + r["status"] = "pending" + r["attempts"] = 0 + case "jobs": + r["kind"] = "suggest" + r["status"] = "queued" + r["payload"] = "{}" + case "schedule_runs": + r["schedule"] = "plan_draft" + r["period_key"] = fmt.Sprintf("2026-W%02d", i+1) + r["created_at"] = testTime + return r + case "sessions": + return map[string]any{ + "id_hash": fmt.Sprintf("hash-%d", i), + "subject": "sub", + "email": "kitchen@example.com", + "name": "Our kitchen", + "groups": `["mealquest-admin"]`, + "csrf_token": "csrf", + "created_at": testTime, + "last_seen_at": testTime, + "expires_at": testTime, + } + default: + panic("testRow: unknown table " + table) + } + for k, v := range ts { + r[k] = v + } + return r +} + +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func insertSchemaRow(db execer, table string, row map[string]any) error { + cols := make([]string, 0, len(row)) + for c := range row { + cols = append(cols, c) + } + slices.Sort(cols) + quoted := make([]string, len(cols)) + args := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + args[i] = row[c] + } + q := fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s)`, + table, strings.Join(quoted, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", ")) + _, err := db.ExecContext(context.Background(), q, args...) + return err +} + +// migratedStore opens a temp store migrated with the embedded migrations. +func migratedStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return s +} + +// seededStore is a migrated store holding row 0 of every multi-row table. +// Single-row tables are left empty so tests can insert their only row. +func seededStore(t *testing.T) *Store { + t.Helper() + s := migratedStore(t) + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("seed %s: %v", table, err) + } + } + return s +} + +// inRolledBackTx runs fn in a writer transaction that is always rolled back, +// so each case starts from the seeded state. +func inRolledBackTx(t *testing.T, s *Store, fn func(tx *sql.Tx)) { + t.Helper() + tx, err := s.Writer.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + }() + fn(tx) +} + +func wantConstraintError(t *testing.T, err error, kind, what string) { + t.Helper() + if err == nil { + t.Fatalf("%s: accepted, want %s constraint failure", what, kind) + } + if !strings.Contains(err.Error(), kind+" constraint failed") { + t.Fatalf("%s: error %q, want %s constraint failure", what, err, kind) + } +} + +func TestSchemaTablesExist(t *testing.T) { + s := migratedStore(t) + if len(schemaTables) != 25 { + t.Fatalf("schemaTables lists %d tables, want 25", len(schemaTables)) + } + for _, table := range schemaTables { + if !tableExists(t, s, table) { + t.Errorf("table %s missing", table) + } + } +} + +func TestSchemaForeignKeysAreValid(t *testing.T) { + s := seededStore(t) + rows, err := s.Writer.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if rows.Next() { + t.Error("foreign_key_check reported violations in the seeded data") + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } +} + +func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { + cases := []struct { + table, column string + bad any + }{ + {"household_settings", "plan_draft_dow", 7}, + {"household_settings", "suggestions_dow", -1}, + {"household_settings", "chat_autosend_dow", 7}, + {"household_settings", "plan_draft_dow", 5.5}, + {"household_settings", "suggestions_dow", 0.5}, + {"household_settings", "chat_autosend_dow", 3.5}, + {"ai_runs", "provider", "openai"}, + {"ai_runs", "status", "done"}, + {"dinners", "status", "deck"}, + {"dinners", "origin", "ai"}, + {"dinners", "method", "microwave"}, + {"dinners", "difficulty", "extreme"}, + {"dinners", "is_favorite", 2}, + {"dinners", "grounding_status", "unknown"}, + {"suggestion_batches", "trigger", "cron"}, + {"suggestion_batches", "status", "ok"}, + {"suggestions", "status", "maybe"}, + {"week_plans", "status", "final"}, + {"plan_days", "is_new", 2}, + {"rotation_reviews", "status", "open"}, + {"rotation_reviews", "decision", "promote"}, + {"shopping_lists", "status", "frozen"}, + {"shopping_items", "needed", 2}, + {"ai_settings", "provider", "openai"}, + {"recipe_search_settings", "provider", "google"}, + {"chat_settings", "last_test_status", "pending"}, + {"notification_prefs", "event", "weekly_digest"}, + {"notification_prefs", "push", 2}, + {"notification_prefs", "chat", 2}, + {"notification_outbox", "channel", "email"}, + {"notification_outbox", "status", "queued"}, + {"jobs", "status", "bogus"}, + {"schedule_runs", "schedule", "hourly"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(fmt.Sprintf("%s.%s=%v", c.table, c.column, c.bad), func(t *testing.T) { + row := testRow(c.table, 1) + if slices.Contains(singleRowTables, c.table) { + row = testRow(c.table, 0) + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("valid row rejected: %v", err) + } + }) + row[c.column] = c.bad + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "CHECK", fmt.Sprintf("%s.%s = %v", c.table, c.column, c.bad)) + }) + }) + } +} + +func TestSchemaRotationReviewEventAllowed(t *testing.T) { + s := migratedStore(t) + if err := insertSchemaRow(s.Writer, "notification_prefs", testRow("notification_prefs", 4)); err != nil { + t.Fatalf("rotation_review event rejected: %v", err) + } +} + +func TestSchemaDinnerStatuses(t *testing.T) { + s := seededStore(t) + for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} { + inRolledBackTx(t, s, func(tx *sql.Tx) { + row := testRow("dinners", 1) + row["status"] = status + if err := insertSchemaRow(tx, "dinners", row); err != nil { + t.Errorf("status %s rejected: %v", status, err) + } + }) + } +} + +func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) { + cases := []struct { + table string + columns []string + }{ + {"recipes", []string{"source_url"}}, + {"week_plans", []string{"week_start"}}, + {"plan_days", []string{"plan_id", "date"}}, + {"rotation_reviews", []string{"dinner_id", "plan_date"}}, + {"shopping_lists", []string{"week_start"}}, + {"push_subscriptions", []string{"endpoint"}}, + {"notification_outbox", []string{"event_key", "channel"}}, + {"schedule_runs", []string{"schedule", "period_key"}}, + {"notification_prefs", []string{"event"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+"("+strings.Join(c.columns, ",")+")", func(t *testing.T) { + row := testRow(c.table, 1) + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("distinct row rejected: %v", err) + } + }) + seeded := testRow(c.table, 0) + for _, col := range c.columns { + row[col] = seeded[col] + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + wantConstraintError(t, insertSchemaRow(tx, c.table, row), "UNIQUE", "duplicate "+c.table) + }) + }) + } +} + +// TestSchemaCompositeUniqueAllowsPartialOverlap shows each composite UNIQUE +// spans all its columns: a row sharing only some of them with the seeded row +// is accepted. (Sharing only the other columns is covered by the distinct row +// in TestSchemaUniqueConstraintsRejectDuplicates.) +func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) { + cases := []struct { + name string + table string + shared string // column copied from the seeded row + parent string // row 1 of this table is inserted first, if set + set map[string]any // columns that differ from the seeded row + }{ + {"same date, different plan", "plan_days", "date", "week_plans", map[string]any{"plan_id": rowID("week_plans", 1)}}, + {"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}}, + {"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}}, + {"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.table+": "+c.name, func(t *testing.T) { + row := testRow(c.table, 1) + row[c.shared] = testRow(c.table, 0)[c.shared] + for k, v := range c.set { + row[k] = v + } + inRolledBackTx(t, s, func(tx *sql.Tx) { + if c.parent != "" { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + } + if err := insertSchemaRow(tx, c.table, row); err != nil { + t.Fatalf("row rejected: %v", err) + } + }) + }) + } +} + +// TestSchemaTextPrimaryKeysRejectNull guards against SQLite's legacy +// acceptance of NULL in non-INTEGER primary keys. +func TestSchemaTextPrimaryKeysRejectNull(t *testing.T) { + s := seededStore(t) + ctx := context.Background() + for _, table := range schemaTables { + if slices.Contains(singleRowTables, table) { + continue + } + key := "id" + switch table { + case "notification_prefs": + key = "event" + case "sessions": + key = "id_hash" + } + t.Run(table+"."+key, func(t *testing.T) { + inRolledBackTx(t, s, func(tx *sql.Tx) { + // Two NULL-key rows: neither may be accepted, so NULL can + // never stand in for a key, once or repeatedly. + for i := 1; i <= 2; i++ { + row := testRow(table, i) + row[key] = nil + wantConstraintError(t, insertSchemaRow(tx, table, row), "NOT NULL", fmt.Sprintf("%s.%s = NULL (row %d)", table, key, i)) + } + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE "%s" IS NULL`, table, key)).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%d rows with NULL %s", n, key) + } + }) + }) + } +} + +func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) { + s := migratedStore(t) + for _, table := range singleRowTables { + if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil { + t.Fatalf("%s: %v", table, err) + } + row := testRow(table, 0) + row["id"] = 2 + wantConstraintError(t, insertSchemaRow(s.Writer, table, row), "CHECK", table+" second row") + } +} + +func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) { + cases := []struct{ parent, child, column string }{ + {"recipes", "recipe_ingredients", "recipe_id"}, + {"recipes", "recipe_steps", "recipe_id"}, + {"recipes", "dinners", "recipe_id"}, + {"photos", "dinners", "photo_id"}, + {"dinners", "suggestions", "dinner_id"}, + {"suggestion_batches", "suggestions", "batch_id"}, + {"week_plans", "plan_days", "plan_id"}, + {"dinners", "plan_days", "dinner_id"}, + {"dinners", "rotation_reviews", "dinner_id"}, + {"week_plans", "shopping_lists", "plan_id"}, + {"shopping_lists", "shopping_groups", "list_id"}, + {"dinners", "shopping_groups", "dinner_id"}, + {"shopping_groups", "shopping_items", "group_id"}, + // ai_runs too: the purge must clear references before deleting. + {"ai_runs", "suggestion_batches", "ai_run_id"}, + {"ai_runs", "week_plans", "ai_run_id"}, + } + s := seededStore(t) + for _, c := range cases { + t.Run(c.parent+"<-"+c.child+"."+c.column, func(t *testing.T) { + // A fresh parent with exactly one child, through this column only. + parentID := rowID(c.parent, 1) + child := testRow(c.child, 1) + child[c.column] = parentID + del := fmt.Sprintf(`DELETE FROM "%s" WHERE id = ?`, c.parent) + ctx := context.Background() + + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if err := insertSchemaRow(tx, c.child, child); err != nil { + t.Fatalf("insert child: %v", err) + } + _, err := tx.ExecContext(ctx, del, parentID) + wantConstraintError(t, err, "FOREIGN KEY", "delete "+c.parent) + var n int + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE id = ?`, c.parent), parentID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("parent row gone after refused delete") + } + }) + // Control: without the child the same delete succeeds. + inRolledBackTx(t, s, func(tx *sql.Tx) { + if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil { + t.Fatalf("insert parent: %v", err) + } + if _, err := tx.ExecContext(ctx, del, parentID); err != nil { + t.Fatalf("delete childless parent: %v", err) + } + }) + }) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e3748d2 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,79 @@ +// Package store owns the MealQuest SQLite database: one writer connection, +// a read pool, and the embedded schema migrations. +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "path/filepath" + + _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver +) + +// FileName is the database file inside the data directory. +const FileName = "mealquest.db" + +// readPoolSize is the maximum number of open read connections. +const readPoolSize = 4 + +// connPragmas are applied by the driver to every new connection. +var connPragmas = []string{ + "busy_timeout(5000)", + "journal_mode(WAL)", + "foreign_keys(1)", +} + +// Store holds the database handles. All writes go through Writer, which has +// exactly one connection; Reader is a pool of read-only connections. +type Store struct { + Writer *sql.DB + Reader *sql.DB +} + +// Open opens /mealquest.db, creating it if absent, and verifies both +// handles can connect. It does not apply migrations; call Migrate for that. +func Open(ctx context.Context, dataDir string) (*Store, error) { + path, err := filepath.Abs(filepath.Join(dataDir, FileName)) + if err != nil { + return nil, fmt.Errorf("store: resolve database path: %w", err) + } + + // The writer is opened and pinged first so the file exists and is in WAL + // mode before any read-only connection touches it. + writer, err := openDB(ctx, path, 1, url.Values{"_txlock": {"immediate"}}) + if err != nil { + return nil, fmt.Errorf("store: open writer: %w", err) + } + reader, err := openDB(ctx, path, readPoolSize, url.Values{"_pragma": {"query_only(1)"}}) + if err != nil { + return nil, errors.Join(fmt.Errorf("store: open reader: %w", err), writer.Close()) + } + return &Store{Writer: writer, Reader: reader}, nil +} + +// Close closes both handles. +func (s *Store) Close() error { + return errors.Join(s.Reader.Close(), s.Writer.Close()) +} + +func openDB(ctx context.Context, path string, maxConns int, extra url.Values) (*sql.DB, error) { + q := url.Values{"_pragma": connPragmas} + for k, vs := range extra { + q[k] = append(q[k], vs...) + } + dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: q.Encode()}).String() + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + if err := db.PingContext(ctx); err != nil { + return nil, errors.Join(err, db.Close()) + } + return db, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1125ce0 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + s, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Error(err) + } + }) + return s +} + +func TestOpenCreatesDatabaseFile(t *testing.T) { + dir := t.TempDir() + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err := os.Stat(filepath.Join(dir, FileName)); err != nil { + t.Fatalf("database file: %v", err) + } +} + +func TestOpenMissingDirFails(t *testing.T) { + _, err := Open(context.Background(), filepath.Join(t.TempDir(), "missing")) + if err == nil { + t.Fatal("Open succeeded on a missing data directory") + } +} + +func TestPoolSizes(t *testing.T) { + s := openTestStore(t) + if got := s.Writer.Stats().MaxOpenConnections; got != 1 { + t.Errorf("writer max open connections = %d, want 1", got) + } + if got := s.Reader.Stats().MaxOpenConnections; got != readPoolSize { + t.Errorf("reader max open connections = %d, want %d", got, readPoolSize) + } +} + +// TestPragmasOnEveryConnection holds every reader connection open at once so +// each one is a distinct connection, then checks the pragmas on each and on +// the writer. +func TestPragmasOnEveryConnection(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + + conns := []*sql.Conn{} + for range readPoolSize { + c, err := s.Reader.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer c.Close() + conns = append(conns, c) + } + w, err := s.Writer.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer w.Close() + conns = append(conns, w) + + for i, c := range conns { + var fk, busy int + var journal string + if err := c.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil { + t.Fatal(err) + } + if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil { + t.Fatal(err) + } + if err := c.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journal); err != nil { + t.Fatal(err) + } + if fk != 1 || busy != 5000 || journal != "wal" { + t.Errorf("connection %d: foreign_keys=%d busy_timeout=%d journal_mode=%q, want 1, 5000, wal", i, fk, busy, journal) + } + } +} + +func TestReaderIsReadOnly(t *testing.T) { + s := openTestStore(t) + if _, err := s.Reader.Exec("CREATE TABLE t (id INTEGER)"); err == nil { + t.Fatal("write through the reader succeeded") + } + if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER)"); err != nil { + t.Fatalf("write through the writer: %v", err) + } +} + +func TestForeignKeysEnforced(t *testing.T) { + s := openTestStore(t) + if _, err := s.Writer.Exec(` + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (parent_id INTEGER NOT NULL REFERENCES parent(id)); + `); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.Exec("INSERT INTO child (parent_id) VALUES (42)"); err == nil { + t.Fatal("insert violating a foreign key succeeded") + } +} diff --git a/internal/store/time.go b/internal/store/time.go new file mode 100644 index 0000000..068c10f --- /dev/null +++ b/internal/store/time.go @@ -0,0 +1,134 @@ +package store + +import ( + "database/sql/driver" + "errors" + "fmt" + "time" +) + +// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so +// stored timestamps sort lexicographically in time order. +const timestampLayout = "2006-01-02T15:04:05.000Z" + +// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond +// precision. Sub-millisecond precision is dropped on write. Use +// sql.Null[Timestamp] for a nullable column. +type Timestamp struct { + time.Time +} + +// Now returns the current instant as a Timestamp, in UTC at millisecond +// precision, so it equals its stored form. +func Now() Timestamp { + return Timestamp{time.Now().UTC().Truncate(time.Millisecond)} +} + +// String returns the stored TEXT form. +func (t Timestamp) String() string { + return t.UTC().Format(timestampLayout) +} + +// Value implements driver.Valuer. The zero Timestamp is an error, as is a UTC +// year outside 0000–9999, which the four-digit stored form cannot represent. +func (t Timestamp) Value() (driver.Value, error) { + if t.IsZero() { + return nil, errors.New("store: zero Timestamp") + } + if y := t.UTC().Year(); y < 0 || y > 9999 { + return nil, fmt.Errorf("store: Timestamp year %d outside 0000-9999", y) + } + return t.String(), nil +} + +// Scan implements sql.Scanner. It accepts only TEXT in the stored form, and +// rejects the zero instant, which Value refuses to write. +func (t *Timestamp) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src) + } + parsed, err := time.Parse(timestampLayout, s) + if err != nil { + return fmt.Errorf("store: scan Timestamp %q: %w", s, err) + } + if parsed.Format(timestampLayout) != s { + return fmt.Errorf("store: scan Timestamp %q: want %s", s, timestampLayout) + } + if parsed.IsZero() { + return fmt.Errorf("store: scan Timestamp %q: zero Timestamp", s) + } + t.Time = parsed + return nil +} + +// localDateLayout is the household-local calendar date form. +const localDateLayout = "2006-01-02" + +// LocalDate is a household-local calendar date with no time or zone, stored +// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored. +type LocalDate struct { + year int + month time.Month + day int +} + +// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any +// date that does not exist on the calendar. +func ParseLocalDate(s string) (LocalDate, error) { + t, err := time.Parse(localDateLayout, s) + if err != nil { + return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err) + } + if t.Format(localDateLayout) != s { + return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s) + } + return LocalDate{t.Year(), t.Month(), t.Day()}, nil +} + +// LocalDateOf returns the calendar date of t in t's own location. +func LocalDateOf(t time.Time) LocalDate { + y, m, d := t.Date() + return LocalDate{y, m, d} +} + +// Date returns the year, month and day of d. +func (d LocalDate) Date() (year int, month time.Month, day int) { + return d.year, d.month, d.day +} + +// String returns the YYYY-MM-DD form. +func (d LocalDate) String() string { + return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) +} + +// IsZero reports whether d is the zero LocalDate. +func (d LocalDate) IsZero() bool { + return d == LocalDate{} +} + +// Value implements driver.Valuer. The zero LocalDate is an error, as is a +// year outside 0000–9999, which the four-digit stored form cannot represent. +func (d LocalDate) Value() (driver.Value, error) { + if d.IsZero() { + return nil, errors.New("store: zero LocalDate") + } + if d.year < 0 || d.year > 9999 { + return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year) + } + return d.String(), nil +} + +// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT. +func (d *LocalDate) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src) + } + parsed, err := ParseLocalDate(s) + if err != nil { + return fmt.Errorf("store: scan LocalDate: %w", err) + } + *d = parsed + return nil +} diff --git a/internal/store/time_test.go b/internal/store/time_test.go new file mode 100644 index 0000000..10c0791 --- /dev/null +++ b/internal/store/time_test.go @@ -0,0 +1,151 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func openTypesTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil { + t.Fatal(err) + } + return s +} + +// roundTrip writes in through the writer, then reads it back into out and +// returns the stored SQLite type and raw text. +func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) { + t.Helper() + ctx := context.Background() + if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil { + t.Fatal(err) + } + if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil { + t.Fatal(err) + } + return typ, raw +} + +func TestTimestampRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + zone := time.FixedZone("EDT", -4*60*60) + in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)} + + var out Timestamp + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != "2026-10-03T12:37:05.123Z" { + t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z") + } + if want := in.Truncate(time.Millisecond); !out.Equal(want) { + t.Fatalf("read back %v, want %v", out, want) + } + if out.Location() != time.UTC { + t.Fatalf("read back location %v, want UTC", out.Location()) + } + + now := Now() + roundTrip(t, s, now, &out) + if out != now { + t.Fatalf("Now() round trip = %#v, want %#v", out, now) + } +} + +func TestTimestampRejects(t *testing.T) { + if _, err := (Timestamp{}).Value(); err == nil { + t.Error("zero Timestamp Value succeeded") + } + for _, tm := range []time.Time{ + time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(-1, 12, 31, 0, 0, 0, 0, time.UTC), + time.Date(9999, 12, 31, 23, 0, 0, 0, time.FixedZone("west", -2*3600)), + } { + if v, err := (Timestamp{tm}).Value(); err == nil { + t.Errorf("Timestamp{%v}.Value() = %v, want error", tm, v) + } + } + for _, src := range []any{ + nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z", + "2026-10-03T08:37:05.123-04:00", "2026-10-03T12:37:05,123Z", "garbage", + "0001-01-01T00:00:00.000Z", + } { + var ts Timestamp + if err := ts.Scan(src); err == nil { + t.Errorf("Scan(%#v) succeeded", src) + } + } +} + +func TestLocalDateRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} { + in, err := ParseLocalDate(str) + if err != nil { + t.Fatalf("ParseLocalDate(%q): %v", str, err) + } + if in.String() != str { + t.Fatalf("String() = %q, want %q", in.String(), str) + } + var out LocalDate + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != str { + t.Fatalf("stored %s %q, want text %q", typ, raw, str) + } + if out != in { + t.Fatalf("read back %v, want %v", out, in) + } + } +} + +func TestLocalDateOf(t *testing.T) { + auckland, err := time.LoadLocation("Pacific/Auckland") + if err != nil { + t.Fatal(err) + } + // 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13). + instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC) + if got := LocalDateOf(instant).String(); got != "2026-10-03" { + t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got) + } + d := LocalDateOf(instant.In(auckland)) + if got := d.String(); got != "2026-10-04" { + t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got) + } + if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 { + t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day) + } +} + +func TestLocalDateRejectsInvalid(t *testing.T) { + for _, str := range []string{ + "", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00", + "2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03", + "2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date", + } { + if d, err := ParseLocalDate(str); err == nil { + t.Errorf("ParseLocalDate(%q) = %v, want error", str, d) + } + var d LocalDate + if err := d.Scan(str); err == nil { + t.Errorf("Scan(%q) succeeded", str) + } + } + if _, err := (LocalDate{}).Value(); err == nil { + t.Error("zero LocalDate Value succeeded") + } + for _, y := range []int{-1, 10000} { + d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC)) + if _, err := d.Value(); err == nil { + t.Errorf("LocalDate year %d Value succeeded", y) + } + } + var d LocalDate + if err := d.Scan(nil); err == nil { + t.Error("Scan(nil) succeeded") + } +} diff --git a/internal/store/tx.go b/internal/store/tx.go new file mode 100644 index 0000000..bb53661 --- /dev/null +++ b/internal/store/tx.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// WithTx runs fn in a transaction on the writer. It commits when fn returns +// nil and rolls back when fn returns an error, panics or exits the goroutine. +// A panic is not recovered: it propagates unchanged after the rollback. +func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.Writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: begin transaction: %w", err) + } + finished := false + defer func() { + if !finished { + _ = tx.Rollback() // fn panicked; the panic carries on regardless + } + }() + + if err := fn(tx); err != nil { + finished = true + if rbErr := tx.Rollback(); rbErr != nil { + return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr)) + } + return err + } + finished = true + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: commit transaction: %w", err) + } + return nil +} diff --git a/internal/store/tx_test.go b/internal/store/tx_test.go new file mode 100644 index 0000000..a8c196e --- /dev/null +++ b/internal/store/tx_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +func openTxTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil { + t.Fatal(err) + } + return s +} + +func countRows(t *testing.T, s *Store) int { + t.Helper() + var n int + if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func insertRow(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO t (id) VALUES (1)") + return err +} + +func TestWithTxCommitsOnNil(t *testing.T) { + s := openTxTestStore(t) + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatal(err) + } + if n := countRows(t, s); n != 1 { + t.Fatalf("rows after commit = %d, want 1", n) + } +} + +func TestWithTxRollsBackOnError(t *testing.T) { + s := openTxTestStore(t) + errBoom := errors.New("boom") + err := s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + return errBoom + }) + if !errors.Is(err, errBoom) { + t.Fatalf("WithTx error = %v, want %v", err, errBoom) + } + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after rollback = %d, want 0", n) + } +} + +func TestWithTxRollsBackOnPanic(t *testing.T) { + s := openTxTestStore(t) + const value = "kaboom" + func() { + defer func() { + if p := recover(); p != value { + t.Fatalf("recovered %v, want panic %q to propagate", p, value) + } + }() + _ = s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + panic(value) + }) + t.Fatal("WithTx returned instead of panicking") + }() + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after panic = %d, want 0", n) + } + // The single writer connection must have been released. + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatalf("WithTx after panic: %v", err) + } +} diff --git a/migrations/0000_bootstrap.sql b/migrations/0000_bootstrap.sql new file mode 100644 index 0000000..a21b38f --- /dev/null +++ b/migrations/0000_bootstrap.sql @@ -0,0 +1,4 @@ +-- 0000_bootstrap: immutable no-op bootstrap migration. It lets the migrations +-- embed compile and proves the runner end to end. Never edit or remove it: +-- applied versions are recorded by filename and never re-run. The initial v1 +-- schema arrives as a new migration, 0001_init.sql (Feature 02 task 3). diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..80bfe04 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,333 @@ +-- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model). +-- +-- Conventions: +-- * Row ids are ULIDs stored as TEXT NOT NULL PRIMARY KEY (SQLite would +-- otherwise accept NULL in a TEXT primary key). Single-row settings tables +-- use id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused. +-- * Timestamps are UTC RFC 3339 TEXT. Household-local dates (week_start, +-- plan day) are YYYY-MM-DD TEXT; household-local times are HH:MM TEXT. +-- * Columns ending in _ct only ever hold secretbox ciphertext. +-- * Every enum column has a CHECK. Booleans are INTEGER CHECK (x IN (0, 1)). +-- Days of the week are INTEGER IN (0, 1, 2, 3, 4, 5, 6), Sunday = 0 (Go +-- time.Weekday); IN rather than BETWEEN so fractional values are refused. +-- * Foreign keys use the default NO ACTION, so deleting a parent that still +-- has children is refused. This includes ai_run_id: the 30-day ai_runs +-- purge must clear those references explicitly before deleting. +-- * Defaults exist only where CTXD names one (household_settings times and +-- shopping_items.needed). +-- +-- Enum value sets not spelled out in CONTEXT.MD, chosen here: +-- ai_settings.provider, ai_runs.provider: openai_compatible, anthropic, ollama +-- ai_runs.status: running, ok, error +-- chat_settings.last_test_status: ok, failed +-- jobs.status, suggestion_batches.status: pending, running, done, failed +-- notification_outbox.status: pending, sent, failed +-- schedule_runs.schedule: suggestions_weekly, plan_draft, +-- shopping_chat_autosend, day_of_reminder, rotation_check, week_rollover, +-- ai_runs_purge +-- jobs.kind and ai_runs.skill are open identifiers that grow with features, +-- not enums, so they carry no CHECK. + +CREATE TABLE household_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + timezone TEXT NOT NULL, + dinner_time TEXT NOT NULL DEFAULT '17:30', + reminder_time TEXT NOT NULL DEFAULT '16:30', + review_time TEXT NOT NULL DEFAULT '09:00', + plan_draft_dow INTEGER NOT NULL DEFAULT 5 CHECK (plan_draft_dow IN (0, 1, 2, 3, 4, 5, 6)), + plan_draft_time TEXT NOT NULL DEFAULT '09:00', + suggestions_dow INTEGER NOT NULL DEFAULT 0 CHECK (suggestions_dow IN (0, 1, 2, 3, 4, 5, 6)), + suggestions_time TEXT NOT NULL DEFAULT '08:00', + chat_autosend_dow INTEGER NOT NULL DEFAULT 6 CHECK (chat_autosend_dow IN (0, 1, 2, 3, 4, 5, 6)), + chat_autosend_time TEXT NOT NULL DEFAULT '08:00', + onboarding_completed_at TEXT, + updates_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_runs ( + id TEXT NOT NULL PRIMARY KEY, + skill TEXT NOT NULL, + skill_version TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + model TEXT NOT NULL, + temperature REAL NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')), + latency_ms INTEGER, + tokens_in INTEGER, + tokens_out INTEGER, + error TEXT, + request_redacted TEXT NOT NULL, + response_redacted TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipes ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL UNIQUE, + site_name TEXT, + title TEXT NOT NULL, + total_minutes INTEGER, + prep_minutes INTEGER, + cook_minutes INTEGER, + yield TEXT, + raw_jsonld TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_ingredients ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + raw_text TEXT NOT NULL, + name TEXT, + qty TEXT, + unit TEXT, + note TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_steps ( + id TEXT NOT NULL PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes (id), + position INTEGER NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE photos ( + id TEXT NOT NULL PRIMARY KEY, + source_url TEXT NOT NULL, + local_path TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + sha256 TEXT NOT NULL, + fetched_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE dinners ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('suggested', 'quest_log', 'tried', 'regular', 'archived')), + origin TEXT NOT NULL CHECK (origin IN ('regular', 'side_quest', 'ingredients', 'quick', 'manual')), + method TEXT CHECK (method IN ('stovetop', 'oven', 'crockpot', 'grill', 'other')), + minutes_total INTEGER, + difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')), + cuisine TEXT, + is_favorite INTEGER NOT NULL CHECK (is_favorite IN (0, 1)), + recipe_id TEXT REFERENCES recipes (id), + photo_id TEXT REFERENCES photos (id), + grounding_status TEXT NOT NULL CHECK (grounding_status IN ('pending', 'grounded', 'failed', 'manual')), + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestion_batches ( + id TEXT NOT NULL PRIMARY KEY, + trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual', 'replenish')), + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + ai_run_id TEXT REFERENCES ai_runs (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + batch_id TEXT NOT NULL REFERENCES suggestion_batches (id), + reason TEXT NOT NULL, + cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')), + decided_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE week_plans ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('draft', 'synced', 'changed')), + ai_note TEXT, + ai_run_id TEXT REFERENCES ai_runs (id), + calendar_synced_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE plan_days ( + id TEXT NOT NULL PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + date TEXT NOT NULL, + dinner_id TEXT REFERENCES dinners (id), + is_new INTEGER NOT NULL CHECK (is_new IN (0, 1)), + calendar_event_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (plan_id, date) +); + +CREATE TABLE rotation_reviews ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + plan_date TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'decided')), + decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')), + decided_at TEXT, + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (dinner_id, plan_date) +); + +CREATE TABLE shopping_lists ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('current', 'archived')), + sent_to_chat_at TEXT, + frozen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_groups ( + id TEXT NOT NULL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES shopping_lists (id), + dinner_id TEXT NOT NULL REFERENCES dinners (id), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_items ( + id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES shopping_groups (id), + name TEXT NOT NULL, + qty TEXT, + needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')), + base_url TEXT NOT NULL, + model TEXT NOT NULL, + temperature REAL NOT NULL, + system_prompt TEXT, + api_key_ct BLOB, + api_key_last4 TEXT, + key_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE recipe_search_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('searxng', 'brave')), + base_url TEXT, + api_key_ct BLOB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE integrations_google ( + id INTEGER PRIMARY KEY CHECK (id = 1), + refresh_token_ct BLOB NOT NULL, + calendar_id TEXT, + calendar_name TEXT, + connected_at TEXT NOT NULL, + last_sync_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE chat_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + webhook_url_ct BLOB NOT NULL, + space_label TEXT, + last_test_at TEXT, + last_test_status TEXT CHECK (last_test_status IN ('ok', 'failed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_prefs ( + event TEXT NOT NULL PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')), + push INTEGER NOT NULL CHECK (push IN (0, 1)), + chat INTEGER NOT NULL CHECK (chat IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE push_subscriptions ( + id TEXT NOT NULL PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + failure_count INTEGER NOT NULL, + last_success_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE notification_outbox ( + id TEXT NOT NULL PRIMARY KEY, + event_key TEXT NOT NULL, + channel TEXT NOT NULL CHECK (channel IN ('push', 'chat')), + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')), + attempts INTEGER NOT NULL, + next_attempt_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (event_key, channel) +); + +CREATE TABLE jobs ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + payload TEXT NOT NULL, -- JSON + result TEXT, -- JSON, written progressively + error TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE schedule_runs ( + id TEXT NOT NULL PRIMARY KEY, + schedule TEXT NOT NULL CHECK (schedule IN ('suggestions_weekly', 'plan_draft', 'shopping_chat_autosend', 'day_of_reminder', 'rotation_check', 'week_rollover', 'ai_runs_purge')), + period_key TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (schedule, period_key) +); + +CREATE TABLE sessions ( + id_hash TEXT NOT NULL PRIMARY KEY, + subject TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + groups TEXT NOT NULL, -- JSON array + csrf_token TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); diff --git a/migrations/0002_household_settings_updated_by.sql b/migrations/0002_household_settings_updated_by.sql new file mode 100644 index 0000000..b75042a --- /dev/null +++ b/migrations/0002_household_settings_updated_by.sql @@ -0,0 +1,4 @@ +-- 0002_household_settings_updated_by: record who last changed the household +-- settings, as dinners and suggestion_batches already do. NULL until the +-- first change made by a signed-in member. +ALTER TABLE household_settings ADD COLUMN updated_by TEXT; diff --git a/migrations/0003_jobs_status.sql b/migrations/0003_jobs_status.sql new file mode 100644 index 0000000..c5adef8 --- /dev/null +++ b/migrations/0003_jobs_status.sql @@ -0,0 +1,32 @@ +-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed, +-- the states the job runner (internal/jobs) reports. SQLite cannot change a +-- CHECK in place, so the table is rebuilt: create the new table, copy every +-- row, drop the old one and rename. Nothing references jobs and it has no +-- indexes or triggers, so the rebuild touches no other schema object. Rows +-- written under the 0001 values are mapped (pending -> queued, +-- done -> succeeded) so the copy cannot fail on an existing database. + +CREATE TABLE jobs_new ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')), + payload TEXT NOT NULL, -- JSON + result TEXT, -- JSON, written progressively + error TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at) +SELECT + id, + kind, + CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END, + payload, result, error, started_at, finished_at, created_at, updated_at +FROM jobs; + +DROP TABLE jobs; + +ALTER TABLE jobs_new RENAME TO jobs; diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..a9cb022 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,10 @@ +// Package migrations embeds the SQL schema migrations applied at boot by +// internal/store. Files are named NNNN_slug.sql and applied in filename order. +package migrations + +import "embed" + +// FS holds every *.sql migration in this directory. +// +//go:embed *.sql +var FS embed.FS diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql new file mode 100644 index 0000000..6b4d39b --- /dev/null +++ b/migrations/queries/dinners.sql @@ -0,0 +1,57 @@ +-- name: CreateDinner :one +INSERT INTO dinners ( + id, name, normalized_name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, + updated_by, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +RETURNING *; + +-- name: GetDinner :one +SELECT * FROM dinners +WHERE id = ?; + +-- name: ListDinnersByStatus :many +SELECT * FROM dinners +WHERE status = ? +ORDER BY normalized_name, id; + +-- name: UpdateDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: ArchiveDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET status = 'archived', + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: SetDinnerFavorite :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET is_favorite = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; diff --git a/migrations/queries/jobs.sql b/migrations/queries/jobs.sql new file mode 100644 index 0000000..6d2bad9 --- /dev/null +++ b/migrations/queries/jobs.sql @@ -0,0 +1,37 @@ +-- name: CreateJob :exec +INSERT INTO jobs (id, kind, status, payload, created_at, updated_at) +VALUES (?, ?, 'queued', ?, ?, ?); + +-- name: ClaimNextJob :one +-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows +-- when no job is queued. +UPDATE jobs +SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1) +RETURNING *; + +-- name: PublishJobResult :execrows +-- Stores a partial result of a running job. +UPDATE jobs +SET result = ?, updated_at = ? +WHERE id = ? AND status = 'running'; + +-- name: SucceedJob :execrows +UPDATE jobs +SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'running'; + +-- name: FailJob :execrows +UPDATE jobs +SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'running'; + +-- name: FailRunningJobs :execrows +-- Fails every job left running, at boot before any worker starts. +UPDATE jobs +SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE status = 'running'; + +-- name: GetJob :one +SELECT * FROM jobs +WHERE id = ?; diff --git a/migrations/queries/photos.sql b/migrations/queries/photos.sql new file mode 100644 index 0000000..983d5ab --- /dev/null +++ b/migrations/queries/photos.sql @@ -0,0 +1,17 @@ +-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING *; + +-- name: GetPhotoBySHA256 :one +-- Finds stored image bytes by content hash. The schema does not make sha256 +-- unique, so the earliest-created (lowest id) match is returned. +SELECT * FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1; diff --git a/migrations/queries/recipes.sql b/migrations/queries/recipes.sql new file mode 100644 index 0000000..3ed7a3c --- /dev/null +++ b/migrations/queries/recipes.sql @@ -0,0 +1,65 @@ +-- name: UpsertRecipe :one +-- A recipe is keyed by source_url: re-fetching the same URL updates the +-- existing row and keeps its id and created_at, which the returned row +-- carries. Replace its ingredients and steps in the same transaction with +-- DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipe :one +SELECT * FROM recipes +WHERE id = ?; + +-- name: GetRecipeBySourceURL :one +SELECT * FROM recipes +WHERE source_url = ?; + +-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +); + +-- name: ListRecipeIngredients :many +SELECT * FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ?; + +-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +); + +-- name: ListRecipeSteps :many +SELECT * FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ?; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql new file mode 100644 index 0000000..171be84 --- /dev/null +++ b/migrations/queries/settings.sql @@ -0,0 +1,129 @@ +-- name: GetHouseholdSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM household_settings +WHERE id = 1; + +-- name: UpsertHouseholdSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + updated_by, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_by = excluded.updated_by, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetAISettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM ai_settings +WHERE id = 1; + +-- name: UpsertAISettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipeSearchSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM recipe_search_settings +WHERE id = 1; + +-- name: UpsertRecipeSearchSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetIntegrationsGoogle :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM integrations_google +WHERE id = 1; + +-- name: UpsertIntegrationsGoogle :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetChatSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM chat_settings +WHERE id = 1; + +-- name: UpsertChatSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING *; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..bbdc793 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,15 @@ +# sqlc code generation. Regenerate with `make sqlc` (pinned sqlc version in the +# Makefile); `make sqlc-check` fails when internal/db is stale. sqlc reads only +# the top-level *.sql files of the schema directory, so migrations/queries/ is +# not treated as schema. +version: "2" +sql: + - engine: sqlite + schema: migrations + queries: migrations/queries + gen: + go: + package: db + out: internal/db + sql_package: database/sql + emit_interface: true diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..46ee35a --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + MealQuest + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..28508c6 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2467 @@ +{ + "name": "mealquest-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mealquest-web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "lucide-react": "^1.51.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^7.18.4" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.2", + "vitest": "^5.0.3" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.3.tgz", + "integrity": "sha512-MDKmPBa6CfNN1zAJroTl7Uc+nBbcNtfE/F24iROfZAtcLffEeY3IbHR8XeHH5dJBspMYTpAsjEYmnp3tAmoVxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz", + "integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9ce06bc --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "mealquest-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "npm run lint:colors && vitest run", + "lint:colors": "node scripts/lint-colors.mjs", + "check:dist-fonts": "node scripts/check-dist-fonts.mjs" + }, + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "lucide-react": "^1.51.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^7.18.4" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.2", + "vitest": "^5.0.3" + } +} diff --git a/web/scripts/check-dist-fonts.mjs b/web/scripts/check-dist-fonts.mjs new file mode 100644 index 0000000..fa9d2cd --- /dev/null +++ b/web/scripts/check-dist-fonts.mjs @@ -0,0 +1,32 @@ +// Fails when the production build references Google Fonts: fonts are +// self-hosted, so dist must never point at fonts.googleapis.com or +// fonts.gstatic.com. +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)) +const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com'] + +function* files(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) yield* files(path) + else yield path + } +} + +const violations = [] +for (const path of files(distDir)) { + const content = readFileSync(path, 'latin1') + for (const host of hosts) { + if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`) + } +} + +if (violations.length > 0) { + console.error('Production build references Google Fonts; fonts must be self-hosted:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('check:dist-fonts: dist makes no Google Fonts requests') diff --git a/web/scripts/lint-colors.mjs b/web/scripts/lint-colors.mjs new file mode 100644 index 0000000..e6ed7e5 --- /dev/null +++ b/web/scripts/lint-colors.mjs @@ -0,0 +1,42 @@ +// Fails when a hex colour literal appears in any CSS or TypeScript file under +// web/ outside the design tokens: every colour must come from a custom +// property in tokens.css. Dependencies, build output and hidden directories +// (tool caches) are not source and are skipped. +import { readFileSync, readdirSync } from 'node:fs' +import { extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const webRoot = fileURLToPath(new URL('..', import.meta.url)) +const allowed = new Set(['src/styles/tokens.css']) +const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')]) +const extensions = new Set(['.css', '.ts', '.tsx']) +const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g + +function* sourceFiles(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path) + } else if (extensions.has(extname(entry.name))) yield path + } +} + +const violations = [] +for (const path of sourceFiles(webRoot)) { + const name = relative(webRoot, path).split('\\').join('/') + if (allowed.has(name)) continue + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, i) => { + for (const match of line.matchAll(hexColour)) { + violations.push(`${name}:${i + 1}: ${match[0]}`) + } + }) +} + +if (violations.length > 0) { + console.error('Hex colour literals found; use a token from src/styles/tokens.css:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('lint:colors: no hex colour literals outside src/styles/tokens.css') diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..ed77cf6 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApiClient } from './api/index.ts' +import { mockViewport } from './test/matchMedia.ts' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' +import App, { routes } from './App.tsx' + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +function renderAt(path: string, location = fakeLocation(path)) { + render( + + + , + ) + return location +} + +describe('App routes', () => { + it('registers every handoff route plus the extra settings pages', () => { + expect(routes.map((route) => route.path)).toEqual([ + '/', + '/onboarding', + '/plan', + '/plan/shopping', + '/plan/shopping/archive', + '/discover', + '/quick', + '/cook-from-pantry', + '/dinner/:id', + '/settings', + '/settings/notifications', + '/settings/ai', + '/settings/household', + '/settings/regulars', + '/settings/calendar', + ]) + }) + + it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + '%s renders its placeholder page inside the shell', + async (path, title) => { + mockViewport(390) + stubFetch(jsonResponse(200, ME)) + renderAt(path) + const heading = await screen.findByRole('heading', { level: 1, name: title }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }, + ) +}) + +describe('App bootstrap', () => { + it('shows the skeleton until /me loads, then the routes', async () => { + mockViewport(390) + const fetchMock = stubFetch(jsonResponse(200, ME)) + renderAt('/plan') + + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) + }) + + it('sends a signed-out visitor to sign-in and never renders the routes', async () => { + const fetchMock = stubFetch(errorResponse(401, 'unauthenticated')) + const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1')) + + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1')) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }) + + it('shows the not-in-this-household screen on a 403', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const location = renderAt('/') + + expect(await screen.findByText(/not in this household/)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(location.assign).not.toHaveBeenCalled() + }) + + it('reports a /me failure with a retry instead of rendering the routes', async () => { + mockViewport(390) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME)) + renderAt('/') + + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') + screen.getByRole('button', { name: 'Retry' }).click() + expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..13cdf10 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { QueryClientProvider } from '@tanstack/react-query' +import { Route, Routes } from 'react-router' +import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' +import { AuthBootstrap } from './AuthBootstrap.tsx' +import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { AppShell } from './shell/AppShell.tsx' + +// Every route the app serves, with the heading its placeholder page shows. +export const routes = [ + { path: '/', title: 'Home' }, + { path: '/onboarding', title: 'Onboarding' }, + { path: '/plan', title: 'Plan' }, + { path: '/plan/shopping', title: 'Supply run' }, + { path: '/plan/shopping/archive', title: 'Supply archive' }, + { path: '/discover', title: 'Discover' }, + { path: '/quick', title: 'Quick dinners' }, + { path: '/cook-from-pantry', title: 'Cook from what I have' }, + { path: '/dinner/:id', title: 'Dinner' }, + { path: '/settings', title: 'Settings' }, + { path: '/settings/notifications', title: 'Notifications' }, + { path: '/settings/ai', title: 'AI assistant' }, + { path: '/settings/household', title: 'Household' }, + { path: '/settings/regulars', title: 'Regulars' }, + { path: '/settings/calendar', title: 'Calendar' }, +] as const + +// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. +export function AppRoutes() { + return ( + + }> + {routes.map(({ path, title }) => ( + } /> + ))} + + + ) +} + +export type AppProps = { + api: ApiClient +} + +// App expects to be rendered inside a router (BrowserRouter in main.tsx, +// MemoryRouter in tests). +export default function App({ api }: AppProps) { + const [queryClient] = useState(createQueryClient) + return ( + + + + + + + + ) +} diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx new file mode 100644 index 0000000..53f8661 --- /dev/null +++ b/web/src/AuthBootstrap.tsx @@ -0,0 +1,38 @@ +import { useSyncExternalStore, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ApiError, ME_PATH, useApi } from './api/index.ts' +import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx' +import { ErrorBanner } from './ui/ErrorBanner.tsx' +import { Skeleton } from './ui/Skeleton.tsx' +import styles from './pages/FullScreen.module.css' + +export const meQueryKey = [ME_PATH] as const + +// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is +// already navigating to sign-in, so the skeleton stays up; an account outside +// the household sees only the "not in this household" screen. +export function AuthBootstrap({ children }: { children: ReactNode }) { + const api = useApi() + const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) + const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity }) + + if (forbidden) return + if (me.isSuccess) return children + if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) { + return ( +
+ void me.refetch()}>MealQuest could not load: {me.error.message} +
+ ) + } + return ( +
+
+ + + + +
+
+ ) +} diff --git a/web/src/api/ApiContext.tsx b/web/src/api/ApiContext.tsx new file mode 100644 index 0000000..b12ae8a --- /dev/null +++ b/web/src/api/ApiContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext, type ReactNode } from 'react' +import type { ApiClient } from './client.ts' + +const ApiContext = createContext(null) + +export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) { + return {children} +} + +// useApi returns the app's API client. It must be rendered inside ApiProvider. +export function useApi(): ApiClient { + const client = useContext(ApiContext) + if (client === null) throw new Error('useApi: no ApiProvider above this component') + return client +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..f6fa5be --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts' +import { ApiError, createApiClient, CSRF_HEADER } from './index.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('apiFetch', () => { + it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => { + const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 })) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 }) + await expect( + api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }), + ).resolves.toEqual({ ok: 2 }) + + const get = request(fetchMock, 1) + expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' }) + expect(get.headers.has(CSRF_HEADER)).toBe(false) + + const patch = request(fetchMock, 2) + expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' }) + expect(patch.headers.get(CSRF_HEADER)).toBe('token-1') + expect(patch.headers.get('Content-Type')).toBe('application/json') + }) + + it('refuses a non-GET request before /me has loaded', async () => { + const fetchMock = stubFetch() + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([ + 'https://evil.example/api/v1/me', + '//evil.example/api/v1/me', + // The placeholder origin an earlier guard compared against. + '//same-origin.invalid/api/v1/me', + 'https://same-origin.invalid/api/v1/me', + '/\\evil.example/api/v1/me', + '/auth/logout', + '/api/v1/../auth/logout', + '/api/v1/%2e%2e/auth/logout', + 'api/v1/me', + ])('refuses %s without sending it or the CSRF token', async (path) => { + const fetchMock = stubFetch(jsonResponse(200, ME)) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow( + `POST ${path}: not a same-origin /api/v1/ path`, + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }) + expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false) + }) + + it('rejects with a typed ApiError from the error body', async () => { + stubFetch(errorResponse(503, 'unavailable', true)) + const api = createApiClient(fakeLocation()) + const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true }) + }) + + it('rejects a response without the API error body', async () => { + stubFetch(new Response('bad gateway', { status: 502 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON') + }) + + it('on 401 navigates to sign-in with the current path and search to return to', async () => { + stubFetch(errorResponse(401, 'unauthenticated')) + const location = fakeLocation('/plan/shopping', '?week=2026-10-05') + const api = createApiClient(location) + + await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 }) + expect(location.assign).toHaveBeenCalledExactlyOnceWith( + '/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05', + ) + }) + + it('on a csrf 403 refreshes /me once and retries with the new token', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + jsonResponse(201, { id: 'j1' }), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({ + id: 'j1', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1') + expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' }) + expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' }) + expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2') + expect(api.forbidden()).toBe(false) + }) + + it('does not loop when the retry gets a csrf 403 too', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + errorResponse(403, 'csrf'), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({ + status: 403, + code: 'csrf', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(api.forbidden()).toBe(false) + }) + + it('marks any other 403 as not in this household and notifies subscribers', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const api = createApiClient(fakeLocation()) + const listener = vi.fn() + api.subscribe(listener) + + await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(api.forbidden()).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('does not take a 403 without the API error body as not in this household', async () => { + stubFetch(new Response('denied by proxy', { status: 403 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON') + expect(api.forbidden()).toBe(false) + }) + + it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => { + const fetchMock = stubFetch(errorResponse(403, 'csrf')) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('loadMe', () => { + it('rejects a /me response without a CSRF token', async () => { + stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token') + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..a4f840e --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,155 @@ +import { ApiError, isRecord, parseApiError } from './errors.ts' + +// CSRF_HEADER carries the session's CSRF token on every non-GET request. +export const CSRF_HEADER = 'X-MealQuest-CSRF' + +export const ME_PATH = '/api/v1/me' + +// API_PREFIX is the only path apiFetch sends to. +const API_PREFIX = '/api/v1/' + +// apiURL resolves path against the page origin and returns it as the +// path-absolute URL to fetch. It refuses any path that would not resolve to a +// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere +// else: absolute and protocol-relative URLs on another origin, and dot +// segments that climb out of /api/v1/. +function apiURL(origin: string, method: string, path: string): string { + const url = new URL(path, origin) + if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) { + throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`) + } + return url.pathname + url.search +} + +// Me is the GET /api/v1/me response: the signed-in member, the household and +// the session's CSRF token. +export type Me = { + name: string + email: string + household: { label: string } + csrfToken: string +} + +// ApiLocation is the part of window.location the client reads and navigates. +// origin is the page origin every request must stay on. +export type ApiLocation = Pick + +export type ApiRequest = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + // Sent as JSON. + body?: unknown +} + +export type ApiClient = { + // apiFetch sends a same-origin JSON request to path, which must be under + // /api/v1/, and resolves with the decoded response body, or rejects with an + // ApiError. + apiFetch(path: string, request?: ApiRequest): Promise + // loadMe fetches GET /api/v1/me and caches its CSRF token. + loadMe(): Promise + // forbidden reports whether a request was refused because the signed-in + // account is not in this household; subscribe is notified when it changes. + forbidden(): boolean + subscribe(listener: () => void): () => void +} + +// createApiClient builds the app's API client. location is window.location in +// the app: a 401 sends it to /auth/login with the current path to return to. +export function createApiClient(location: ApiLocation): ApiClient { + let csrfToken: string | null = null + let refreshing: Promise | null = null + let forbidden = false + const listeners = new Set<() => void>() + + async function send(method: string, path: string, body: unknown): Promise { + const headers: Record = { Accept: 'application/json' } + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (method !== 'GET') { + if (csrfToken === null) { + throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`) + } + headers[CSRF_HEADER] = csrfToken + } + return fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }) + } + + // refreshMe shares one in-flight /me request between every request that + // got a csrf 403 at the same time. + function refreshMe(): Promise { + refreshing ??= loadMe().finally(() => { + refreshing = null + }) + return refreshing + } + + async function apiFetch(path: string, request: ApiRequest = {}): Promise { + const method = request.method ?? 'GET' + const url = apiURL(location.origin, method, path) + let response = await send(method, url, request.body) + if (response.status === 403) { + const error = await parseForbidden(method, path, response) + // Only a non-GET carries the token, so only it can be refused for it. + if (error.code !== 'csrf' || method === 'GET') throw error + // The session's token changed: refresh it once and retry once. + await refreshMe() + response = await send(method, url, request.body) + if (response.status === 403) throw await parseForbidden(method, path, response) + } + return decode(method, path, response) + } + + // parseForbidden reads a 403. Any API error other than csrf means the + // account is not in this household. + async function parseForbidden(method: string, path: string, response: Response): Promise { + const error = await parseApiError(method, path, response) + if (error.code !== 'csrf') markForbidden() + return error + } + + function markForbidden() { + if (forbidden) return + forbidden = true + listeners.forEach((listener) => listener()) + } + + async function decode(method: string, path: string, response: Response): Promise { + if (response.status === 401) { + location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`) + } + if (!response.ok) throw await parseApiError(method, path, response) + if (response.status === 204) return undefined as T + return (await response.json()) as T + } + + async function loadMe(): Promise { + const me = await apiFetch(ME_PATH) + if ( + !isRecord(me) || + typeof me.name !== 'string' || + typeof me.email !== 'string' || + !isRecord(me.household) || + typeof me.household.label !== 'string' || + typeof me.csrfToken !== 'string' || + me.csrfToken === '' + ) { + throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`) + } + csrfToken = me.csrfToken + return me as Me + } + + return { + apiFetch, + loadMe, + forbidden: () => forbidden, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..94b81e2 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,41 @@ +// ApiError is a failed API response carrying the server's +// {"error":{"code","message","retryable"}} body. +export class ApiError extends Error { + readonly status: number + readonly code: string + readonly retryable: boolean + + constructor(status: number, code: string, message: string, retryable: boolean) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + this.retryable = retryable + } +} + +// parseApiError reads an error response body. A body that is not the API +// error shape is itself an error: the server always sends one. +export async function parseApiError(method: string, path: string, response: Response): Promise { + const text = await response.text() + let body: unknown + try { + body = JSON.parse(text) + } catch { + throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`) + } + const error = isRecord(body) ? body.error : undefined + if ( + !isRecord(error) || + typeof error.code !== 'string' || + typeof error.message !== 'string' || + typeof error.retryable !== 'boolean' + ) { + throw new Error(`${method} ${path}: ${response.status} response has no API error body`) + } + return new ApiError(response.status, error.code, error.message, error.retryable) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..ea7a989 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,5 @@ +export { ApiProvider, useApi } from './ApiContext.tsx' +export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' +export { ApiError } from './errors.ts' +export { createQueryClient, shouldRetry } from './queryClient.ts' +export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts' diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts new file mode 100644 index 0000000..81a1378 --- /dev/null +++ b/web/src/api/queryClient.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { ApiError, createQueryClient, shouldRetry } from './index.ts' + +describe('query retries', () => { + it('retry only an ApiError the server marked retryable, a bounded number of times', () => { + expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true) + expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false) + expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false) + expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false) + expect(shouldRetry(0, new Error('network down'))).toBe(false) + }) + + it('are the QueryClient default for queries and mutations', () => { + const options = createQueryClient().getDefaultOptions() + expect(options.queries?.retry).toBe(shouldRetry) + expect(options.mutations?.retry).toBe(shouldRetry) + }) +}) diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts new file mode 100644 index 0000000..b703055 --- /dev/null +++ b/web/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './errors.ts' + +// MAX_RETRIES bounds retries of a failure the server marked retryable. +export const MAX_RETRIES = 3 + +// shouldRetry retries only an ApiError the server marked retryable — never a +// 401, a 403 or a malformed response. +export function shouldRetry(failureCount: number, error: unknown): boolean { + return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: shouldRetry, staleTime: 30_000 }, + mutations: { retry: shouldRetry }, + }, + }) +} diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx new file mode 100644 index 0000000..f93ce20 --- /dev/null +++ b/web/src/api/useJob.test.tsx @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts' +import { ApiProvider, createApiClient, useJob } from './index.ts' + +type Draft = { dinners: string[] } + +function job(status: string, result: Draft | null, error: string | null = null) { + return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error }) +} + +function renderJob(jobId: string | null) { + const api = createApiClient(fakeLocation()) + const wrapper = ({ children }: { children: ReactNode }) => {children} + return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) +} + +// advance runs the timers due in ms and lets the polls they start answer. +async function advance(ms: number) { + await act(() => vi.advanceTimersByTimeAsync(ms)) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('useJob', () => { + it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => { + const fetchMock = stubFetch( + job('queued', null), + job('running', { dinners: ['Tacos'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }), + ) + const { result } = renderJob('j1') + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + + await advance(0) + expect(result.current.status).toBe('queued') + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' }) + + await advance(499) + expect(fetchMock).toHaveBeenCalledTimes(1) + await advance(1) + expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] }) + + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(4) + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(5) + expect(result.current).toEqual({ + status: 'succeeded', + result: { dinners: ['Tacos', 'Chili', 'Soup'] }, + error: null, + }) + + // Terminal: no timer left, no further polls. + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(5) + }) + + it('exposes a failed job as its error and stops', async () => { + const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused')) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(result.current.status).toBe('failed') + expect(result.current.error?.message).toBe('model refused') + expect(vi.getTimerCount()).toBe(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('exposes a failed poll as the error and keeps polling to the terminal status', async () => { + const fetchMock = stubFetch( + job('running', { dinners: ['Tacos'] }), + errorResponse(503, 'unavailable', true), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' }) + expect(vi.getTimerCount()).toBe(1) + + // The backoff carries on from the failed poll: the next one is 1 s later. + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops polling on unmount after a failed poll', async () => { + const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) + const { result, unmount } = renderJob('j1') + + await advance(0) + expect(result.current).toMatchObject({ status: undefined, result: null }) + expect(result.current.error).toMatchObject({ status: 503 }) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('stops polling the old job when the job id changes', async () => { + const fetchMock = stubFetch(job('running', null), job('running', null)) + const { result, rerender } = renderJob('j1') + + await advance(0) + rerender({ id: 'j2' }) + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + await advance(0) + expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') + expect(vi.getTimerCount()).toBe(1) + }) + + it('stops polling on unmount', async () => { + const fetchMock = stubFetch(job('running', null)) + const { unmount } = renderJob('j1') + + await advance(0) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a poll that answers after unmount', async () => { + let answer!: (response: Response) => void + const fetchMock = stubFetch() + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + const { result, unmount } = renderJob('j1') + + unmount() + await act(async () => answer(await Promise.resolve(job('running', null)))) + expect(vi.getTimerCount()).toBe(0) + expect(result.current.status).toBeUndefined() + }) + + it('polls nothing without a job id', async () => { + const fetchMock = stubFetch() + const { result } = renderJob(null) + await advance(5000) + expect(fetchMock).not.toHaveBeenCalled() + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + }) +}) diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts new file mode 100644 index 0000000..2a26c2c --- /dev/null +++ b/web/src/api/useJob.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react' +import { useApi } from './ApiContext.tsx' + +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' + +// Job is the GET /api/v1/jobs/{id} response. result may be set while the job +// is running (a partial result); error is set only when it failed. +export type Job = { + id: string + kind: string + status: JobStatus + result: R | null + error: string | null +} + +export type JobState = { + // undefined until the first poll answers. + status: JobStatus | undefined + // The latest result, partial while the job runs. + result: R | null + // The job's failure, or the latest polling request's own failure (cleared + // by the next poll that answers). + error: Error | null +} + +// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS. +export const FIRST_DELAY_MS = 500 +export const MAX_DELAY_MS = 2000 + +const idle = { status: undefined, result: null, error: null } + +function isTerminal(status: JobStatus): boolean { + return status === 'succeeded' || status === 'failed' +} + +// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, +// backing off from 500 ms to 2 s. A failed poll is exposed as the error and +// polling continues. Polling stops only at a terminal status, when jobId +// changes and on unmount. A null jobId polls nothing. +export function useJob(jobId: string | null): JobState { + const { apiFetch } = useApi() + const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) + + useEffect(() => { + if (jobId === null) return + let active = true + let timer: ReturnType | undefined + let delay = FIRST_DELAY_MS + + function next() { + timer = setTimeout(poll, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + + async function poll() { + try { + const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) + if (!active) return + const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null + setState({ jobId, job: { status: job.status, result: job.result, error } }) + if (isTerminal(job.status)) return + } catch (error) { + if (!active) return + // A failed poll is not the job's end: keep the last known status and + // result, expose the request's error, and poll again. + setState((prev) => ({ + jobId, + job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, + })) + } + next() + } + + void poll() + return () => { + active = false + clearTimeout(timer) + } + }, [apiFetch, jobId]) + + // State left from a previous jobId is not this job's. + return state.jobId === jobId ? state.job : idle +} diff --git a/web/src/assets/fonts/BagelFatOne-Latin.woff2 b/web/src/assets/fonts/BagelFatOne-Latin.woff2 new file mode 100644 index 0000000..bd96304 Binary files /dev/null and b/web/src/assets/fonts/BagelFatOne-Latin.woff2 differ diff --git a/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 new file mode 100644 index 0000000..f3bd500 Binary files /dev/null and b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 differ diff --git a/web/src/assets/fonts/BagelFatOne-OFL.txt b/web/src/assets/fonts/BagelFatOne-OFL.txt new file mode 100644 index 0000000..21c7b2d --- /dev/null +++ b/web/src/assets/fonts/BagelFatOne-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Bold.woff2 b/web/src/assets/fonts/Figtree-Bold.woff2 new file mode 100644 index 0000000..81bcca4 Binary files /dev/null and b/web/src/assets/fonts/Figtree-Bold.woff2 differ diff --git a/web/src/assets/fonts/Figtree-OFL.txt b/web/src/assets/fonts/Figtree-OFL.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/web/src/assets/fonts/Figtree-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Regular.woff2 b/web/src/assets/fonts/Figtree-Regular.woff2 new file mode 100644 index 0000000..2c1165f Binary files /dev/null and b/web/src/assets/fonts/Figtree-Regular.woff2 differ diff --git a/web/src/assets/fonts/Figtree-SemiBold.woff2 b/web/src/assets/fonts/Figtree-SemiBold.woff2 new file mode 100644 index 0000000..8855ccb Binary files /dev/null and b/web/src/assets/fonts/Figtree-SemiBold.woff2 differ diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..fe00a99 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import './styles/tokens.css' +import './styles/base.css' +import App from './App.tsx' +import { createApiClient } from './api/index.ts' + +const root = document.getElementById('root') +if (!root) { + throw new Error('MealQuest: #root element missing from index.html') +} + +const api = createApiClient(window.location) + +createRoot(root).render( + + + + + , +) diff --git a/web/src/pages/FullScreen.module.css b/web/src/pages/FullScreen.module.css new file mode 100644 index 0000000..12c55ec --- /dev/null +++ b/web/src/pages/FullScreen.module.css @@ -0,0 +1,23 @@ +/* A full-viewport screen shown instead of the app shell. */ +.screen { + min-height: 100dvh; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + background: var(--color-bg); +} + +.loading { + width: min(100%, 480px); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.title { + height: 32px; + width: 60%; +} diff --git a/web/src/pages/NotInHouseholdPage.tsx b/web/src/pages/NotInHouseholdPage.tsx new file mode 100644 index 0000000..f1bb3db --- /dev/null +++ b/web/src/pages/NotInHouseholdPage.tsx @@ -0,0 +1,14 @@ +import { EmptyState } from '../ui/EmptyState.tsx' +import styles from './FullScreen.module.css' + +// Shown when the server refuses a signed-in account that is not in this +// household. Nothing else renders: the account can do nothing here. +export function NotInHouseholdPage() { + return ( +
+ + You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it. + +
+ ) +} diff --git a/web/src/pages/PlaceholderPage.tsx b/web/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..a8d7124 --- /dev/null +++ b/web/src/pages/PlaceholderPage.tsx @@ -0,0 +1,9 @@ +export type PlaceholderPageProps = { + // The route's screen name, shown as the page heading. + title: string +} + +// Stands in for a screen until its feature is built. +export function PlaceholderPage({ title }: PlaceholderPageProps) { + return

{title}

+} diff --git a/web/src/shell/AppShell.module.css b/web/src/shell/AppShell.module.css new file mode 100644 index 0000000..e1eff44 --- /dev/null +++ b/web/src/shell/AppShell.module.css @@ -0,0 +1,24 @@ +.shell { + position: relative; + height: 100dvh; + display: flex; + background: var(--color-bg); +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +/* The bottom bar overlays the last 94 px of the screen (plus the safe area), + so the scroll area reserves at least 100 px beneath its content. */ +.mobile .main { + padding: 0 20px; + padding-bottom: calc(100px + env(safe-area-inset-bottom)); +} + +.tablet .main, +.desktop .main { + padding: 32px 36px; +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..03d7bfd --- /dev/null +++ b/web/src/shell/AppShell.test.tsx @@ -0,0 +1,211 @@ +import { render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it } from 'vitest' +import { AppRoutes } from '../App.tsx' +import { declaredStyle, outerSize } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' +import bottomBarStyles from './BottomBar.module.css' +import railStyles from './Rail.module.css' +import sidebarStyles from './Sidebar.module.css' + +const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings'] +const MOBILE = 390 +const TABLET = 1180 +const DESKTOP = 1440 + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, width: number) { + const resize = mockViewport(width) + render( + + + , + ) + return resize +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function navLinks() { + return within(nav()).getAllByRole('link') +} + +function current() { + return navLinks() + .filter((link) => link.getAttribute('aria-current') === 'page') + .map((link) => link.textContent) +} + +describe('AppShell', () => { + it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => { + renderAt('/', width) + expect(navLinks().map((link) => link.textContent)).toEqual(ORDER) + expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/', + '/plan', + '/discover', + '/quick', + '/settings', + ]) + }) + + it('shows only the mobile bottom bar below 768 px', () => { + renderAt('/', 767) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.queryByText('M')).not.toBeInTheDocument() + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + const bar = declaredStyle(nav()) + expect(bar.position).toBe('absolute') + expect(bar.left).toBe('12px') + expect(bar.right).toBe('12px') + expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))') + expect(bar.height).toBe('62px') + expect(outerSize(nav(), 'height')).toBe(62) + expect(bar.background).toBe('var(--color-text)') + expect(bar.borderRadius).toBe('var(--radius-pill)') + }) + + it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => { + renderAt('/discover', MOBILE) + const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`) + expect(pills).toHaveLength(1) + const pill = declaredStyle(pills[0]!) + expect(pill.position).toBe('absolute') + expect(pill.top).toBe('10px') + expect(pill.height).toBe('42px') + expect(pill.background).toBe('var(--color-accent)') + expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex)) + + const active = screen.getByRole('link', { name: 'Discover' }) + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!) + expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px') + + const inactive = screen.getByRole('link', { name: 'Plan' }) + expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)') + expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) + }) + + it('makes the full 62 px bar height the mobile tap target', () => { + renderAt('/', MOBILE) + for (const link of navLinks()) { + expect(declaredStyle(link).height).toBe('62px') + expectMinHitArea(link) + } + }) + + it('reserves at least 100 px below the mobile scroll area', () => { + renderAt('/', MOBILE) + const padding = declaredStyle(screen.getByRole('main')).paddingBottom + const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding) + expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull() + expect(Number(reserved![1])).toBeGreaterThanOrEqual(100) + }) + + it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => { + renderAt('/plan', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(railStyles.rail!) + expect(declaredStyle(nav()).width).toBe('96px') + expect(outerSize(nav(), 'width')).toBe(96) + expect(declaredStyle(nav()).background).toBe('var(--color-surface)') + const mark = within(nav()).getByText('M') + expect(declaredStyle(mark).width).toBe('48px') + expect(outerSize(mark, 'width')).toBe(48) + expect(outerSize(mark, 'height')).toBe(48) + expect(declaredStyle(mark).background).toBe('var(--color-accent)') + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + for (const link of navLinks()) { + const pill = link.firstElementChild! + expect(declaredStyle(pill).width).toBe('56px') + expect(declaredStyle(pill).height).toBe('36px') + expect(outerSize(pill, 'width')).toBe(56) + expect(outerSize(pill, 'height')).toBe(36) + expect(declaredStyle(link).fontSize).toBe('11px') + expect(declaredStyle(link).fontWeight).toBe('700') + expectMinHitArea(link) + } + expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!) + expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!) + }) + + it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => { + renderAt('/quick', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + const sidebar = nav().closest('aside')! + expect(sidebar).toHaveClass(sidebarStyles.sidebar!) + expect(declaredStyle(sidebar).width).toBe('208px') + expect(declaredStyle(sidebar).background).toBe('var(--color-surface)') + expect(declaredStyle(sidebar).padding).toBe('28px 16px') + // The 16 px side padding sits inside the 208 px, not outside it. + expect(outerSize(sidebar, 'width')).toBe(208) + + const wordmark = within(sidebar).getByText('MealQuest') + expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(wordmark).fontSize).toBe('28px') + expect(declaredStyle(wordmark).color).toBe('var(--color-accent)') + + expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument() + expect(within(sidebar).getByText('Shared household')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() + + const active = screen.getByRole('link', { name: 'Quick' }) + expect(active).toHaveClass(sidebarStyles.active!) + expect(declaredStyle(active).background).toBe('var(--color-accent)') + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(declaredStyle(active).fontSize).toBe('15px') + expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!) + // Rows stretch across the sidebar, so only their height needs declaring. + for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px') + }) + + it('switches layout when the viewport crosses a breakpoint', () => { + const resize = renderAt('/', MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + resize(TABLET) + expect(nav()).toHaveClass(railStyles.rail!) + resize(DESKTOP) + expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!) + resize(MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + }) + + describe.each([ + ['mobile', MOBILE], + ['tablet', TABLET], + ['desktop', DESKTOP], + ])('aria-current (%s)', (_layout, width) => { + it.each([ + ['/', ['Home']], + ['/plan', ['Plan']], + ['/plan/shopping', ['Plan']], + ['/plan/shopping/archive', ['Plan']], + ['/discover', ['Discover']], + ['/quick', ['Quick']], + ['/settings', ['Settings']], + ['/settings/notifications', ['Settings']], + ['/settings/ai', ['Settings']], + ['/settings/household', ['Settings']], + ['/settings/regulars', ['Settings']], + ['/settings/calendar', ['Settings']], + ['/onboarding', []], + ['/cook-from-pantry', []], + ['/dinner/abc123', []], + ])('%s marks %j current', (path, expected) => { + renderAt(path, width) + expect(current()).toEqual(expected) + }) + }) +}) diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx new file mode 100644 index 0000000..7baf72b --- /dev/null +++ b/web/src/shell/AppShell.tsx @@ -0,0 +1,22 @@ +import { Outlet } from 'react-router' +import { BottomBar } from './BottomBar.tsx' +import { Rail } from './Rail.tsx' +import { Sidebar } from './Sidebar.tsx' +import { useBreakpoint } from './useBreakpoint.ts' +import styles from './AppShell.module.css' + +// The responsive frame around every page: one nav layout per breakpoint and a +// scrolling main area for the routed page. +export function AppShell() { + const breakpoint = useBreakpoint() + return ( +
+ {breakpoint === 'mobile' && } + {breakpoint === 'tablet' && } + {breakpoint === 'desktop' && } +
+ +
+
+ ) +} diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css new file mode 100644 index 0000000..97effa3 --- /dev/null +++ b/web/src/shell/BottomBar.module.css @@ -0,0 +1,65 @@ +.bar { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom)); + height: 62px; + padding: 0 6px; + box-sizing: border-box; + display: flex; + justify-content: space-around; + align-items: center; + border-radius: var(--radius-pill); + background: var(--color-text); +} + +/* The one grape pill, shared by every item: BottomBar sets its left and width + to the active item's and slides it between items. */ +.pill { + position: absolute; + top: 10px; + height: 42px; + z-index: 0; + border-radius: var(--radius-pill); + background: var(--color-accent); + pointer-events: none; +} + +/* The whole bar height is the tap target; the 42 px pill sits behind it. + Items stay above the pill. */ +.item { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 62px; + min-width: 44px; + padding: 0 13px; + box-sizing: border-box; + color: var(--color-neutral-400); + text-decoration: none; + white-space: nowrap; + transition: color .2s; +} + +.item[aria-current='page'] { + color: var(--on-accent); +} + +.label { + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* Inactive items are icon-only on screen but keep their name for assistive tech. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx new file mode 100644 index 0000000..3ff7db6 --- /dev/null +++ b/web/src/shell/BottomBar.test.tsx @@ -0,0 +1,282 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppRoutes } from '../App.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { mockViewport } from '../test/matchMedia.ts' +import styles from './BottomBar.module.css' + +const MOBILE = 390 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' + +// jsdom does no layout, so lay the bar out here: items sit side by side from +// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px. +function layOut(element: HTMLElement): { left: number; width: number } { + const bar = element.parentElement! + const items = Array.from(bar.querySelectorAll(`.${styles.item}`)) + let left = 6 + for (const item of items) { + const width = item.getAttribute('aria-current') === 'page' ? 110 : 48 + if (item === element) return { left, width } + left += width + } + return { left: 0, width: 0 } +} + +// jsdom paints nothing either, so bounding boxes come from the laid-out +// offsets with the bar at the viewport's left edge. A test mid-slide sets +// where the pill is drawn and how far each item is still translated. +let drawnPill: { left: number; width: number } | null +let translated: Map + +function box(left: number, width: number): DOMRect { + return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect +} + +let animate: Mock + +beforeEach(() => { + drawnPill = null + translated = new Map() + // jsdom has no Web Animations API. + animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation) + Element.prototype.animate = animate + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (!(this instanceof HTMLElement)) return box(0, 0) + if (this.classList.contains(styles.pill!)) { + return drawnPill + ? box(drawnPill.left, drawnPill.width) + : box(parseFloat(this.style.left), parseFloat(this.style.width)) + } + if (this.classList.contains(styles.item!)) { + return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth) + } + return box(0, 0) + }) + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).left + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).width + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(Element.prototype, 'animate') + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, reducedMotion = false) { + mockViewport(MOBILE, { reducedMotion }) + render( + + + , + ) +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function pill(): HTMLElement { + return nav().querySelector(`.${styles.pill}`)! +} + +function link(name: string): HTMLElement { + return within(nav()).getByRole('link', { name }) +} + +function runningOn(element: Element): Animation[] { + return animate.mock.contexts + .map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation })) + .filter(({ target }) => target === element) + .map(({ animation }) => animation) +} + +function animationsOf(element: Element) { + return animate.mock.contexts + .map((target, i) => ({ target, args: animate.mock.calls[i]! })) + .filter(({ target }) => target === element) + .map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions })) +} + +describe('BottomBar', () => { + it('places the pill on the active item without animating on first render', () => { + renderAt('/plan') + expect(pill().style.left).toBe('54px') + expect(pill().style.width).toBe('110px') + expect(pill()).toBeVisible() + expect(animate).not.toHaveBeenCalled() + }) + + it('hides the pill when no item is active', () => { + renderAt('/onboarding') + expect(pill()).not.toBeVisible() + }) + + it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '6px', width: '110px' }, + { left: '150px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(pill().style.left).toBe('150px') + expect(pill().style.width).toBe('110px') + }) + + it('FLIPs every item that moved from its old left to its new one with the same timing', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + // Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left + // and Quick grows in place; Settings ends where it started. + for (const name of ['Plan', 'Discover']) { + expect(animationsOf(link(name))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + } + expect(animationsOf(link('Quick'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Home'))).toEqual([]) + expect(animationsOf(link('Settings'))).toEqual([]) + }) + + it('fades the new label in over 220 ms ease-out after 120 ms', () => { + renderAt('/') + fireEvent.click(link('Discover')) + + const label = within(link('Discover')).getByText('Discover') + expect(label).toHaveClass(styles.label!) + expect(animationsOf(label)).toEqual([ + { + keyframes: [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' }, + }, + ]) + }) + + it('does not animate when the active item stays the same', () => { + renderAt('/plan') + fireEvent.click(link('Plan')) + expect(animate).not.toHaveBeenCalled() + }) + + it('jumps the pill to the new item with no animation under reduced motion', () => { + renderAt('/', true) + fireEvent.click(link('Settings')) + + expect(link('Settings')).toHaveAttribute('aria-current', 'page') + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('198px') + expect(pill().style.width).toBe('110px') + }) + + it('re-places the pill when the viewport resizes', () => { + renderAt('/discover') + expect(pill().style.left).toBe('102px') + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + expect(pill().style.left).toBe('120px') + expect(pill().style.width).toBe('96px') + expect(animate).not.toHaveBeenCalled() + }) + + it('starts a second slide from where the first one is on screen, not from its destination', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const [firstPill] = runningOn(pill()) + const firstPlan = runningOn(link('Plan'))[0]! + + // Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan + // still carries 31 of its 62 px FLIP translation. + drawnPill = { left: 78, width: 110 } + translated.set(link('Plan'), 31) + animate.mockClear() + fireEvent.click(link('Settings')) + + expect(firstPill!.cancel).toHaveBeenCalled() + expect(firstPlan.cancel).toHaveBeenCalled() + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '78px', width: '110px' }, + { left: '198px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + // Plan's layout does not move between Quick and Settings, so it only + // finishes the 31 px it had left. Settings moves 62 px left. + expect(animationsOf(link('Plan'))).toEqual([ + { + keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Settings'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + }) + + it('stops a running slide and places the pill on the active item when the viewport resizes', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn) + expect(slide).toHaveLength(4) + + animate.mockClear() + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + + for (const animation of slide) expect(animation.cancel).toHaveBeenCalled() + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('140px') + expect(pill().style.width).toBe('96px') + }) + + it('re-places the pill once the web fonts are ready', async () => { + let fontsReady!: () => void + const ready = new Promise((resolve) => (fontsReady = resolve)) + Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } }) + try { + renderAt('/discover') + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124) + fontsReady() + await ready + expect(pill().style.width).toBe('124px') + } finally { + Reflect.deleteProperty(document, 'fonts') + } + }) + + it('crossfades the icon colour with a .2s colour transition', () => { + renderAt('/') + for (const item of within(nav()).getAllByRole('link')) { + expect(declaredStyle(item).transition).toBe('color .2s') + } + }) +}) diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx new file mode 100644 index 0000000..f6af58b --- /dev/null +++ b/web/src/shell/BottomBar.tsx @@ -0,0 +1,152 @@ +import { useLayoutEffect, useRef } from 'react' +import { NavLink, useLocation } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './BottomBar.module.css' + +// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value. +const SLIDE_MS = 420 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' +const LABEL_MS = 220 +const LABEL_DELAY_MS = 120 +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' + +// The active item and every item's layout offset within the bar when the pill +// was last placed (offsets ignore transforms, so a running FLIP does not skew +// them). +type Geometry = { + active: HTMLElement | null + itemLefts: number[] +} + +// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive +// items show only their icon; the active item adds its label. One shared +// grape pill sits behind the items and slides to the active one. +export function BottomBar() { + const barRef = useRef(null) + const pillRef = useRef(null) + const placed = useRef(null) + // The pill slide and item FLIPs currently running. + const sliding = useRef([]) + const stopSlide = () => { + sliding.current.forEach((animation) => animation.cancel()) + sliding.current = [] + } + const { pathname } = useLocation() + + // After each navigation: if the active item changed, move the pill to it and, + // motion allowed, slide the pill, FLIP the items and fade the label in. A + // slide still running is measured where it is on screen, then stopped, so + // the next one starts from there instead of from its destination. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + const active = activeItem(bar) + const before = placed.current + if (before && before.active === active) return + const from = before && onScreen(bar, pill, before) + stopSlide() + placed.current = place(bar, pill, active) + if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return + + const after = placed.current + if (before.active) { + sliding.current.push( + pill.animate( + [ + { left: `${from.pill.left}px`, width: `${from.pill.width}px` }, + { left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` }, + ], + { duration: SLIDE_MS, easing: SPRING }, + ), + ) + } + items(bar).forEach((item, i) => { + const dx = from.itemLefts[i]! - after.itemLefts[i]! + if (dx === 0) return + sliding.current.push( + item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { + duration: SLIDE_MS, + easing: SPRING, + }), + ) + }) + active.querySelector(`.${styles.label}`)!.animate( + [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' }, + ) + }, [pathname]) + + // Item widths change with the viewport and once the web fonts arrive, so + // stop any running slide and re-place the pill (without animating) on both. A failed font load + // rejects fonts.ready and is left to surface as an unhandled rejection. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + let mounted = true + const replace = () => { + if (!mounted) return + stopSlide() + placed.current = place(bar, pill, activeItem(bar)) + } + window.addEventListener('resize', replace) + // jsdom has no document.fonts; every browser the app targets does. + if (document.fonts) void document.fonts.ready.then(replace) + return () => { + mounted = false + window.removeEventListener('resize', replace) + } + }, []) + + return ( + + ) +} + +function items(bar: HTMLElement): HTMLElement[] { + return Array.from(bar.querySelectorAll(`.${styles.item}`)) +} + +// NavLink's aria-current is the one record of which item is active. +function activeItem(bar: HTMLElement): HTMLElement | null { + return bar.querySelector(`.${styles.item}[aria-current="page"]`) +} + +// Where the pill and items are on screen right now, as offsets within the +// bar. Bounding boxes include a running slide: the pill's box is its animated +// left and width, and an item's box is its current layout plus whatever FLIP +// translation remains, which applies to the layout it had when last placed. +function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) { + const origin = bar.getBoundingClientRect().left + bar.clientLeft + const pillBox = pill.getBoundingClientRect() + return { + pill: { left: pillBox.left - origin, width: pillBox.width }, + itemLefts: items(bar).map( + (item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft, + ), + } +} + +// Puts the pill on the active item (hidden when no item is active) and +// returns the resulting geometry. +function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry { + pill.hidden = !active + pill.style.left = `${active?.offsetLeft ?? 0}px` + pill.style.width = `${active?.offsetWidth ?? 0}px` + return { active, itemLefts: items(bar).map((item) => item.offsetLeft) } +} diff --git a/web/src/shell/Rail.module.css b/web/src/shell/Rail.module.css new file mode 100644 index 0000000..f9da188 --- /dev/null +++ b/web/src/shell/Rail.module.css @@ -0,0 +1,51 @@ +.rail { + width: 96px; + padding: 24px 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + background: var(--color-surface); +} + +.mark { + width: 48px; + height: 48px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 22px; +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 56px; + min-height: 44px; + color: var(--color-neutral-700); + font-size: 11px; + font-weight: 700; + text-decoration: none; +} + +.pill { + width: 56px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} diff --git a/web/src/shell/Rail.tsx b/web/src/shell/Rail.tsx new file mode 100644 index 0000000..221bef4 --- /dev/null +++ b/web/src/shell/Rail.tsx @@ -0,0 +1,27 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Rail.module.css' + +// Tablet nav: a narrow icon rail with the "M" mark on top. +export function Rail() { + return ( + + ) +} diff --git a/web/src/shell/Sidebar.module.css b/web/src/shell/Sidebar.module.css new file mode 100644 index 0000000..4aa11ce --- /dev/null +++ b/web/src/shell/Sidebar.module.css @@ -0,0 +1,78 @@ +.sidebar { + width: 208px; + padding: 28px 16px; + box-sizing: border-box; + display: flex; + flex-direction: column; + background: var(--color-surface); +} + +.wordmark { + margin: 0; + padding: 0 12px 22px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + color: var(--color-accent); +} + +.nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 12px 16px; + box-sizing: border-box; + border-radius: var(--radius-pill); + color: var(--color-neutral-800); + font-size: 15px; + font-weight: 700; + text-decoration: none; +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.household { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 22px; + background: var(--color-bg); +} + +.avatar { + width: 36px; + height: 36px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-heading); + font-size: 15px; +} + +.householdText { + margin: 0; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.3; +} + +.householdNote { + color: var(--color-neutral-700); +} diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..9374af2 --- /dev/null +++ b/web/src/shell/Sidebar.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Sidebar.module.css' + +// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared +// household card at the foot. +export function Sidebar() { + return ( + + ) +} diff --git a/web/src/shell/navItems.ts b/web/src/shell/navItems.ts new file mode 100644 index 0000000..8b22f63 --- /dev/null +++ b/web/src/shell/navItems.ts @@ -0,0 +1,20 @@ +import type { LucideIcon } from 'lucide-react' +import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react' + +export type NavItem = { + to: string + label: string + icon: LucideIcon + // Match the path exactly; otherwise nested paths (/plan/shopping, + // /settings/ai) also mark the item current. + end: boolean +} + +// The primary nav, in the same order in every layout. +export const navItems: readonly NavItem[] = [ + { to: '/', label: 'Home', icon: House, end: true }, + { to: '/plan', label: 'Plan', icon: Calendar, end: false }, + { to: '/discover', label: 'Discover', icon: Compass, end: false }, + { to: '/quick', label: 'Quick', icon: Zap, end: false }, + { to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false }, +] diff --git a/web/src/shell/useBreakpoint.ts b/web/src/shell/useBreakpoint.ts new file mode 100644 index 0000000..28b9f48 --- /dev/null +++ b/web/src/shell/useBreakpoint.ts @@ -0,0 +1,31 @@ +import { useCallback, useState, useSyncExternalStore } from 'react' + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop' + +// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px, +// desktop ≥ 1200 px. +export const TABLET_QUERY = '(min-width: 768px)' +export const DESKTOP_QUERY = '(min-width: 1200px)' + +// The layout the current viewport calls for, updated as the viewport crosses +// a breakpoint. +export function useBreakpoint(): Breakpoint { + const [lists] = useState(() => ({ + tablet: window.matchMedia(TABLET_QUERY), + desktop: window.matchMedia(DESKTOP_QUERY), + })) + const subscribe = useCallback( + (onChange: () => void) => { + lists.tablet.addEventListener('change', onChange) + lists.desktop.addEventListener('change', onChange) + return () => { + lists.tablet.removeEventListener('change', onChange) + lists.desktop.removeEventListener('change', onChange) + } + }, + [lists], + ) + return useSyncExternalStore(subscribe, () => + lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile', + ) +} diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..dc83c17 --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,12 @@ +/* App-wide base styles. Values come only from tokens.css. */ +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..167806b --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,116 @@ +/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white". + Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */ +/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside) + in place of the handoff's Google Fonts @import, so the app never calls Google. + Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets. + Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */ +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2"); +} + +:root { + /* Ground + ink */ + --color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */ + --color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */ + --color-text: #201e1d; + --color-divider: color-mix(in srgb, #201e1d 16%, transparent); + --on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */ + + /* Primary accent — Grape (hue 300) */ + --color-accent: #5a358c; /* oklch(0.42 0.14 300) */ + --color-accent-100: #f8f1ff; + --color-accent-200: #eedfff; + --color-accent-300: #dac3ff; + --color-accent-400: #c1a1f9; + --color-accent-500: #a47ee1; + --color-accent-600: #865fc0; + --color-accent-700: #68449b; /* accent-colored small text on light grounds */ + --color-accent-800: #4a2d73; + --color-accent-900: #2d1a49; + + /* Second accent — Teal blue (hue 215) */ + --color-accent-2: #29889c; /* oklch(0.58 0.09 215) */ + --color-accent-2-100: #e0fbff; + --color-accent-2-200: #bbf3ff; + --color-accent-2-300: #88e0f6; + --color-accent-2-400: #3fc7e4; + --color-accent-2-500: #00a9ca; + --color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */ + --color-accent-2-700: #006b88; + --color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */ + --color-accent-2-900: #002f3e; + + /* Neutrals (unchanged from Organic) */ + --color-neutral-100: #f9f4ed; + --color-neutral-200: #eee7db; + --color-neutral-300: #dcd3c4; + --color-neutral-400: #c0b6a5; + --color-neutral-500: #a19786; + --color-neutral-600: #82796a; + --color-neutral-700: #645c50; + --color-neutral-800: #474238; + --color-neutral-900: #2e2b25; + + /* Type */ + --font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */ + --font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */ + --font-mono: ui-monospace, Menlo, monospace; + + /* Radii */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 28px; + --radius-card: 32px; /* mobile hero cards */ + --radius-pill: 999px; /* buttons, inputs, tags, nav */ + + /* Spacing (Organic, density 1.1) */ + --space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px; + + /* Elevation */ + --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); + + /* Motion */ + --ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */ + --ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */ + --ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */ +} + +@media (prefers-reduced-motion: reduce) { + :root { --motion-scale: 0; } +} diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts new file mode 100644 index 0000000..3260596 --- /dev/null +++ b/web/src/test/cssRules.ts @@ -0,0 +1,75 @@ +// Reads the CSS Modules' rules straight from the stylesheets Vitest injects +// (css: true). jsdom's getComputedStyle cannot answer two things these +// primitives need checked: it resolves var() colours to transparent, and it +// evaluates only "all" and "screen" media queries, so styles inside +// @media (prefers-reduced-motion: reduce) never apply. + +function cssRules(): CSSRule[] { + return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules)) +} + +function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) { + for (const rule of rules) { + if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue + for (const property of Array.from(rule.style)) { + style.setProperty(property, rule.style.getPropertyValue(property)) + } + } +} + +// The declarations the stylesheets give the element, as written. Read the +// shorthands the CSS uses (background, animation): jsdom does not expand +// animation, nor any shorthand holding a var(). Later rules win; specificity +// is not considered. +export function declaredStyle(element: Element): CSSStyleDeclaration { + const style = document.createElement('div').style + collect(element, cssRules(), style) + return style +} + +// Only the declarations @media (prefers-reduced-motion: reduce) gives the element. +export function reducedMotionStyle(element: Element): CSSStyleDeclaration { + const style = document.createElement('div').style + for (const rule of cssRules()) { + if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) { + collect(element, Array.from(rule.cssRules), style) + } + } + return style +} + +// The @keyframes the element's animation runs. CSS Modules hash keyframe +// names, so this follows the element's own animation-name. +export function animationKeyframes(element: Element): CSSKeyframesRule { + const [name] = declaredStyle(element).animation.split(' ') + const keyframes = cssRules().find( + (rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name, + ) + if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`) + return keyframes +} + +function px(value: string, what: string): number { + if (value === '') return 0 + const match = /^(\d+(?:\.\d+)?)px$/.exec(value) + if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`) + return Number(match[1]) +} + +// The border-box size the element's declared rules give it on one axis: +// width or height, plus padding and border unless box-sizing is border-box. +// jsdom does no layout (getBoundingClientRect is all zeros), so this is the +// way to catch padding that would push a fixed box past its specified size. +export function outerSize(element: Element, axis: 'width' | 'height'): number { + const style = declaredStyle(element) + const size = px(style.getPropertyValue(axis), axis) + if (style.boxSizing === 'border-box') return size + const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom'] + return sides.reduce( + (total, side) => + total + + px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) + + px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`), + size, + ) +} diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts new file mode 100644 index 0000000..9a7a932 --- /dev/null +++ b/web/src/test/fetchStub.ts @@ -0,0 +1,49 @@ +import { vi, type Mock } from 'vitest' +import type { ApiLocation, Me } from '../api/index.ts' + +export const ME: Me = { + name: 'Sam', + email: 'sam@example.com', + household: { label: 'Our kitchen' }, + csrfToken: 'token-1', +} + +export function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +export function errorResponse(status: number, code: string, retryable = false): Response { + return jsonResponse(status, { error: { code, message: `${code} message`, retryable } }) +} + +// stubFetch replaces the global fetch with a mock answered from responses in +// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals. +export function stubFetch(...responses: Response[]): Mock { + const fetchMock = vi.fn() + for (const response of responses) fetchMock.mockResolvedValueOnce(response) + fetchMock.mockImplementation((input, init) => { + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// request returns the method, path and headers of the fetch mock's nth call. +export function request(fetchMock: Mock, n: number) { + const call = fetchMock.mock.calls[n] + if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`) + const [input, init] = call + return { + path: String(input), + method: init?.method, + credentials: init?.credentials, + headers: new Headers(init?.headers), + body: init?.body, + } +} + +// fakeLocation stands in for window.location on https://mealquest.test, +// recording navigations. +export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } { + return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() } +} diff --git a/web/src/test/hitArea.ts b/web/src/test/hitArea.ts new file mode 100644 index 0000000..fa0f903 --- /dev/null +++ b/web/src/test/hitArea.ts @@ -0,0 +1,26 @@ +import { expect } from 'vitest' + +// Minimum tap target from the handoff's accessibility rules. +export const MIN_HIT_AREA = 44 + +function px(value: string): number { + return value.endsWith('px') ? Number.parseFloat(value) : 0 +} + +// The tap target an element's stylesheet guarantees: the larger of its +// declared size and declared minimum size, per axis. jsdom does no layout, so +// this reads the CSS Modules' computed values (Vitest runs with css: true). +// A dimension the CSS leaves unsized counts as 0 and fails the check. +export function hitArea(element: Element): { width: number; height: number } { + const style = getComputedStyle(element) + return { + width: Math.max(px(style.width), px(style.minWidth)), + height: Math.max(px(style.height), px(style.minHeight)), + } +} + +export function expectMinHitArea(element: Element) { + const { width, height } = hitArea(element) + expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA) + expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA) +} diff --git a/web/src/test/matchMedia.ts b/web/src/test/matchMedia.ts new file mode 100644 index 0000000..a4312c3 --- /dev/null +++ b/web/src/test/matchMedia.ts @@ -0,0 +1,51 @@ +import { act } from '@testing-library/react' + +type Listener = (event: MediaQueryListEvent) => void + +// Replaces window.matchMedia with one that answers min-width / max-width +// queries for a pretend viewport width, and prefers-reduced-motion: reduce +// with reducedMotion (jsdom has no matchMedia and no layout). Returns a +// function that resizes the pretend viewport and notifies every query whose +// answer changed. +export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void { + let width = initialWidth + const lists: { list: MediaQueryList; listeners: Set }[] = [] + + function evaluate(query: string): boolean { + if (query === '(prefers-reduced-motion: reduce)') return reducedMotion + const min = /\(min-width:\s*(\d+)px\)/.exec(query) + const max = /\(max-width:\s*(\d+)px\)/.exec(query) + if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`) + return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1])) + } + + window.matchMedia = (query: string) => { + const listeners = new Set() + const list = { + media: query, + get matches() { + return evaluate(query) + }, + onchange: null, + addEventListener: (_type: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener), + addListener: (listener: Listener) => listeners.add(listener), + removeListener: (listener: Listener) => listeners.delete(listener), + dispatchEvent: () => true, + } as unknown as MediaQueryList + lists.push({ list, listeners }) + return list + } + + return (next: number) => { + const before = lists.map(({ list }) => list.matches) + width = next + act(() => { + lists.forEach(({ list, listeners }, i) => { + if (list.matches === before[i]) return + const event = { matches: list.matches, media: list.media } as MediaQueryListEvent + listeners.forEach((listener) => listener(event)) + }) + }) + } +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..e87ca3f --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) diff --git a/web/src/ui/Button.module.css b/web/src/ui/Button.module.css new file mode 100644 index 0000000..8b9de26 --- /dev/null +++ b/web/src/ui/Button.module.css @@ -0,0 +1,63 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-width: 44px; + min-height: 48px; + padding: 0 22px; + border: 1.5px solid transparent; + border-radius: var(--radius-pill); + font-family: var(--font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft); +} + +.button:active:not(:disabled) { + transform: scale(0.97); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.primary { + background: var(--color-accent); + color: var(--on-accent); +} + +.primary:hover:not(:disabled), +.primary:active:not(:disabled) { + background: var(--color-accent-800); +} + +.outline { + background: transparent; + border-color: var(--color-neutral-300); + color: var(--color-text); +} + +.outline:hover:not(:disabled), +.outline:active:not(:disabled) { + background: var(--color-neutral-200); +} + +.dark { + background: var(--color-text); + color: var(--color-bg); +} + +.dark:hover:not(:disabled), +.dark:active:not(:disabled) { + background: var(--color-neutral-800); +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } +} diff --git a/web/src/ui/Button.test.tsx b/web/src/ui/Button.test.tsx new file mode 100644 index 0000000..4797e88 --- /dev/null +++ b/web/src/ui/Button.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { Button } from './Button.tsx' +import styles from './Button.module.css' + +describe('Button', () => { + it('is a button that defaults to type="button" and the primary variant', () => { + render() + const button = screen.getByRole('button', { name: 'Save changes' }) + expect(button).toHaveAttribute('type', 'button') + expect(button).toHaveClass(styles.button!, styles.primary!) + }) + + it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => { + render() + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toHaveClass(styles[variant]!) + expectMinHitArea(button) + }) + + it('declares the 100 ms pressed scale transition', () => { + render() + expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms') + }) + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn() + render( + , + ) + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + it('forwards the ref, rest props and an explicit type', () => { + const ref = createRef() + render( + , + ) + const button = screen.getByRole('button', { name: 'Send' }) + expect(ref.current).toBe(button) + expect(button).toHaveAttribute('type', 'submit') + expect(button).toHaveAttribute('aria-describedby', 'hint') + }) +}) diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..4a58825 --- /dev/null +++ b/web/src/ui/Button.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Button.module.css' + +export type ButtonVariant = 'primary' | 'outline' | 'dark' + +export type ButtonProps = ComponentPropsWithRef<'button'> & { + variant?: ButtonVariant +} + +// Pill button. Primary is grape with --on-accent text; outline and dark are the +// secondary treatments from the handoff. `type` defaults to "button" so a +// Button inside a form never submits by accident. +export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) { + const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ') + return + )} + + ) +} diff --git a/web/src/ui/EmptyState.module.css b/web/src/ui/EmptyState.module.css new file mode 100644 index 0000000..c9ef4f1 --- /dev/null +++ b/web/src/ui/EmptyState.module.css @@ -0,0 +1,30 @@ +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + color: var(--color-text); + text-align: center; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.15; +} + +.body { + margin: 0; + max-width: 32ch; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.action { + margin-top: var(--space-2); +} diff --git a/web/src/ui/EmptyState.test.tsx b/web/src/ui/EmptyState.test.tsx new file mode 100644 index 0000000..c861752 --- /dev/null +++ b/web/src/ui/EmptyState.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { EmptyState } from './EmptyState.tsx' + +describe('EmptyState', () => { + it('shows a Bagel title and body without an action by default', () => { + render(Swipe Try it on a Side quest to save it here.) + const title = screen.getByRole('heading', { name: 'Quest log is empty' }) + expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)') + expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders the optional primary action and calls its handler', () => { + const onClick = vi.fn() + render( + + Add the dinners you make most. + , + ) + const action = screen.getByRole('button', { name: 'Add a dinner' }) + expectMinHitArea(action) + fireEvent.click(action) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/ui/EmptyState.tsx b/web/src/ui/EmptyState.tsx new file mode 100644 index 0000000..0c0ecab --- /dev/null +++ b/web/src/ui/EmptyState.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { Button } from './Button.tsx' +import styles from './EmptyState.module.css' + +export type EmptyStateAction = { + label: string + onClick: () => void +} + +export type EmptyStateProps = { + title: string + children: ReactNode + // Optional primary action, rendered as a primary Button. + action?: EmptyStateAction +} + +// Bagel title, body text and an optional primary action. +export function EmptyState({ title, children, action }: EmptyStateProps) { + return ( +
+

{title}

+

{children}

+ {action && ( + + )} +
+ ) +} diff --git a/web/src/ui/ErrorBanner.module.css b/web/src/ui/ErrorBanner.module.css new file mode 100644 index 0000000..e0ffc8d --- /dev/null +++ b/web/src/ui/ErrorBanner.module.css @@ -0,0 +1,16 @@ +.banner { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--color-neutral-100); +} + +.message { + flex: 1; + margin: 0; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/ui/ErrorBanner.test.tsx b/web/src/ui/ErrorBanner.test.tsx new file mode 100644 index 0000000..811b5e5 --- /dev/null +++ b/web/src/ui/ErrorBanner.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { declaredStyle } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { ErrorBanner } from './ErrorBanner.tsx' + +describe('ErrorBanner', () => { + it('announces the message and calls onRetry from the Retry button', () => { + const onRetry = vi.fn() + render(Couldn't load the Supply run.) + expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.") + const retry = screen.getByRole('button', { name: 'Retry' }) + expectMinHitArea(retry) + fireEvent.click(retry) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('uses a caller-supplied retry label', () => { + render( + {}} retryLabel="Rebuild week"> + The plan didn't save. + , + ) + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() + }) + + it('is a neutral-100 card with grape-700 text', () => { + render( {}}>Offline) + expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)') + expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)') + }) +}) diff --git a/web/src/ui/ErrorBanner.tsx b/web/src/ui/ErrorBanner.tsx new file mode 100644 index 0000000..1274bf9 --- /dev/null +++ b/web/src/ui/ErrorBanner.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' +import { Button } from './Button.tsx' +import { Card } from './Card.tsx' +import styles from './ErrorBanner.module.css' + +export type ErrorBannerProps = { + // What went wrong, in the caller's words. + children: ReactNode + onRetry: () => void + retryLabel?: string +} + +// Muted neutral-100 card with grape-700 text and a Retry button. +export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) { + return ( + +

{children}

+ +
+ ) +} diff --git a/web/src/ui/Icon.module.css b/web/src/ui/Icon.module.css new file mode 100644 index 0000000..1c297c1 --- /dev/null +++ b/web/src/ui/Icon.module.css @@ -0,0 +1,15 @@ +.icon { + display: inline-block; + flex: none; + vertical-align: middle; +} + +.nav { + width: 22px; + height: 22px; +} + +.inline { + width: 16px; + height: 16px; +} diff --git a/web/src/ui/Icon.test.tsx b/web/src/ui/Icon.test.tsx new file mode 100644 index 0000000..195b11a --- /dev/null +++ b/web/src/ui/Icon.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react' +import { House } from 'lucide-react' +import { describe, expect, it } from 'vitest' +import { Icon } from './Icon.tsx' + +function svg(container: HTMLElement) { + return container.querySelector('svg')! +} + +describe('Icon', () => { + it('is hidden and unnamed without a label', () => { + const { container } = render() + expect(svg(container)).toHaveAttribute('aria-hidden', 'true') + expect(svg(container)).not.toHaveAttribute('aria-label') + expect(screen.queryByRole('img')).toBeNull() + }) + + it('is a named image when given a label', () => { + const { container } = render() + expect(svg(container)).not.toHaveAttribute('aria-hidden') + expect(screen.getByRole('img', { name: 'Home' })).toBe(svg(container)) + }) + + it('draws at stroke width 2.75', () => { + const { container } = render() + expect(svg(container)).toHaveAttribute('stroke-width', '2.75') + }) + + it('is 22 px at the nav size', () => { + const { container } = render() + const style = getComputedStyle(svg(container)) + expect([style.width, style.height]).toEqual(['22px', '22px']) + }) + + it('is 16 px inline by default', () => { + const { container } = render() + const style = getComputedStyle(svg(container)) + expect([style.width, style.height]).toEqual(['16px', '16px']) + }) + + it('merges a caller className', () => { + const { container } = render() + expect(svg(container)).toHaveClass('extra') + }) +}) diff --git a/web/src/ui/Icon.tsx b/web/src/ui/Icon.tsx new file mode 100644 index 0000000..c3b5919 --- /dev/null +++ b/web/src/ui/Icon.tsx @@ -0,0 +1,21 @@ +import type { LucideIcon } from 'lucide-react' +import styles from './Icon.module.css' + +export type IconSize = 'nav' | 'inline' + +export type IconProps = { + // A lucide-react icon component, e.g. `import { House } from 'lucide-react'`. + icon: LucideIcon + // nav = 22 px (bottom bar, sidebar); inline = 16 px (beside text). + size?: IconSize + // Accessible name. Without one the icon is decorative and hidden. + label?: string + className?: string +} + +// Lucide icon at the handoff's stroke width 2.75, sized by the stylesheet. +export function Icon({ icon: LucideGlyph, size = 'inline', label, className }: IconProps) { + const classes = [styles.icon, styles[size], className].filter(Boolean).join(' ') + const a11y = label === undefined ? { 'aria-hidden': true } : { role: 'img', 'aria-label': label } + return +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css new file mode 100644 index 0000000..7dd0c66 --- /dev/null +++ b/web/src/ui/IconButton.module.css @@ -0,0 +1,74 @@ +/* The button is the hit box; .face is the visible circle. A 40 px face sits in + a 44 px hit box whose negative margin keeps the 40 px layout footprint. */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: inherit; + cursor: pointer; +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.size40 { + width: 44px; + height: 44px; + margin: -2px; +} + +.size62 { + width: 62px; + height: 62px; +} + +.face { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); + transition: transform 100ms var(--ease-out-soft); +} + +.size40 .face { + width: 40px; + height: 40px; +} + +.size62 .face { + width: 62px; + height: 62px; + box-shadow: var(--shadow-md); +} + +.button:active:not(:disabled) .face { + transform: scale(0.97); +} + +.surface { + background: var(--color-surface); + color: var(--color-text); +} + +.accent { + background: var(--color-accent); + color: var(--on-accent); +} + +.teal { + background: var(--color-accent-2-600); + color: var(--on-accent); +} + +@media (prefers-reduced-motion: reduce) { + .face { + transition: none; + } +} diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx new file mode 100644 index 0000000..0341c1a --- /dev/null +++ b/web/src/ui/IconButton.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { hitArea, expectMinHitArea } from '../test/hitArea.ts' +import { IconButton } from './IconButton.tsx' + +describe('IconButton', () => { + it('is named by its label, not its icon', () => { + const onClick = vi.fn() + render( + + + , + ) + const button = screen.getByRole('button', { name: 'Notifications' }) + expect(button).toHaveAttribute('type', 'button') + fireEvent.click(button) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a 44 px hit area around the 40 px circle', () => { + render() + const button = screen.getByRole('button', { name: 'Back' }) + expectMinHitArea(button) + const face = button.firstElementChild! + expect(hitArea(face)).toEqual({ width: 40, height: 40 }) + }) + + it('is 62 px at the large size', () => { + render() + const button = screen.getByRole('button', { name: 'Try it' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 62, height: 62 }) + }) +}) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx new file mode 100644 index 0000000..1a5ab73 --- /dev/null +++ b/web/src/ui/IconButton.tsx @@ -0,0 +1,33 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './IconButton.module.css' + +export type IconButtonSize = 40 | 62 +export type IconButtonTone = 'surface' | 'accent' | 'teal' + +export type IconButtonProps = Omit, 'aria-label'> & { + // Accessible name; an icon alone never names a button. + label: string + size?: IconButtonSize + tone?: IconButtonTone +} + +// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS); +// the 62 px size is the round action button with --shadow-md. +export function IconButton({ + label, + size = 40, + tone = 'surface', + type = 'button', + className, + children, + ...rest +}: IconButtonProps) { + const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ') + return ( + + ) +} diff --git a/web/src/ui/ListRow.module.css b/web/src/ui/ListRow.module.css new file mode 100644 index 0000000..f182b5b --- /dev/null +++ b/web/src/ui/ListRow.module.css @@ -0,0 +1,69 @@ +.row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: 44px; + padding: 14px 16px; + color: var(--color-text); + text-align: left; +} + +.interactive { + padding: 0; + border-radius: var(--radius-md); +} + +.interactive:hover { + background: var(--color-neutral-200); +} + +.main { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1 1 auto; + min-width: 44px; + min-height: 44px; + padding: 14px 16px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.interactive > .trailing { + padding-right: 16px; +} + +.leading, +.trailing { + display: inline-flex; + align-items: center; + flex: none; +} + +.body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.title { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-neutral-700); +} diff --git a/web/src/ui/ListRow.test.tsx b/web/src/ui/ListRow.test.tsx new file mode 100644 index 0000000..f1e9108 --- /dev/null +++ b/web/src/ui/ListRow.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { ListRow } from './ListRow.tsx' +import { Toggle } from './Toggle.tsx' + +describe('ListRow', () => { + it('renders leading, title, meta and trailing slots without being interactive', () => { + render( + icon} + title="Notifications" + meta="Push and Google Chat" + trailing={chevron} + />, + ) + for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) { + expect(screen.getByText(text)).toBeInTheDocument() + } + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('is a single button with a ≥ 44 px hit area when it has onClick', () => { + const onClick = vi.fn() + render() + const row = screen.getByRole('button', { name: '10 new side quests to swipe' }) + expect(row).toHaveAttribute('type', 'button') + expectMinHitArea(row) + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a trailing toggle outside the row button so each control fires only its own action', () => { + const onClick = vi.fn() + const onCheckedChange = vi.fn() + render( + } + onClick={onClick} + />, + ) + const row = screen.getByRole('button', { name: /^Dinner reminders/ }) + const toggle = screen.getByRole('switch', { name: 'Dinner reminders' }) + expect(row).not.toContainElement(toggle) + expect(row.querySelector('button')).toBeNull() + + fireEvent.click(toggle) + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true) + expect(onClick).not.toHaveBeenCalled() + + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + expect(onCheckedChange).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/ui/ListRow.tsx b/web/src/ui/ListRow.tsx new file mode 100644 index 0000000..8004bf2 --- /dev/null +++ b/web/src/ui/ListRow.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import styles from './ListRow.module.css' + +export type ListRowProps = { + title: ReactNode + meta?: ReactNode + // Leading slot: icon, photo or avatar. + leading?: ReactNode + // Trailing slot: chevron, toggle, count or action. + trailing?: ReactNode + // When set, the leading slot, title and meta form one button. The trailing + // slot stays a sibling outside it, so a trailing toggle or action is never + // nested in the row's button and never triggers the row's action. + onClick?: () => void + className?: string +} + +export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) { + const main = ( + <> + {leading && {leading}} + + {title} + {meta && {meta}} + + + ) + const trailingSlot = trailing && {trailing} + if (onClick) { + return ( +
+ + {trailingSlot} +
+ ) + } + return ( +
+ {main} + {trailingSlot} +
+ ) +} diff --git a/web/src/ui/Photo.module.css b/web/src/ui/Photo.module.css new file mode 100644 index 0000000..8dc45d9 --- /dev/null +++ b/web/src/ui/Photo.module.css @@ -0,0 +1,26 @@ +/* Radius and inset come from the --photo-radius and --photo-inset custom + properties the component sets from its props. Size it with className. */ +.frame { + display: block; + margin: var(--photo-inset); + border-radius: var(--photo-radius); + overflow: hidden; +} + +.photo { + object-fit: cover; + filter: saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94); +} + +.placeholder { + display: flex; + align-items: center; + justify-content: center; + background: repeating-linear-gradient( + 135deg, + var(--color-neutral-300) 0 8px, + var(--color-neutral-200) 8px 16px + ); + color: var(--color-neutral-700); + font: 11px var(--font-mono); +} diff --git a/web/src/ui/Photo.test.tsx b/web/src/ui/Photo.test.tsx new file mode 100644 index 0000000..b4ed27a --- /dev/null +++ b/web/src/ui/Photo.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { declaredStyle } from '../test/cssRules.ts' +import { Photo } from './Photo.tsx' + +const id = '01JABCDEFGHJKMNPQRSTVWXYZ0' + +describe('Photo', () => { + it('offers the 400 and 1200 renditions as a srcset', () => { + render() + const img = screen.getByRole('img', { name: 'Korean beef bowls' }) + expect(img).toHaveAttribute('src', `/media/photos/${id}-400.jpg`) + expect(img.getAttribute('srcset')!.split(', ')).toEqual([ + `/media/photos/${id}-400.jpg 400w`, + `/media/photos/${id}-1200.jpg 1200w`, + ]) + }) + + it('loads lazily with the dinner name as alt text', () => { + render() + const img = screen.getByRole('img') + expect(img).toHaveAttribute('loading', 'lazy') + expect(img).toHaveAttribute('alt', 'Pizza night') + }) + + it('applies the washed filter', () => { + render() + expect(declaredStyle(screen.getByRole('img')).filter).toBe( + 'saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)', + ) + }) + + it('takes its radius and inset from props', () => { + render() + const img = screen.getByRole('img') + const style = declaredStyle(img) + expect(style.borderRadius).toBe('var(--photo-radius)') + expect(style.margin).toBe('var(--photo-inset)') + expect(img.style.getPropertyValue('--photo-radius')).toBe('var(--radius-lg)') + expect(img.style.getPropertyValue('--photo-inset')).toBe('10px') + }) + + it('shows the striped placeholder when there is no photo', () => { + const { container } = render() + expect(container.querySelector('img')).toBeNull() + const placeholder = screen.getByRole('img', { name: 'Beef tacos' }) + expect(placeholder).toHaveTextContent('dish photo') + expect(declaredStyle(placeholder).background.replace(/\s+/g, ' ')).toBe( + 'repeating-linear-gradient( 135deg, var(--color-neutral-300) 0 8px, var(--color-neutral-200) 8px 16px )', + ) + }) + + it('falls back to the placeholder when the image fails to load', () => { + const { container } = render() + fireEvent.error(container.querySelector('img')!) + expect(container.querySelector('img')).toBeNull() + expect(screen.getByRole('img', { name: 'Beef tacos' })).toHaveTextContent('dish photo') + }) + + it('tries again when given a new id after a failure', () => { + const { container, rerender } = render() + fireEvent.error(container.querySelector('img')!) + rerender() + expect(container.querySelector('img')).toHaveAttribute('src', '/media/photos/01JZZZZZZZZZZZZZZZZZZZZZZZ-400.jpg') + }) +}) diff --git a/web/src/ui/Photo.tsx b/web/src/ui/Photo.tsx new file mode 100644 index 0000000..476fc4a --- /dev/null +++ b/web/src/ui/Photo.tsx @@ -0,0 +1,49 @@ +import { useState, type CSSProperties } from 'react' +import styles from './Photo.module.css' + +export type PhotoProps = { + // Photo id; the server stores /media/photos/{id}-400.jpg and -1200.jpg. + // Without one the striped "dish photo" placeholder is shown. + id?: string + // The dinner name, used as the alt text. + name: string + // Corner radius and the margin from the containing card edge, as CSS lengths. + radius?: string + inset?: string + // The rendered width hint for srcset selection. + sizes?: string + className?: string + style?: CSSProperties +} + +// Dinner photo through the Organic "washed" filter. Falls back to the +// striped placeholder when there is no photo or it fails to load. +export function Photo({ id, name, radius = '24px', inset = '0', sizes = '100vw', className, style }: PhotoProps) { + // The id whose image failed, so a new id gets a fresh attempt. + const [failedId, setFailedId] = useState() + const frame = { '--photo-radius': radius, '--photo-inset': inset, ...style } as CSSProperties + + if (id === undefined || failedId === id) { + const classes = [styles.frame, styles.placeholder, className].filter(Boolean).join(' ') + return ( +
+ +
+ ) + } + + const base = `/media/photos/${encodeURIComponent(id)}` + const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ') + return ( + {name} setFailedId(id)} + /> + ) +} diff --git a/web/src/ui/Segmented.module.css b/web/src/ui/Segmented.module.css new file mode 100644 index 0000000..3d94baa --- /dev/null +++ b/web/src/ui/Segmented.module.css @@ -0,0 +1,35 @@ +.track { + display: flex; + gap: 4px; + padding: 4px; + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.option { + flex: 1 1 0; + min-width: 44px; + min-height: 44px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft); +} + +.selected { + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-sm); +} + +@media (prefers-reduced-motion: reduce) { + .option { + transition: none; + } +} diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx new file mode 100644 index 0000000..c107a1e --- /dev/null +++ b/web/src/ui/Segmented.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { Segmented } from './Segmented.tsx' +import styles from './Segmented.module.css' + +const options = [ + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, + { value: 'year', label: 'Year' }, +] as const + +type Period = (typeof options)[number]['value'] + +function Harness({ initial = 'week' }: { initial?: Period }) { + const [value, setValue] = useState(initial) + return +} + +function radio(name: string) { + return screen.getByRole('radio', { name }) +} + +describe('Segmented', () => { + it('is a named radiogroup of radios with one checked', () => { + render() + expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument() + expect(screen.getAllByRole('radio')).toHaveLength(3) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveAttribute('aria-checked', 'false') + expect(radio('Week')).toHaveClass(styles.selected!) + }) + + it('has a single tab stop on the selected option', () => { + render() + expect(radio('Month')).toHaveAttribute('tabindex', '0') + expect(radio('Week')).toHaveAttribute('tabindex', '-1') + expect(radio('Year')).toHaveAttribute('tabindex', '-1') + }) + + it('moves selection and focus with the arrow keys, wrapping at the ends', () => { + render() + radio('Week').focus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + + fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveFocus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + expect(radio('Year')).toHaveAttribute('aria-checked', 'false') + }) + + it('jumps to the first and last option with Home and End', () => { + render() + fireEvent.keyDown(radio('Month'), { key: 'End' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(radio('Year'), { key: 'Home' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + }) + + it('selects an option on click', () => { + render() + fireEvent.click(radio('Year')) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveAttribute('aria-checked', 'false') + }) + + it('gives every option a ≥ 44 px hit area', () => { + render() + for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) + }) + + it('fails fast when the value is not one of the options', () => { + expect(() => + render( {}} />), + ).toThrow('value "decade" is not one of its options') + }) +}) diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx new file mode 100644 index 0000000..bd53b27 --- /dev/null +++ b/web/src/ui/Segmented.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent } from 'react' +import styles from './Segmented.module.css' + +export type SegmentedOption = { + value: T + label: string +} + +export type SegmentedProps = { + // Accessible name of the radiogroup. + label: string + options: readonly SegmentedOption[] + value: T + onValueChange: (value: T) => void + className?: string +} + +const step: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } + +// Segmented control with radiogroup semantics: one tab stop (the selected +// option); arrow keys move the selection and focus, wrapping at the ends; +// Home and End jump to the first and last option. +export function Segmented({ label, options, value, onValueChange, className }: SegmentedProps) { + const refs = useRef<(HTMLButtonElement | null)[]>([]) + const selected = options.findIndex((option) => option.value === value) + if (selected === -1) { + throw new Error(`Segmented "${label}": value "${value}" is not one of its options`) + } + + function select(index: number) { + const option = options[index] + if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`) + onValueChange(option.value) + refs.current[index]?.focus() + } + + function onKeyDown(event: KeyboardEvent) { + const delta = step[event.key] + let next: number + if (delta !== undefined) next = (selected + delta + options.length) % options.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = options.length - 1 + else return + event.preventDefault() + select(next) + } + + return ( +
+ {options.map((option, index) => { + const checked = index === selected + return ( + + ) + })} +
+ ) +} diff --git a/web/src/ui/Sheet.module.css b/web/src/ui/Sheet.module.css new file mode 100644 index 0000000..dcb5cd8 --- /dev/null +++ b/web/src/ui/Sheet.module.css @@ -0,0 +1,57 @@ +.root { + position: fixed; + inset: 0; + z-index: 100; +} + +.scrim { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent); + animation: fade 320ms var(--ease-out-soft); +} + +.panel { + position: absolute; + right: 0; + bottom: 0; + left: 0; + max-height: 90dvh; + overflow-y: auto; + padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom)); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-lg); + transform: translateY(0); + animation: rise 320ms var(--ease-out-soft); +} + +.panel:focus { + outline: none; +} + +@keyframes rise { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .scrim, + .panel { + animation: none; + } +} diff --git a/web/src/ui/Sheet.test.tsx b/web/src/ui/Sheet.test.tsx new file mode 100644 index 0000000..8e8cc27 --- /dev/null +++ b/web/src/ui/Sheet.test.tsx @@ -0,0 +1,267 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import '../styles/tokens.css' +import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts' +import { Sheet } from './Sheet.tsx' +import styles from './Sheet.module.css' + +function Harness({ withControls = true }: { withControls?: boolean }) { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)}> +

Grouped by dinner

+ {withControls && ( + <> + + + + )} +
+ + ) +} + +function openSheet() { + const opener = screen.getByRole('button', { name: 'Open Supply run' }) + opener.focus() + fireEvent.click(opener) + return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) } +} + +function scrim() { + const element = document.querySelector(`.${styles.scrim}`) + if (!element) throw new Error('scrim not rendered') + return element +} + +describe('Sheet', () => { + it('renders nothing while closed', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('is a labelled modal dialog', () => { + render() + const { dialog } = openSheet() + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + it('accepts aria-labelledby as its accessible name', () => { + render( + <> +

Quest log

+ {}}> +

Nothing yet

+
+ , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument() + }) + + it('moves focus to the first control and restores it to the opener on close', () => { + render() + const { opener } = openSheet() + expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus() + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(opener).toHaveFocus() + }) + + it('focuses the dialog itself when it has no focusable control', () => { + render() + const { dialog } = openSheet() + expect(dialog).toHaveFocus() + }) + + it('skips controls that cannot take keyboard focus when moving focus in', () => { + render( + {}}> + + + + + + + , + ) + expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus() + }) + + it('focuses the dialog itself when every control is hidden', () => { + render( + {}}> + + , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus() + }) + + it('does not stop the Tab trap on a hidden control', () => { + render( + {}}> + + + + + , + ) + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('does not stop the Tab trap on a control disabled by its fieldset', () => { + render( + {}}> +
+ +
+ + +
+ +
+
, + ) + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + expect(first).toHaveFocus() + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('wraps Tab and Shift+Tab inside the sheet', () => { + render() + openSheet() + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('keeps focus on the dialog when Tab has nowhere to go', () => { + render() + const { dialog } = openSheet() + const tab = fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(tab).toBe(false) // default prevented + expect(dialog).toHaveFocus() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render( + + + , + ) + fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('closes on a scrim click but not on a panel click', () => { + const onClose = vi.fn() + render( + +

Grouped by dinner

+
, + ) + fireEvent.click(screen.getByRole('dialog')) + fireEvent.click(screen.getByText('Grouped by dinner')) + expect(onClose).not.toHaveBeenCalled() + fireEvent.click(scrim()) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('makes the background inert while open and restores it on close', () => { + const { container } = render() + openSheet() + const root = screen.getByRole('dialog').parentElement! + expect(root.parentElement).toBe(document.body) + expect(container).toHaveAttribute('inert') + expect(root).not.toHaveAttribute('inert') + + fireEvent.click(scrim()) + expect(container).not.toHaveAttribute('inert') + }) + + it('leaves an already-inert background element inert after close', () => { + const other = document.createElement('div') + other.setAttribute('inert', '') + document.body.append(other) + try { + render() + openSheet() + fireEvent.click(scrim()) + expect(other).toHaveAttribute('inert') + } finally { + other.remove() + } + }) + + it('rises from 100% to 0 over 320 ms with the soft ease-out', () => { + render() + const { dialog } = openSheet() + expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)']) + const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft') + expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)') + const rise = animationKeyframes(dialog) + expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)') + expect(rise.findRule('to')?.style.transform).toBe('translateY(0)') + }) + + it('fades the scrim in to 50% neutral-900', () => { + render() + openSheet() + const style = declaredStyle(scrim()) + expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)') + expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)']) + const fade = animationKeyframes(scrim()) + expect(fade.findRule('from')?.style.opacity).toBe('0') + expect(fade.findRule('to')?.style.opacity).toBe('1') + }) + + it('opens in place with no rise or fade under reduced motion', () => { + render() + const { dialog } = openSheet() + expect(reducedMotionStyle(dialog).animation).toBe('none') + expect(reducedMotionStyle(scrim()).animation).toBe('none') + expect(declaredStyle(dialog).transform).toBe('translateY(0)') + }) +}) diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx new file mode 100644 index 0000000..4686f3a --- /dev/null +++ b/web/src/ui/Sheet.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import styles from './Sheet.module.css' + +// The dialog's accessible name: exactly one of a label or a labelling element. +type SheetName = + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-labelledby': string; 'aria-label'?: never } + +export type SheetProps = SheetName & { + open: boolean + onClose: () => void + children: ReactNode +} + +const focusableSelector = [ + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', + '[tabindex]', +].join(',') + +// Whether an element can take keyboard focus: being disabled (by its own +// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden +// or inert ancestor (or self), display: none on the way up to the panel, or +// visibility: hidden all rule it out. +function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean { + if (element.matches(':disabled')) return false + if (element.tabIndex < 0) return false + if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false + if (getComputedStyle(element).visibility === 'hidden') return false + for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) { + if (getComputedStyle(node).display === 'none') return false + } + return true +} + +function focusables(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll(focusableSelector)).filter((element) => + isKeyboardFocusable(element, panel), + ) +} + +// Focuses the first control that actually takes focus, or the panel if none does. +function focusFirst(panel: HTMLElement) { + for (const element of focusables(panel)) { + element.focus() + if (document.activeElement === element) return + } + panel.focus() +} + +// Modal bottom sheet. Portalled to so everything else there can be made +// inert while it is open. Focus moves in on open, Tab is trapped inside, and +// focus returns to the opener on close. Escape and a scrim click call onClose. +export function Sheet({ open, onClose, children, ...name }: SheetProps) { + const rootRef = useRef(null) + const panelRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const panel = panelRef.current + if (!open || !root || !panel) return + + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null + const background = Array.from(document.body.children).filter( + (element) => element !== root && !element.hasAttribute('inert'), + ) + for (const element of background) element.setAttribute('inert', '') + focusFirst(panel) + + return () => { + for (const element of background) element.removeAttribute('inert') + opener?.focus() + } + }, [open]) + + if (!open) return null + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.stopPropagation() + onClose() + return + } + if (event.key !== 'Tab' || !panelRef.current) return + const items = focusables(panelRef.current) + const first = items[0] + const last = items[items.length - 1] + if (!first || !last) { + event.preventDefault() + return + } + const active = document.activeElement + if (event.shiftKey && (active === first || active === panelRef.current)) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && active === last) { + event.preventDefault() + first.focus() + } + } + + return createPortal( +
+ , + document.body, + ) +} diff --git a/web/src/ui/Skeleton.module.css b/web/src/ui/Skeleton.module.css new file mode 100644 index 0000000..8647c14 --- /dev/null +++ b/web/src/ui/Skeleton.module.css @@ -0,0 +1,24 @@ +.skeleton { + display: block; + width: 100%; + height: 16px; + border-radius: var(--radius-pill); + background: var(--color-surface); + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} diff --git a/web/src/ui/Skeleton.test.tsx b/web/src/ui/Skeleton.test.tsx new file mode 100644 index 0000000..a588018 --- /dev/null +++ b/web/src/ui/Skeleton.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts' +import { Skeleton } from './Skeleton.tsx' + +function skeleton(container: HTMLElement) { + return container.firstElementChild as HTMLElement +} + +describe('Skeleton', () => { + it('is a hidden pill in --color-surface', () => { + const { container } = render() + const element = skeleton(container) + expect(element).toHaveAttribute('aria-hidden', 'true') + const style = declaredStyle(element) + expect(style.borderRadius).toBe('var(--radius-pill)') + expect(style.background).toBe('var(--color-surface)') + }) + + it('pulses its opacity over 1.2 s', () => { + const { container } = render() + expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite']) + expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45') + }) + + it('does not pulse under reduced motion', () => { + const { container } = render() + expect(reducedMotionStyle(skeleton(container)).animation).toBe('none') + }) + + it('merges a caller className and forwards style', () => { + const { container } = render() + expect(skeleton(container)).toHaveClass('line') + expect(skeleton(container).style.width).toBe('60%') + }) +}) diff --git a/web/src/ui/Skeleton.tsx b/web/src/ui/Skeleton.tsx new file mode 100644 index 0000000..75e2458 --- /dev/null +++ b/web/src/ui/Skeleton.tsx @@ -0,0 +1,12 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Skeleton.module.css' + +export type SkeletonProps = Omit, 'children'> + +// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse. +// Full width and 16 px tall by default; size it with className or style. +// Hidden from assistive technology — announce loading on the container. +export function Skeleton({ className, ...rest }: SkeletonProps) { + const classes = [styles.skeleton, className].filter(Boolean).join(' ') + return