Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10257625f | ||
|
|
49743f4c83 | ||
|
|
d1ab3932f8 | ||
|
|
9f4537d0cb | ||
|
|
ec13423b6f | ||
|
|
c28dff18cd | ||
|
|
85569b451f | ||
|
|
f0b2696059 |
@@ -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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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).
|
||||||
|
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 build
|
||||||
|
run: make build
|
||||||
@@ -8,6 +8,11 @@ deploy/secrets/
|
|||||||
# Build artifacts and dependencies
|
# Build artifacts and dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
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
|
# Local databases
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -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/<feature-uuid>-<slug>`, branched from `dev`.
|
||||||
|
- **Task branches:** `task/<slug>_<task-uuid>`, 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
|
||||||
|
<https://ctxd.cubecraftcreations.com>. Project documents: `CONTEXT.MD`
|
||||||
|
(architecture), `DECISIONS.MD` (decision log, e.g. D-021), `RUNBOOKS.MD`,
|
||||||
|
`PROMPTS.MD`, `GLOSSARY.MD`.
|
||||||
|
- **Work tracking:** Maestro at <https://maestro.cubecraftlabs.com>,
|
||||||
|
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. |
|
||||||
+42
@@ -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"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
.PHONY: dev test go-test web-test build web embed check-embed-clean docker
|
||||||
|
|
||||||
|
# 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) .
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 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"
|
||||||
|
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, and secrets are read only from files named
|
||||||
|
by `*_FILE` variables.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
+6
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: server.NewRouter(logger, version),
|
Handler: router,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>MealQuest</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
|
||||||
|
<p>MealQuest web assets have not been built.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
indexBody = "<!doctype html><title>fixture index</title>"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,44 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// reservedPrefixes are owned by the API router and are never answered with the
|
||||||
|
// SPA's index.html.
|
||||||
|
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||||
|
|
||||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||||
// GET /health.
|
// GET /health; assets is the web build served for every other GET.
|
||||||
func NewRouter(logger *slog.Logger, version string) http.Handler {
|
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
||||||
|
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 := chi.NewRouter()
|
||||||
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
|
r.NotFound(notFound)
|
||||||
|
|
||||||
r.Get("/health", health(logger, version))
|
r.Get("/health", health(logger, version))
|
||||||
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
return r
|
r.NotFound(notFound)
|
||||||
|
})
|
||||||
|
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
||||||
|
// handler 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 {
|
type errorBody struct {
|
||||||
|
|||||||
+112
-15
@@ -7,14 +7,35 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fixtureIndex = "<!doctype html><title>fixture index</title>"
|
||||||
|
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()
|
||||||
|
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, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
|
||||||
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
|
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/health")
|
resp, err := http.Get(srv.URL + "/health")
|
||||||
@@ -42,9 +63,8 @@ func TestHealth(t *testing.T) {
|
|||||||
func TestHealthRejectsOtherMethods(t *testing.T) {
|
func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||||
|
|
||||||
if rec.Code != http.StatusMethodNotAllowed {
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
t.Errorf("status = %d, want 405", rec.Code)
|
t.Errorf("status = %d, want 405", rec.Code)
|
||||||
@@ -68,21 +88,98 @@ func TestHealthRejectsOtherMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownPathNotFound(t *testing.T) {
|
func TestReservedPathsNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
router := newTestRouter(t, "dev")
|
||||||
rec := httptest.NewRecorder()
|
tests := []struct {
|
||||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
|
method string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api"},
|
||||||
|
{http.MethodGet, "/api/unknown"},
|
||||||
|
{http.MethodPost, "/api/unknown"},
|
||||||
|
{http.MethodGet, "/auth/callback"},
|
||||||
|
{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()
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
rec := httptest.NewRecorder()
|
||||||
t.Errorf("status = %d, want 404", rec.Code)
|
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
}
|
||||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
||||||
|
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"},
|
||||||
}
|
}
|
||||||
const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
for _, tt := range tests {
|
||||||
if got := rec.Body.String(); got != want {
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
t.Errorf("body = %s, want %s", got, want)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user