From e3d8c4d73d08df49d27f979344aa1f60d568b6dd Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:20:34 -0400 Subject: [PATCH 01/33] Feature 01 task 1: Go module, fail-fast config and /health (#2) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitignore | 11 ++ cmd/server/main.go | 80 +++++++++ cmd/server/main_test.go | 34 ++++ go.mod | 5 + go.sum | 2 + internal/config/config.go | 241 ++++++++++++++++++++++++++ internal/config/config_test.go | 303 +++++++++++++++++++++++++++++++++ internal/server/server.go | 67 ++++++++ internal/server/server_test.go | 88 ++++++++++ 9 files changed, 831 insertions(+) create mode 100644 cmd/server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/.gitignore b/.gitignore index ee38b4e..7523873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ .DS_Store design/.DS_Store + +# Secrets and local environment +.env* +deploy/secrets/ + +# Build artifacts and dependencies +node_modules +dist + +# Local databases +*.db diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e0d649b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,80 @@ +// Command server runs the MealQuest HTTP service. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" +) + +// version is set at build time with -ldflags "-X main.version=". +var version = "dev" + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + logger := slog.New(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() + + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: server.NewRouter(logger, version), + 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 +} + +// 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(), + "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/go.mod b/go.mod new file mode 100644 index 0000000..37e681e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest + +go 1.25 + +require github.com/go-chi/chi/v5 v5.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bdb0eaa --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5673595 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,241 @@ +// 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" +) + +// 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" +) + +// Config is the validated service configuration. +type Config struct { + HTTPAddr string + PublicURL *url.URL + DataDir string + ShutdownTimeout time.Duration + SecretKey Secret +} + +// 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.httpURL(EnvPublicURL), + DataDir: r.dir(EnvDataDir), + ShutdownTimeout: r.duration(EnvShutdownTimeout), + SecretKey: r.secretFile(EnvSecretKeyFile), + } + 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) 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 +} + +func (r *reader) httpURL(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 || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + r.fail(name, "invalid URL (want absolute http:// or https:// URL)") + 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 + } + 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 +} + +// 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..01a184f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,303 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +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") + 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, + } + } + 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: "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, + }, + }, + { + 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: "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: "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 cfg != (Config{}) { + 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) + } + env := map[string]string{ + EnvHTTPAddr: "127.0.0.1:9000", + EnvPublicURL: "http://localhost:9000", + EnvDataDir: dir, + EnvShutdownTimeout: "1m30s", + EnvSecretKeyFile: secretPath, + } + 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()) + } +} + +func TestSecretRedacted(t *testing.T) { + t.Parallel() + + s := Secret{value: "s3cret-value"} + for _, format := range []string{"%v", "%s", "%+v", "%#v"} { + cfg := Config{SecretKey: 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/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..33b0e38 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,67 @@ +// Package server builds the MealQuest HTTP router. +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// NewRouter returns the HTTP handler for the service. version is reported by +// GET /health. +func NewRouter(logger *slog.Logger, version string) http.Handler { + r := chi.NewRouter() + r.Get("/health", health(logger, version)) + r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")) + r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")) + return r +} + +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 { + body, err := json.Marshal(errorBody{Error: apiError{ + Code: code, + Message: message, + }}) + if err != nil { + panic(err) // fixed string and bool fields cannot fail to marshal + } + return func(w http.ResponseWriter, r *http.Request) { + 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) + } + } +} + +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..f8e3cc5 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,88 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealth(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(NewRouter(logger, "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() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + rec := httptest.NewRecorder() + NewRouter(logger, "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 TestUnknownPathNotFound(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + rec := httptest.NewRecorder() + NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", 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) + } + const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}` + if got := rec.Body.String(); got != want { + t.Errorf("body = %s, want %s", got, want) + } +} -- 2.55.0 From f0b2696059da18706ecf39646b7a94fa11b04c49 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:26:34 -0400 Subject: [PATCH 02/33] Feature 01 task 2: Vite React TypeScript web skeleton (#3) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/index.html | 12 + web/package-lock.json | 2451 +++++++++++++++++++++++++++++++++++++++++ web/package.json | 31 + web/src/App.test.tsx | 15 + web/src/App.tsx | 25 + web/src/main.tsx | 17 + web/src/test/setup.ts | 7 + web/tsconfig.json | 21 + web/vite.config.ts | 19 + 9 files changed, 2598 insertions(+) create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts 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..6414da2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2451 @@ +{ + "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", + "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==", + "dev": 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==", + "dev": 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/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..3ea1eed --- /dev/null +++ b/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "mealquest-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "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/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..f50d2b2 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { describe, expect, it } from 'vitest' +import App from './App.tsx' + +describe('App', () => { + it('renders the MealQuest shell', () => { + render( + + + , + ) + expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument() + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..9415bc4 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Route, Routes } from 'react-router' + +// Placeholder shell only: no product UI, auth or API calls yet. +function Shell() { + return ( +
+

MealQuest

+
+ ) +} + +// App expects to be rendered inside a router (BrowserRouter in main.tsx, +// MemoryRouter in tests). +export default function App() { + const [queryClient] = useState(() => new QueryClient()) + return ( + + + } /> + + + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..1a842db --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import App from './App.tsx' + +const root = document.getElementById('root') +if (!root) { + throw new Error('MealQuest: #root element missing from index.html') +} + +createRoot(root).render( + + + + + , +) 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/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..74f6c16 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1a9192b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +const backend = 'http://localhost:8080' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': backend, + '/auth': backend, + '/media': backend, + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, +}) -- 2.55.0 From 85569b451f090061fda2c73e7300140a9cb1d0e0 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:47:01 -0400 Subject: [PATCH 03/33] Feature 01 task 3: Embed the PWA build with SPA fallback (#4) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitignore | 5 ++ Makefile | 24 +++++ cmd/server/main.go | 7 +- internal/frontend/dist/index.html | 11 +++ internal/frontend/frontend.go | 127 ++++++++++++++++++++++++++ internal/frontend/frontend_test.go | 137 +++++++++++++++++++++++++++++ internal/server/server.go | 34 +++++-- internal/server/server_test.go | 127 ++++++++++++++++++++++---- 8 files changed, 451 insertions(+), 21 deletions(-) create mode 100644 Makefile create mode 100644 internal/frontend/dist/index.html create mode 100644 internal/frontend/frontend.go create mode 100644 internal/frontend/frontend_test.go diff --git a/.gitignore b/.gitignore index 7523873..60709ee 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ 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/Makefile b/Makefile new file mode 100644 index 0000000..3917323 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +# Build entry point: build the PWA, embed it, then build the server binary. + +FRONTEND_DIST := internal/frontend/dist + +.PHONY: build web embed check-embed-clean + +build: embed + go build -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index e0d649b..bbf388b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "time" "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" ) @@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + router, err := server.NewRouter(logger, version, frontend.Assets()) + if err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, - Handler: server.NewRouter(logger, version), + Handler: router, ReadHeaderTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), } 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/server/server.go b/internal/server/server.go index 33b0e38..929c846 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,20 +3,44 @@ package server import ( "encoding/json" + "io/fs" "log/slog" "net/http" "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 -// GET /health. -func NewRouter(logger *slog.Logger, version string) http.Handler { +// GET /health; assets is the web build served for every other GET. +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.MethodNotAllowed(methodNotAllowed) + r.NotFound(notFound) + r.Get("/health", health(logger, version)) - r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")) - r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")) - return r + r.Route("/api", func(r chi.Router) { + r.MethodNotAllowed(methodNotAllowed) + 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 { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f8e3cc5..f1adc7e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,14 +7,35 @@ import ( "maps" "net/http" "net/http/httptest" + "strings" "testing" + "testing/fstest" ) +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() + 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) { t.Parallel() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(NewRouter(logger, "1.2.3")) + srv := httptest.NewServer(newTestRouter(t, "1.2.3")) t.Cleanup(srv.Close) resp, err := http.Get(srv.URL + "/health") @@ -42,9 +63,8 @@ func TestHealth(t *testing.T) { func TestHealthRejectsOtherMethods(t *testing.T) { t.Parallel() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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 { 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() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - rec := httptest.NewRecorder() - NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil)) + router := newTestRouter(t, "dev") + tests := []struct { + 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 { - t.Errorf("status = %d, want 404", rec.Code) + rec := httptest.NewRecorder() + 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}}` - if got := rec.Body.String(); got != want { - t.Errorf("body = %s, want %s", got, want) + 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()) } } -- 2.55.0 From c28dff18cd723091c1efea89f10d66f89f1aebfb Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 23:06:58 -0400 Subject: [PATCH 04/33] Feature 01 task 4: Makefile and multi-stage Dockerfile (#5) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .dockerignore | 26 ++++++++++++++++++++++++++ Dockerfile | 42 ++++++++++++++++++++++++++++++++++++++++++ Makefile | 42 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile 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/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 index 3917323..e9f157a 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,44 @@ -# Build entry point: build the PWA, embed it, then build the server binary. +# 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: build web embed check-embed-clean +.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 - go build -o bin/mealquest ./cmd/server + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server web: cd web && npm ci && npm run build @@ -22,3 +55,6 @@ 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) . -- 2.55.0 From ec13423b6fd668e93d56822a47424279ab9be1b9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:12:39 -0400 Subject: [PATCH 05/33] Feature 01 task 5: Gitea Actions CI workflow (#6) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitea/workflows/ci.yml | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4862a8a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,60 @@ +# 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. +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 + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "gofmt needed:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go test + run: go test -race ./... + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: npm ci + working-directory: web + run: npm ci + + - name: npm run typecheck + working-directory: web + run: npm run typecheck + + - name: npm test + working-directory: web + run: npm test + + - name: npm run build + working-directory: web + run: npm run build + + # Embed the real PWA, as `make embed` does, then build the static binary. + - name: go build (embedded PWA) + run: | + rm -rf internal/frontend/dist + mkdir -p internal/frontend/dist + cp -R web/dist/. internal/frontend/dist/ + CGO_ENABLED=0 go build -o bin/mealquest ./cmd/server -- 2.55.0 From 9f4537d0cb5013afdcfcc434b713aeb2e773b9c5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:26:15 -0400 Subject: [PATCH 06/33] Feature 01 task 6: AGENTS.md, CLAUDE.md, README and architecture docs (#7) Co-authored-by: dev_agent Co-committed-by: dev_agent --- AGENTS.md | 143 ++++++++++++++++++++++++++ CLAUDE.md | 1 + README.md | 56 +++++++++++ docs/ARCHITECTURE.md | 235 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/ARCHITECTURE.md 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/README.md b/README.md index e69de29..c764516 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..4beae8c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. -- 2.55.0 From 645b3c00b22d20cfb4184e97a1427e74e97dae19 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:36:48 -0400 Subject: [PATCH 07/33] Feature 01 integration fix: CI build parity with make build (#9) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitea/workflows/ci.yml | 46 ++++++++--------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4862a8a..40baf62 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,11 @@ # 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] @@ -16,45 +21,12 @@ jobs: go-version-file: go.mod cache: false - - name: gofmt - run: | - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "gofmt needed:" - echo "$unformatted" - exit 1 - fi - - - name: go vet - run: go vet ./... - - - name: go test - run: go test -race ./... - - uses: actions/setup-node@v4 with: node-version: "22" - - name: npm ci - working-directory: web - run: npm ci + - name: make test + run: make test - - name: npm run typecheck - working-directory: web - run: npm run typecheck - - - name: npm test - working-directory: web - run: npm test - - - name: npm run build - working-directory: web - run: npm run build - - # Embed the real PWA, as `make embed` does, then build the static binary. - - name: go build (embedded PWA) - run: | - rm -rf internal/frontend/dist - mkdir -p internal/frontend/dist - cp -R web/dist/. internal/frontend/dist/ - CGO_ENABLED=0 go build -o bin/mealquest ./cmd/server + - name: make build + run: make build -- 2.55.0 From e9ee4e768a8c7a6ed58b53d70c0e928eca7f5284 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:54:02 -0400 Subject: [PATCH 08/33] Feature 02 task 1: SQLite connection and embedded migration runner (#10) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/main.go | 15 +++ go.mod | 19 ++- go.sum | 50 ++++++++ internal/store/migrate.go | 105 +++++++++++++++++ internal/store/migrate_test.go | 206 +++++++++++++++++++++++++++++++++ internal/store/store.go | 79 +++++++++++++ internal/store/store_test.go | 116 +++++++++++++++++++ migrations/0000_bootstrap.sql | 4 + migrations/embed.go | 10 ++ 9 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 internal/store/migrate.go create mode 100644 internal/store/migrate_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 migrations/0000_bootstrap.sql create mode 100644 migrations/embed.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bbf388b..ec9f141 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,8 @@ import ( "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/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) // version is set at build time with -ldflags "-X main.version=". @@ -38,6 +40,19 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + db, err := store.Open(ctx, cfg.DataDir) + if err != nil { + return err + } + defer func() { + if err := db.Close(); err != nil { + logger.Error("close database", "error", err) + } + }() + if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { + return err + } + router, err := server.NewRouter(logger, version, frontend.Assets()) if err != nil { return err diff --git a/go.mod b/go.mod index 37e681e..3c014a7 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest -go 1.25 +go 1.25.0 -require github.com/go-chi/chi/v5 v5.3.1 +require ( + github.com/go-chi/chi/v5 v5.3.1 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // 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 index bdb0eaa..1d524f4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,52 @@ +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/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/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/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/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..35a0824 --- /dev/null +++ b/internal/store/migrate_test.go @@ -0,0 +1,206 @@ +package store + +import ( + "context" + "io/fs" + "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" { + t.Errorf("versions = %q, want 0000_bootstrap", got) + } +} + +func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") + if err != nil { + t.Fatal(err) + } + // A later build adds 0001_init.sql alongside the unchanged bootstrap. + upgraded := fstest.MapFS{ + "0000_bootstrap.sql": {Data: bootstrap}, + "0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")}, + } + if err := Migrate(ctx, s.Writer, upgraded); err != nil { + t.Fatal(err) + } + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) + } + if !tableExists(t, s, "later") { + t.Error("table from 0001_init was not created") + } +} 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/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/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 -- 2.55.0 From b06d1680a387142d0660eed9ac1f56280decc25e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 08:54:55 -0400 Subject: [PATCH 09/33] Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions (#11) Co-authored-by: dev_agent Co-committed-by: dev_agent --- go.mod | 1 + go.sum | 3 + internal/store/id.go | 27 ++++++++ internal/store/id_test.go | 25 +++++++ internal/store/time.go | 119 ++++++++++++++++++++++++++++++++++ internal/store/time_test.go | 126 ++++++++++++++++++++++++++++++++++++ internal/store/tx.go | 37 +++++++++++ internal/store/tx_test.go | 84 ++++++++++++++++++++++++ 8 files changed, 422 insertions(+) create mode 100644 internal/store/id.go create mode 100644 internal/store/id_test.go create mode 100644 internal/store/time.go create mode 100644 internal/store/time_test.go create mode 100644 internal/store/tx.go create mode 100644 internal/store/tx_test.go diff --git a/go.mod b/go.mod index 3c014a7..a517797 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.1 + github.com/oklog/ulid/v2 v2.1.2 modernc.org/sqlite v1.59.0 ) diff --git a/go.sum b/go.sum index 1d524f4..3a07785 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,9 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe 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= 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/time.go b/internal/store/time.go new file mode 100644 index 0000000..e558bc2 --- /dev/null +++ b/internal/store/time.go @@ -0,0 +1,119 @@ +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 +} + +// 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. +func (d LocalDate) Value() (driver.Value, error) { + if d.IsZero() { + return nil, errors.New("store: zero LocalDate") + } + 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..e809633 --- /dev/null +++ b/internal/store/time_test.go @@ -0,0 +1,126 @@ +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 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") + } + 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) + } +} -- 2.55.0 From fa7c6b721822b5b784a410c13b159d6e5e417377 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:06:21 -0400 Subject: [PATCH 10/33] Feature 02 task 3: Initial v1 schema migration (#12) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/store/migrate_test.go | 21 +- internal/store/schema_test.go | 556 +++++++++++++++++++++++++++++++++ migrations/0001_init.sql | 333 ++++++++++++++++++++ 3 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 internal/store/schema_test.go create mode 100644 migrations/0001_init.sql diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 35a0824..99d381c 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -174,33 +174,30 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap" { - t.Errorf("versions = %q, want 0000_bootstrap", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } } -func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) { +func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { ctx := context.Background() s := openTestStore(t) - if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { - t.Fatal(err) - } bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql") if err != nil { t.Fatal(err) } - // A later build adds 0001_init.sql alongside the unchanged bootstrap. - upgraded := fstest.MapFS{ - "0000_bootstrap.sql": {Data: bootstrap}, - "0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")}, + // A database migrated by the bootstrap-only build upgrades to the + // embedded set, which adds 0001_init.sql 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, upgraded); err != nil { + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" { t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) } - if !tableExists(t, s, "later") { + if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") } } diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go new file mode 100644 index 0000000..ed61047 --- /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"] = "pending" + 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", "queued"}, + {"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/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 +); -- 2.55.0 From 17a69f4de77e3d89c92e310cd8db53116aec3292 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:15:40 -0400 Subject: [PATCH 11/33] Feature 02 task 4: sqlc configuration and core queries (#13) Co-authored-by: dev_agent Co-committed-by: dev_agent --- .gitea/workflows/ci.yml | 9 +- Makefile | 30 +- internal/db/db.go | 31 ++ internal/db/db_test.go | 564 ++++++++++++++++++++++++++++++++ internal/db/dinners.sql.go | 321 ++++++++++++++++++ internal/db/models.go | 309 +++++++++++++++++ internal/db/photos.sql.go | 86 +++++ internal/db/querier.go | 65 ++++ internal/db/recipes.sql.go | 298 +++++++++++++++++ internal/db/settings.sql.go | 428 ++++++++++++++++++++++++ migrations/queries/dinners.sql | 57 ++++ migrations/queries/photos.sql | 17 + migrations/queries/recipes.sql | 65 ++++ migrations/queries/settings.sql | 128 ++++++++ sqlc.yaml | 15 + 15 files changed, 2421 insertions(+), 2 deletions(-) create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/dinners.sql.go create mode 100644 internal/db/models.go create mode 100644 internal/db/photos.sql.go create mode 100644 internal/db/querier.go create mode 100644 internal/db/recipes.sql.go create mode 100644 internal/db/settings.sql.go create mode 100644 migrations/queries/dinners.sql create mode 100644 migrations/queries/photos.sql create mode 100644 migrations/queries/recipes.sql create mode 100644 migrations/queries/settings.sql create mode 100644 sqlc.yaml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 40baf62..d475990 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,7 +5,9 @@ # 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 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] @@ -28,5 +30,10 @@ jobs: - 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/Makefile b/Makefile index e9f157a..4356184 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ # 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 @@ -14,7 +17,13 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(G IMAGE ?= mealquest:$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: dev test go-test web-test build web embed check-embed-clean docker +# 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. @@ -58,3 +67,22 @@ check-embed-clean: 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/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/models.go b/internal/db/models.go new file mode 100644 index 0000000..78206b0 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,309 @@ +// 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 +} + +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..bc63ad5 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" +) + +type Querier interface { + // An unknown id returns sql.ErrNoRows. + ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, 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 + // 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) + // 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) + // An unknown id returns sql.ErrNoRows. + SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, 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..5bdc156 --- /dev/null +++ b/internal/db/settings.sql.go @@ -0,0 +1,428 @@ +// 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 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, + ) + 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, + 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_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 +` + +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 + 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.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, + ) + 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/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/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..2b39e69 --- /dev/null +++ b/migrations/queries/settings.sql @@ -0,0 +1,128 @@ +-- 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, + 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_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 -- 2.55.0 From c1a868c48be0d0b4fcbd230dca33ce1245d271c3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:27:22 -0400 Subject: [PATCH 12/33] Feature 02 integration fix: repository coverage and boot-path tests (#15) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/boot_test.go | 96 ++++++++++++++++ cmd/server/main.go | 23 +++- internal/store/migrate_test.go | 60 ++++++++++ internal/store/repository_test.go | 177 ++++++++++++++++++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 cmd/server/boot_test.go create mode 100644 internal/store/repository_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go new file mode 100644 index 0000000..df8106d --- /dev/null +++ b/cmd/server/boot_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "context" + "io/fs" + "log/slog" + "net/url" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "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 }) +} + +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 := config.Config{ + HTTPAddr: "127.0.0.1:0", + PublicURL: &url.URL{Scheme: "http", Host: "localhost"}, + DataDir: t.TempDir(), + ShutdownTimeout: time.Second, + } + 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()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index ec9f141..f98335e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io/fs" "log/slog" "net/http" "os" @@ -40,7 +41,7 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - db, err := store.Open(ctx, cfg.DataDir) + db, err := openStore(ctx, cfg.DataDir) if err != nil { return err } @@ -49,9 +50,6 @@ func run(cfg config.Config, logger *slog.Logger) error { logger.Error("close database", "error", err) } }() - if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil { - return err - } router, err := server.NewRouter(logger, version, frontend.Assets()) if err != nil { @@ -88,6 +86,23 @@ func run(cfg config.Config, logger *slog.Logger) error { return 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 +} + // 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) { diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 99d381c..bff84c5 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -3,6 +3,7 @@ package store import ( "context" "io/fs" + "slices" "strings" "testing" "testing/fstest" @@ -201,3 +202,62 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { 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) + } +} 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) + } + }) + } +} -- 2.55.0 From 6d5c14f778b119dc1fbf95ef38590ff265f12418 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:46:29 -0400 Subject: [PATCH 13/33] Feature 04 task 1: secretbox: AES-256-GCM secrets at rest (#17) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/boot_test.go | 113 ++++++++++++++- cmd/server/main.go | 15 ++ internal/secretbox/secretbox.go | 81 +++++++++++ internal/secretbox/secretbox_test.go | 200 +++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 internal/secretbox/secretbox.go create mode 100644 internal/secretbox/secretbox_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index df8106d..8ce5eca 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -3,15 +3,21 @@ package main import ( "bytes" "context" + "crypto/rand" + "encoding/base64" + "errors" "io/fs" "log/slog" - "net/url" + "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/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -24,6 +30,37 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) { 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() + keyPath := filepath.Join(t.TempDir(), "secret_key") + if err := os.WriteFile(keyPath, []byte(keyFile), 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) + 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;")}, @@ -73,12 +110,7 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) { useBootMigrations(t, brokenMigrations) var logs bytes.Buffer - cfg := config.Config{ - HTTPAddr: "127.0.0.1:0", - PublicURL: &url.URL{Scheme: "http", Host: "localhost"}, - DataDir: t.TempDir(), - ShutdownTimeout: time.Second, - } + cfg := loadConfig(t, validKeyFile(t)) errc := make(chan error, 1) go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() @@ -94,3 +126,70 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) { 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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f98335e..79bdbdb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "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/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" @@ -41,6 +42,10 @@ 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 @@ -86,6 +91,16 @@ func run(cfg config.Config, logger *slog.Logger) error { 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 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) + } +} -- 2.55.0 From 99c8f1625f34755b30cff46427ac94d2ea8d36fb Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:58:05 -0400 Subject: [PATCH 14/33] Feature 03 task 1: OIDC login and callback against Authentik (#16) Co-authored-by: dev_agent Co-committed-by: dev_agent --- README.md | 10 +- cmd/server/main.go | 22 +- go.mod | 3 + go.sum | 6 + internal/auth/auth.go | 299 +++++++++ internal/auth/auth_test.go | 901 +++++++++++++++++++++++++++ internal/auth/cookie.go | 124 ++++ internal/config/config.go | 56 +- internal/config/config_test.go | 102 ++- internal/secureurl/secureurl.go | 32 + internal/secureurl/secureurl_test.go | 40 ++ internal/server/server.go | 16 +- internal/server/server_test.go | 32 +- 13 files changed, 1629 insertions(+), 14 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/cookie.go create mode 100644 internal/secureurl/secureurl.go create mode 100644 internal/secureurl/secureurl_test.go diff --git a/README.md b/README.md index c764516..d08ebbf 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ 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 make dev ``` @@ -37,7 +43,9 @@ make dev (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. +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]`. ## Common commands diff --git a/cmd/server/main.go b/cmd/server/main.go index f98335e..23d8738 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "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/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" @@ -51,7 +52,18 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() - router, err := server.NewRouter(logger, version, frontend.Assets()) + 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()), + }, sessionsPending{}, logger) + if err != nil { + return err + } + + router, err := server.NewRouter(logger, version, authFlow, frontend.Assets()) if err != nil { return err } @@ -103,6 +115,14 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) { return db, nil } +// sessionsPending refuses every login until the session layer (Feature 03 +// task 2) replaces it. +type sessionsPending struct{} + +func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error { + return errors.New("session layer not implemented") +} + // 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) { diff --git a/go.mod b/go.mod index a517797..39a6ce9 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,16 @@ 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 diff --git a/go.sum b/go.sum index 3a07785..ba3f090 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +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= @@ -19,6 +23,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8256c88 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,299 @@ +// 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" + "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" +) + +// 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"` +} + +// SessionStarter is the session layer's entry point. StartSession is called +// once per successful login with claims from a verified ID token; it must set +// the session cookie on w. A returned error aborts the login with 500. +type SessionStarter interface { + StartSession(w http.ResponseWriter, r *http.Request, claims Claims) 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 +} + +// Auth serves the login and callback routes. +type Auth struct { + oauth oauth2.Config + verifier *oidc.IDTokenVerifier + client *http.Client + cookie loginCookie + sessions SessionStarter + 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 SessionStarter, 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 starter is required") + } + + if err := checkEndpoint("issuer", cfg.Issuer); err != nil { + return nil, err + } + + client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect} + provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("auth: OIDC discovery: %w", err) + } + endpoint := provider.Endpoint() + var discovered struct { + JWKSURI string `json:"jwks_uri"` + } + 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}, + } { + 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}, + }), + client: client, + cookie: newLoginCookie(cfg.SecretKey), + sessions: sessions, + logger: logger, + }, nil +} + +// 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 and GET CallbackPath on r. +func (a *Auth) Mount(r chi.Router) { + r.Get(LoginPath, a.login) + r.Get(CallbackPath, a.callback) +} + +// 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. +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 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) +} + +// verify exchanges code for tokens and returns the verified ID token claims. +// The verifier enforces RS256, issuer, audience and expiry; nonce is checked +// here. +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{}, fmt.Errorf("verify ID token: %w", 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") + } + return claims, nil +} + +// 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..66e4379 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,901 @@ +package auth + +import ( + "bytes" + "context" + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "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"} + +// 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 +} + +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) + 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", + "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) { + 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, + }) +} + +// 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 SessionStarter that records the claims it +// was handed. +type memorySessions struct { + mu sync.Mutex + claims []Claims + 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) started() []Claims { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.claims) +} + +type harness struct { + 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"), + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + a.Mount(r) + return &harness{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 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"), + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("New succeeded without a discovery document") + } +} + +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"), + }, &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"), + }, &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"}, + } + 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"), + }, &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"), + }, 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/config/config.go b/internal/config/config.go index 5673595..113c855 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ import ( "strconv" "strings" "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" ) // Environment variable names. @@ -20,6 +22,11 @@ const ( 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" ) // Config is the validated service configuration. @@ -29,6 +36,13 @@ type Config struct { 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 } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -47,10 +61,15 @@ func load(lookup func(string) (string, bool)) (Config, error) { r := reader{lookup: lookup} cfg := Config{ HTTPAddr: r.listenAddr(EnvHTTPAddr), - PublicURL: r.httpURL(EnvPublicURL), + 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), } if len(r.problems) > 0 { return Config{}, &Error{Problems: r.problems} @@ -114,6 +133,29 @@ func (r *reader) required(name string) (string, bool) { 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 +} + func (r *reader) listenAddr(name string) string { v, ok := r.required(name) if !ok { @@ -155,15 +197,21 @@ func validHostname(h string) bool { return true } -func (r *reader) httpURL(name string) *url.URL { +// 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 || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { - r.fail(name, "invalid URL (want absolute http:// or https:// URL)") + 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 01a184f..c00286b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -21,6 +22,10 @@ func TestLoad(t *testing.T) { 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) @@ -33,6 +38,11 @@ func TestLoad(t *testing.T) { 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 { @@ -70,6 +80,7 @@ func TestLoad(t *testing.T) { env: map[string]string{}, badVars: []string{ EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, + EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, }, }, { @@ -139,6 +150,24 @@ func TestLoad(t *testing.T) { 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"}), @@ -169,6 +198,54 @@ func TestLoad(t *testing.T) { 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: "several invalid values are all named", env: with(map[string]string{ @@ -211,7 +288,7 @@ func TestLoad(t *testing.T) { t.Errorf("error message %q does not name %s", err.Error(), v) } } - if cfg != (Config{}) { + if !reflect.ValueOf(cfg).IsZero() { t.Errorf("load() returned non-zero Config alongside error: %+v", cfg) } }) @@ -226,12 +303,21 @@ func TestLoadValues(t *testing.T) { 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", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { @@ -253,6 +339,18 @@ func TestLoadValues(t *testing.T) { 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) + } } func TestSecretRedacted(t *testing.T) { @@ -260,7 +358,7 @@ func TestSecretRedacted(t *testing.T) { s := Secret{value: "s3cret-value"} for _, format := range []string{"%v", "%s", "%+v", "%#v"} { - cfg := Config{SecretKey: s} + 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) 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/server.go b/internal/server/server.go index 929c846..45a8471 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,9 +16,15 @@ import ( // 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. version is reported by -// GET /health; assets is the web build served for every other GET. -func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) { +// GET /health; auth mounts the /auth routes; assets is the web build served +// for every other GET. +func NewRouter(logger *slog.Logger, version string, auth RouteMounter, 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") @@ -32,12 +38,14 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, r.NotFound(notFound) r.Get("/health", health(logger, version)) + auth.Mount(r) r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) 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. + // Catch-all for the PWA. Unknown /auth paths and /media (no routes yet) + // 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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f1adc7e..21d63e8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "testing/fstest" + + "github.com/go-chi/chi/v5" ) const ( @@ -25,13 +27,39 @@ func newTestRouter(t *testing.T, version string) http.Handler { "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, assets) + r, err := NewRouter(logger, version, stubAuth{}, 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) + } +} + func TestHealth(t *testing.T) { t.Parallel() @@ -99,7 +127,7 @@ func TestReservedPathsNotFound(t *testing.T) { {http.MethodGet, "/api"}, {http.MethodGet, "/api/unknown"}, {http.MethodPost, "/api/unknown"}, - {http.MethodGet, "/auth/callback"}, + {http.MethodGet, "/auth/unknown"}, {http.MethodGet, "/media/photo.jpg"}, {http.MethodGet, "/health/deep"}, {http.MethodGet, "/auth/../quests/42"}, -- 2.55.0 From 5e78465d34960b019f23e9d9a91fba2e4e7c8c96 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:58:06 -0400 Subject: [PATCH 15/33] Feature 04 task 2: Household week and local-time utilities (#18) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/household/week.go | 130 +++++++++++++ internal/household/week_test.go | 311 ++++++++++++++++++++++++++++++++ internal/store/time.go | 17 +- internal/store/time_test.go | 25 +++ 4 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 internal/household/week.go create mode 100644 internal/household/week_test.go 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/store/time.go b/internal/store/time.go index e558bc2..068c10f 100644 --- a/internal/store/time.go +++ b/internal/store/time.go @@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) { 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) @@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool { return d == LocalDate{} } -// Value implements driver.Valuer. The zero LocalDate is an error. +// 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 } diff --git a/internal/store/time_test.go b/internal/store/time_test.go index e809633..10c0791 100644 --- a/internal/store/time_test.go +++ b/internal/store/time_test.go @@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) { } } +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", @@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) { 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") -- 2.55.0 From f0bdd0ef4031ca9b00b829b581a95971371ec26b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 10:20:14 -0400 Subject: [PATCH 16/33] Feature 03 task 2: Server-side sessions, cookie and logout (#19) Co-authored-by: dev_agent Co-committed-by: dev_agent --- README.md | 9 +- cmd/server/main.go | 14 +- internal/auth/auth.go | 54 +++- internal/auth/auth_test.go | 17 +- internal/auth/session.go | 238 +++++++++++++++ internal/auth/session_test.go | 521 +++++++++++++++++++++++++++++++++ internal/config/config.go | 47 +++ internal/config/config_test.go | 60 ++++ 8 files changed, 933 insertions(+), 27 deletions(-) create mode 100644 internal/auth/session.go create mode 100644 internal/auth/session_test.go diff --git a/README.md b/README.md index d08ebbf..6ff2bd3 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,19 @@ 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 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. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the +every variable above is required except the commented-out session +lifetimes (when set they must be positive, with MAX at least IDLE), 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]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 23d8738..89f6b9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,13 +52,17 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() + 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()), - }, sessionsPending{}, logger) + }, sessions, logger) if err != nil { return err } @@ -115,14 +119,6 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) { return db, nil } -// sessionsPending refuses every login until the session layer (Feature 03 -// task 2) replaces it. -type sessionsPending struct{} - -func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error { - return errors.New("session layer not implemented") -} - // 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) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8256c88..629ed19 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -25,6 +25,7 @@ import ( const ( LoginPath = "/auth/login" CallbackPath = "/auth/callback" + LogoutPath = "/auth/logout" ) // providerTimeout bounds every request to the identity provider. @@ -43,11 +44,14 @@ type Claims struct { Groups []string `json:"groups"` } -// SessionStarter is the session layer's entry point. StartSession is called -// once per successful login with claims from a verified ID token; it must set -// the session cookie on w. A returned error aborts the login with 500. -type SessionStarter interface { +// 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. @@ -69,13 +73,16 @@ type Auth struct { verifier *oidc.IDTokenVerifier client *http.Client cookie loginCookie - sessions SessionStarter - logger *slog.Logger + sessions Sessions + // 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 SessionStarter, logger *slog.Logger) (*Auth, error) { +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") @@ -84,7 +91,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. case len(cfg.SecretKey) == 0: return nil, errors.New("auth: secret key is required") case sessions == nil: - return nil, errors.New("auth: session starter is required") + return nil, errors.New("auth: session layer is required") } if err := checkEndpoint("issuer", cfg.Issuer); err != nil { @@ -98,7 +105,8 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. } endpoint := provider.Endpoint() var discovered struct { - JWKSURI string `json:"jwks_uri"` + 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) @@ -109,6 +117,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. {"authorization endpoint", endpoint.AuthURL}, {"token endpoint", endpoint.TokenURL}, {"JWKS endpoint", discovered.JWKSURI}, + {"end session endpoint", discovered.EndSessionURI}, } { if err := checkEndpoint(e.name, e.raw); err != nil { return nil, err @@ -129,10 +138,11 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. ClientID: cfg.ClientID, SupportedSigningAlgs: []string{oidc.RS256}, }), - client: client, - cookie: newLoginCookie(cfg.SecretKey), - sessions: sessions, - logger: logger, + client: client, + cookie: newLoginCookie(cfg.SecretKey), + sessions: sessions, + endSessionURL: discovered.EndSessionURI, + logger: logger, }, nil } @@ -169,12 +179,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error { return nil } -// Mount registers GET LoginPath and GET CallbackPath on r. +// 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. @@ -250,6 +264,18 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { 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. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 66e4379..a9d83e6 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -86,6 +86,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) { "authorization_endpoint": p.issuer() + "/authorize", "token_endpoint": p.issuer() + "/token", "jwks_uri": p.issuer() + "/jwks", + "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"}, @@ -223,11 +224,12 @@ func writeJSON(w http.ResponseWriter, v any) { json.NewEncoder(w).Encode(v) } -// memorySessions is an in-memory SessionStarter that records the claims it -// was handed. +// 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 } @@ -242,6 +244,16 @@ func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, cl 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() @@ -780,6 +792,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { {"authorization_endpoint", "authorization endpoint"}, {"token_endpoint", "token endpoint"}, {"jwks_uri", "JWKS endpoint"}, + {"end_session_endpoint", "end session endpoint"}, } for _, tc := range tests { t.Run(tc.field, func(t *testing.T) { 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..20e03fe --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,521 @@ +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"), + }, 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"), + }, &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 index 113c855..699003b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,16 @@ const ( EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID" EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE" EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS" + + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" + EnvSessionMax = "MEALQUEST_SESSION_MAX" +) + +// 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. @@ -43,6 +53,11 @@ type Config struct { 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 } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -70,6 +85,12 @@ func load(lookup func(string) (string, bool)) (Config, error) { OIDCClientID: r.str(EnvOIDCClientID), OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile), OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups), + + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), + SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), + } + 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} @@ -243,6 +264,32 @@ func (r *reader) duration(name string) time.Duration { 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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c00286b..4dafec1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -246,6 +246,53 @@ func TestLoad(t *testing.T) { 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: "several invalid values are all named", env: with(map[string]string{ @@ -351,6 +398,19 @@ func TestLoadValues(t *testing.T) { 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) + } + + env[EnvSessionIdle] = "12h" + env[EnvSessionMax] = "36h" + 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) + } } func TestSecretRedacted(t *testing.T) { -- 2.55.0 From b2201c5da14cd9f86bae4631246ae32017a2b9c7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 10:30:07 -0400 Subject: [PATCH 17/33] Feature 03 task 3: Household group gate and auth middleware (#21) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/main.go | 13 +- internal/auth/actor.go | 35 +++++ internal/auth/actor_test.go | 32 ++++ internal/auth/auth.go | 77 +++++++++- internal/auth/auth_test.go | 113 ++++++++++---- internal/auth/gate_test.go | 264 ++++++++++++++++++++++++++++++++ internal/auth/refused.go | 37 +++++ internal/auth/session_test.go | 22 +-- internal/server/server.go | 23 ++- internal/server/server_test.go | 9 +- internal/server/session.go | 37 +++++ internal/server/session_test.go | 151 ++++++++++++++++++ 12 files changed, 757 insertions(+), 56 deletions(-) create mode 100644 internal/auth/actor.go create mode 100644 internal/auth/actor_test.go create mode 100644 internal/auth/gate_test.go create mode 100644 internal/auth/refused.go create mode 100644 internal/server/session.go create mode 100644 internal/server/session_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 89f6b9d..229a69a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -57,17 +57,18 @@ func run(cfg config.Config, logger *slog.Logger) error { 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()), + 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, authFlow, frontend.Assets()) + router, err := server.NewRouter(logger, version, authFlow, sessions, frontend.Assets()) if err != nil { return err } diff --git a/internal/auth/actor.go b/internal/auth/actor.go new file mode 100644 index 0000000..ac65b84 --- /dev/null +++ b/internal/auth/actor.go @@ -0,0 +1,35 @@ +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 actorKey struct{} + +// WithActor returns a copy of ctx carrying actor. The session middleware +// calls it for every authenticated request. +func WithActor(ctx context.Context, actor Actor) context.Context { + return context.WithValue(ctx, actorKey{}, actor) +} + +// ActorFrom returns the actor WithActor stored in ctx. ok is false when ctx +// has none, which behind the session middleware means a wiring bug. +func ActorFrom(ctx context.Context) (actor Actor, ok bool) { + actor, ok = ctx.Value(actorKey{}).(Actor) + return actor, ok +} diff --git a/internal/auth/actor_test.go b/internal/auth/actor_test.go new file mode 100644 index 0000000..c2f239e --- /dev/null +++ b/internal/auth/actor_test.go @@ -0,0 +1,32 @@ +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") + } + want := Actor{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}} + got, ok := ActorFrom(WithActor(context.Background(), want)) + if !ok || got.Subject != want.Subject || got.Email != want.Email || got.Name != want.Name || !slices.Equal(got.Groups, want.Groups) { + t.Errorf("ActorFrom = %+v (ok=%v), want %+v", got, ok, want) + } +} + +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 index 629ed19..4d3c1fb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,6 +12,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -65,15 +66,20 @@ type Config struct { 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 @@ -92,6 +98,8 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger 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 { @@ -118,6 +126,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger {"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 @@ -138,7 +149,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger 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, @@ -218,7 +231,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) { } // callback completes the flow: it checks state against the login cookie, -// exchanges the code, verifies the ID token and nonce, then starts a session. +// 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() @@ -256,6 +271,22 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { 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) @@ -278,7 +309,8 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { // verify exchanges code for tokens and returns the verified ID token claims. // The verifier enforces RS256, issuer, audience and expiry; nonce is checked -// here. +// 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)) @@ -303,9 +335,50 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim 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") +} + +// 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. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index a9d83e6..4ecf409 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -36,6 +36,9 @@ const ( 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 { @@ -50,6 +53,12 @@ type fakeProvider struct { // 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 } type tokenReply struct { @@ -73,6 +82,7 @@ func newFakeProvider(t *testing.T) *fakeProvider { 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 @@ -86,6 +96,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) { "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"}, @@ -148,6 +159,41 @@ func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) { }) } +// 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 { @@ -291,11 +337,12 @@ func newHarness(t *testing.T) *harness { sessions := &memorySessions{} logs := &syncBuffer{} a, err := New(context.Background(), Config{ - Issuer: p.issuer(), - ClientID: testClientID, - ClientSecret: testClientSecret, - PublicURL: testPublicURL, - SecretKey: []byte("server-secret-key"), + 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) @@ -730,11 +777,12 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) { 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"), + 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") @@ -747,11 +795,12 @@ func TestNewFailsOnIssuerMismatch(t *testing.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"), + 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") @@ -772,11 +821,12 @@ 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"), + 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) @@ -793,6 +843,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { {"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) { @@ -800,11 +851,12 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { 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"), + 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) @@ -840,11 +892,12 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.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"), + 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) 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_test.go b/internal/auth/session_test.go index 20e03fe..3f86f02 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -62,11 +62,12 @@ func newSessionHarness(t *testing.T) *sessionHarness { p := newFakeProvider(t) logs := &syncBuffer{} a, err := New(ctx, Config{ - Issuer: p.issuer(), - ClientID: testClientID, - ClientSecret: testClientSecret, - PublicURL: testPublicURL, - SecretKey: []byte("server-secret-key"), + 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) @@ -490,11 +491,12 @@ func TestNewRequiresEndSessionEndpoint(t *testing.T) { 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"), + 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) diff --git a/internal/server/server.go b/internal/server/server.go index 45a8471..4ae887b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,9 +22,10 @@ type RouteMounter interface { } // NewRouter returns the HTTP handler for the service. version is reported by -// GET /health; auth mounts the /auth routes; assets is the web build served -// for every other GET. -func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) { +// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and +// /media; assets is the web build served for every other GET. /health, /auth +// and the web build are public. +func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, 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") @@ -39,13 +40,23 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs 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) + apiV1, media := chi.NewRouter(), chi.NewRouter() + for _, sub := range []*chi.Mux{apiV1, media} { + sub.MethodNotAllowed(methodNotAllowed) + sub.NotFound(notFound) + } r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) + r.Mount("/v1", signedIn(apiV1)) }) - // Catch-all for the PWA. Unknown /auth paths and /media (no routes yet) - // reach the frontend handler, which passes them to notFound rather than - // the SPA fallback. + 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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 21d63e8..2b2cee1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler { "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, stubAuth{}, assets) + r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets) if err != nil { t.Fatal(err) } @@ -126,6 +126,8 @@ func TestReservedPathsNotFound(t *testing.T) { }{ {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"}, @@ -137,8 +139,11 @@ func TestReservedPathsNotFound(t *testing.T) { t.Run(tt.method+" "+tt.path, func(t *testing.T) { t.Parallel() + // Signed in, so /api/v1 and /media reach their routers. + req := httptest.NewRequest(tt.method, tt.path, nil) + req.AddCookie(validSessionCookie) rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", rec.Code) diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..ade1f51 --- /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's actor in the context (read it with +// 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.WithActor(r.Context(), s.Actor()))) + } + }) + } +} diff --git a/internal/server/session_test.go b/internal/server/session_test.go new file mode 100644 index 0000000..a8b4ac0 --- /dev/null +++ b/internal/server/session_test.go @@ -0,0 +1,151 @@ +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"}, + } +) + +// 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) + } +} -- 2.55.0 From 36b975ff5b01b314e4c18b8e3c5b2c26a22eccfd Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 10:51:26 -0400 Subject: [PATCH 18/33] Feature 03 task 4: CSRF protection and GET /api/v1/me (#22) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/auth/actor.go | 28 ++++-- internal/auth/actor_test.go | 16 +++- internal/server/csrf.go | 42 ++++++++ internal/server/csrf_test.go | 165 ++++++++++++++++++++++++++++++++ internal/server/me.go | 52 ++++++++++ internal/server/server.go | 7 +- internal/server/server_test.go | 4 +- internal/server/session.go | 6 +- internal/server/session_test.go | 9 +- 9 files changed, 306 insertions(+), 23 deletions(-) create mode 100644 internal/server/csrf.go create mode 100644 internal/server/csrf_test.go create mode 100644 internal/server/me.go diff --git a/internal/auth/actor.go b/internal/auth/actor.go index ac65b84..c781091 100644 --- a/internal/auth/actor.go +++ b/internal/auth/actor.go @@ -19,17 +19,27 @@ func (s Session) Actor() Actor { return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)} } -type actorKey struct{} +type sessionKey struct{} -// WithActor returns a copy of ctx carrying actor. The session middleware -// calls it for every authenticated request. -func WithActor(ctx context.Context, actor Actor) context.Context { - return context.WithValue(ctx, actorKey{}, actor) +// 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) } -// ActorFrom returns the actor WithActor stored in ctx. ok is false when ctx -// has none, which behind the session middleware means a wiring bug. +// 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) { - actor, ok = ctx.Value(actorKey{}).(Actor) - return actor, ok + 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 index c2f239e..fff57cb 100644 --- a/internal/auth/actor_test.go +++ b/internal/auth/actor_test.go @@ -11,10 +11,18 @@ func TestActorContext(t *testing.T) { if _, ok := ActorFrom(context.Background()); ok { t.Error("ActorFrom found an actor in an empty context") } - want := Actor{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}} - got, ok := ActorFrom(WithActor(context.Background(), want)) - if !ok || got.Subject != want.Subject || got.Email != want.Email || got.Name != want.Name || !slices.Equal(got.Groups, want.Groups) { - t.Errorf("ActorFrom = %+v (ok=%v), want %+v", got, ok, want) + 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) } } diff --git a/internal/server/csrf.go b/internal/server/csrf.go new file mode 100644 index 0000000..3f58ff2 --- /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, HEAD and OPTIONS 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, http.MethodHead, http.MethodOptions: + 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..e509c8e --- /dev/null +++ b/internal/server/csrf_test.go @@ -0,0 +1,165 @@ +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. + {"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.StatusNotFound, notFoundJSON}, + {"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""}, + // HEAD and OPTIONS pass the check and reach the router, which + // answers neither for /api/v1/me. + {"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusMethodNotAllowed, ""}, + {"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusMethodNotAllowed, methodNotAllowedJSON}, + } { + 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}, + {"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/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/server.go b/internal/server/server.go index 4ae887b..fe336a2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,7 +24,8 @@ type RouteMounter interface { // NewRouter returns the HTTP handler for the service. version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and // /media; assets is the web build served for every other GET. /health, /auth -// and the web build are public. +// and the web build are public. Requests to /api/v1 other than GET, HEAD and +// OPTIONS also need the session's CSRF token in CSRFHeader. func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, 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") @@ -44,15 +45,17 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions // 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)) r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) - r.Mount("/v1", signedIn(apiV1)) + r.Mount("/v1", signedIn(csrf(apiV1))) }) r.Mount("/media", signedIn(media)) // Catch-all for the PWA. Unknown /auth paths reach the frontend handler, diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2b2cee1..05d877f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -139,9 +139,11 @@ func TestReservedPathsNotFound(t *testing.T) { t.Run(tt.method+" "+tt.path, func(t *testing.T) { t.Parallel() - // Signed in, so /api/v1 and /media reach their routers. + // 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) diff --git a/internal/server/session.go b/internal/server/session.go index ade1f51..e718389 100644 --- a/internal/server/session.go +++ b/internal/server/session.go @@ -15,8 +15,8 @@ type SessionLookup interface { } // requireSession is the RequireSession middleware. It passes requests with a -// live session to next with the session's actor in the context (read it with -// auth.ActorFrom); others get 401 unauthenticated. +// 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") @@ -30,7 +30,7 @@ func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handl logger.ErrorContext(r.Context(), "look up session", "error", err) internal(w, r) default: - next.ServeHTTP(w, r.WithContext(auth.WithActor(r.Context(), s.Actor()))) + next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s))) } }) } diff --git a/internal/server/session_test.go b/internal/server/session_test.go index a8b4ac0..d221b4c 100644 --- a/internal/server/session_test.go +++ b/internal/server/session_test.go @@ -21,10 +21,11 @@ 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"}, + Subject: "user-123", + Email: "cook@example.com", + Name: "Home Cook", + Groups: []string{"mealquest-admin"}, + CSRFToken: "csrf-token-123", } ) -- 2.55.0 From 5833a73541bb368c3e77e754bc67192a92781947 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 11:05:33 -0400 Subject: [PATCH 19/33] Feature 03 task 5: Trusted proxy handling for forwarded headers (#23) Co-authored-by: dev_agent Co-committed-by: dev_agent --- README.md | 7 +- cmd/server/main.go | 6 +- docs/ARCHITECTURE.md | 9 + internal/config/config.go | 21 +++ internal/config/config_test.go | 54 ++++++ internal/forwarded/forwarded.go | 221 +++++++++++++++++++++++ internal/forwarded/forwarded_test.go | 260 +++++++++++++++++++++++++++ internal/server/login_test.go | 140 +++++++++++++++ internal/server/origin.go | 34 ++++ internal/server/origin_test.go | 145 +++++++++++++++ internal/server/server.go | 8 +- internal/server/server_test.go | 2 +- 12 files changed, 901 insertions(+), 6 deletions(-) create mode 100644 internal/forwarded/forwarded.go create mode 100644 internal/forwarded/forwarded_test.go create mode 100644 internal/server/login_test.go create mode 100644 internal/server/origin.go create mode 100644 internal/server/origin_test.go diff --git a/README.md b/README.md index 6ff2bd3..d374368 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # 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 make dev ``` @@ -47,7 +51,8 @@ make dev (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), and +lifetimes (when set they must be positive, with MAX at least IDLE) and +trusted proxy list (every entry must be an IP or CIDR), 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]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 229a69a..c937169 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "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/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -31,7 +32,7 @@ func main() { os.Exit(1) } - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + 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) @@ -68,7 +69,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, authFlow, sessions, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets()) if err != nil { return err } @@ -126,6 +127,7 @@ 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4beae8c..6f9eaab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,15 @@ docker compose project "mealquest" — 10.60.1.14 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 | diff --git a/internal/config/config.go b/internal/config/config.go index 699003b..d3ab5e2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" ) @@ -30,6 +31,8 @@ const ( EnvSessionIdle = "MEALQUEST_SESSION_IDLE" EnvSessionMax = "MEALQUEST_SESSION_MAX" + + EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -58,6 +61,10 @@ type Config struct { 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 } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -88,6 +95,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), + + TrustedProxy: r.trustedProxy(EnvTrustedProxy), } if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) @@ -177,6 +186,18 @@ func (r *reader) list(name string) []string { 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4dafec1..8816bf7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "net/netip" "net/url" "os" "path/filepath" @@ -11,6 +12,8 @@ import ( "strings" "testing" "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" ) func TestLoad(t *testing.T) { @@ -293,6 +296,48 @@ func TestLoad(t *testing.T) { 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{ @@ -402,8 +447,13 @@ func TestLoadValues(t *testing.T) { 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) @@ -411,6 +461,10 @@ func TestLoadValues(t *testing.T) { 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) + } } func TestSecretRedacted(t *testing.T) { 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/server/login_test.go b/internal/server/login_test.go new file mode 100644 index 0000000..d928de7 --- /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, + 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/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..1df8212 --- /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{}, + 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 index fe336a2..82a1e8c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" ) @@ -21,12 +22,14 @@ type RouteMounter interface { Mount(r chi.Router) } -// NewRouter returns the HTTP handler for the service. version is reported by +// 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; 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, HEAD and // OPTIONS also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, 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") @@ -36,6 +39,7 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions } r := chi.NewRouter() + r.Use(resolveOrigin(logger, trusted)) r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 05d877f..7419658 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler { "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, stubAuth{}, stubSessions{}, assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets) if err != nil { t.Fatal(err) } -- 2.55.0 From 88f7d0a5fac51050af5b402536d35bb4a61d4b0f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 11:22:53 -0400 Subject: [PATCH 20/33] Feature 03 integration fix: sanitize discovery errors and narrow CSRF exemption (#25) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/auth/auth.go | 34 ++++++++++++++++++++++++++++++++-- internal/auth/auth_test.go | 36 ++++++++++++++++++++++++++++++++++++ internal/server/csrf.go | 4 ++-- internal/server/csrf_test.go | 9 +++++---- internal/server/server.go | 3 +-- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 4d3c1fb..aa17cdd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "slices" + "sync/atomic" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -107,9 +108,12 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger } client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect} - provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer) + 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, fmt.Errorf("auth: OIDC discovery: %w", err) + return nil, discoveryError(err, int(recorder.status.Load())) } endpoint := provider.Endpoint() var discovered struct { @@ -159,6 +163,32 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.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 { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 4ecf409..8af6771 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "log/slog" "maps" @@ -789,6 +790,41 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) { } } +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) diff --git a/internal/server/csrf.go b/internal/server/csrf.go index 3f58ff2..65c21a3 100644 --- a/internal/server/csrf.go +++ b/internal/server/csrf.go @@ -12,7 +12,7 @@ import ( // GET /api/v1/me. const CSRFHeader = "X-MealQuest-CSRF" -// requireCSRF passes GET, HEAD and OPTIONS requests to next; every other +// 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 { @@ -21,7 +21,7 @@ func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { - case http.MethodGet, http.MethodHead, http.MethodOptions: + case http.MethodGet: next.ServeHTTP(w, r) return } diff --git a/internal/server/csrf_test.go b/internal/server/csrf_test.go index e509c8e..7e1dd54 100644 --- a/internal/server/csrf_test.go +++ b/internal/server/csrf_test.go @@ -38,10 +38,9 @@ func TestCSRFThroughRouter(t *testing.T) { {"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.StatusNotFound, notFoundJSON}, {"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""}, - // HEAD and OPTIONS pass the check and reach the router, which - // answers neither for /api/v1/me. - {"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusMethodNotAllowed, ""}, - {"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusMethodNotAllowed, methodNotAllowedJSON}, + // 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() @@ -89,6 +88,8 @@ func TestRequireCSRF(t *testing.T) { {"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}, } { diff --git a/internal/server/server.go b/internal/server/server.go index 82a1e8c..46520c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -27,8 +27,7 @@ type RouteMounter interface { // 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; 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, HEAD and -// OPTIONS also need the session's CSRF token in CSRFHeader. +// 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, 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") -- 2.55.0 From df692ef21490b021f91bfbb84e2aadbdbbdab140 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 11:32:36 -0400 Subject: [PATCH 21/33] Feature 03 integration fix: sanitize ID-token verification errors (#26) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/auth/auth.go | 13 ++++++- internal/auth/auth_test.go | 74 +++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index aa17cdd..d82d33d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -353,7 +353,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim } idToken, err := a.verifier.Verify(ctx, raw) if err != nil { - return Claims{}, fmt.Errorf("verify ID token: %w", err) + return Claims{}, verifyError(err) } if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 { return Claims{}, errors.New("ID token nonce mismatch") @@ -404,6 +404,17 @@ func userinfoError(err error) error { 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) }) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 8af6771..407bbb8 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -60,6 +60,8 @@ type fakeProvider struct { 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 { @@ -110,6 +112,15 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) { } 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, @@ -308,6 +319,7 @@ func (m *memorySessions) started() []Claims { } type harness struct { + auth *Auth provider *fakeProvider sessions *memorySessions router http.Handler @@ -350,7 +362,7 @@ func newHarness(t *testing.T) *harness { } r := chi.NewRouter() a.Mount(r) - return &harness{provider: p, sessions: sessions, router: r, logs: logs} + return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs} } func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response { @@ -728,6 +740,66 @@ func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) { } } +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")) -- 2.55.0 From 8be5c56f6f6c095f5f6fd9fdb92f450846fd52c7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 11:50:15 -0400 Subject: [PATCH 22/33] Feature 04 task 3 (rebased): Household settings seeding and API (#27) Co-authored-by: dev_agent Co-committed-by: dev_agent --- README.md | 10 +- cmd/server/boot_test.go | 128 ++++++ cmd/server/main.go | 19 +- internal/config/config.go | 16 + internal/config/config_test.go | 15 + internal/db/models.go | 1 + internal/db/settings.sql.go | 13 +- internal/household/settings.go | 340 ++++++++++++++++ internal/household/settings_test.go | 374 ++++++++++++++++++ internal/household/zone.go | 28 ++ internal/server/csrf_test.go | 5 +- internal/server/login_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/server.go | 27 +- internal/server/server_test.go | 44 ++- internal/server/settings.go | 135 +++++++ internal/server/settings_test.go | 278 +++++++++++++ internal/store/migrate_test.go | 11 +- .../0002_household_settings_updated_by.sql | 4 + migrations/queries/settings.sql | 5 +- 20 files changed, 1423 insertions(+), 34 deletions(-) create mode 100644 internal/household/settings.go create mode 100644 internal/household/settings_test.go create mode 100644 internal/household/zone.go create mode 100644 internal/server/settings.go create mode 100644 internal/server/settings_test.go create mode 100644 migrations/0002_household_settings_updated_by.sql diff --git a/README.md b/README.md index d374368..f9f43e2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # 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 ``` @@ -51,11 +52,16 @@ make dev (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) and -trusted proxy list (every entry must be an IP or CIDR), and +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 diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 157d39f..37dc639 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -16,6 +16,7 @@ import ( "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" @@ -52,6 +53,11 @@ func loadConfig(t *testing.T, keyFile string) config.Config { 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) @@ -202,3 +208,125 @@ func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) { 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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 666cce7..e6feacc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "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/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -58,6 +59,11 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() + if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil { + return err + } + settings := household.NewSettingsService(db) + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -74,7 +80,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets()) if err != nil { return err } @@ -136,6 +142,17 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) { 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 +} + // 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) { diff --git a/internal/config/config.go b/internal/config/config.go index d3ab5e2..1c1dfd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,8 @@ const ( EnvSessionMax = "MEALQUEST_SESSION_MAX" EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" + + EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -65,6 +67,12 @@ type Config struct { // 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 @@ -97,6 +105,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { 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) @@ -323,6 +333,12 @@ func (r *reader) parseDuration(name, v string) time.Duration { 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8816bf7..937474d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -68,6 +68,16 @@ func TestLoad(t *testing.T) { 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), @@ -410,6 +420,8 @@ func TestLoadValues(t *testing.T) { 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 { @@ -465,6 +477,9 @@ func TestLoadValues(t *testing.T) { 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) { diff --git a/internal/db/models.go b/internal/db/models.go index 78206b0..a2f8121 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -85,6 +85,7 @@ type HouseholdSetting struct { UpdatesSeenAt sql.NullString CreatedAt string UpdatedAt string + UpdatedBy sql.NullString } type IntegrationsGoogle struct { diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 5bdc156..10c89fd 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -57,7 +57,7 @@ func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) { } 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 FROM household_settings +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 ` @@ -81,6 +81,7 @@ func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, e &i.UpdatesSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.UpdatedBy, ) return i, err } @@ -248,12 +249,12 @@ 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, - created_at, updated_at + updated_by, created_at, updated_at ) VALUES ( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? ) ON CONFLICT (id) DO UPDATE SET timezone = excluded.timezone, @@ -268,8 +269,9 @@ ON CONFLICT (id) DO UPDATE SET 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 +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 { @@ -285,6 +287,7 @@ type UpsertHouseholdSettingsParams struct { ChatAutosendTime string OnboardingCompletedAt sql.NullString UpdatesSeenAt sql.NullString + UpdatedBy sql.NullString CreatedAt string UpdatedAt string } @@ -305,6 +308,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho arg.ChatAutosendTime, arg.OnboardingCompletedAt, arg.UpdatesSeenAt, + arg.UpdatedBy, arg.CreatedAt, arg.UpdatedAt, ) @@ -325,6 +329,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho &i.UpdatesSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.UpdatedBy, ) return i, err } 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/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/server/csrf_test.go b/internal/server/csrf_test.go index 7e1dd54..53a69b9 100644 --- a/internal/server/csrf_test.go +++ b/internal/server/csrf_test.go @@ -34,9 +34,10 @@ func TestCSRFThroughRouter(t *testing.T) { {"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. + // 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.StatusNotFound, notFoundJSON}, + {"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}, diff --git a/internal/server/login_test.go b/internal/server/login_test.go index d928de7..0b8ada9 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -82,7 +82,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { if err != nil { t.Fatal(err) } - r, err := NewRouter(logger, "dev", trusted, flow, sessions, + r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 1df8212..e4d0030 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -44,7 +44,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { t.Fatal(err) } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) - r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, + r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 46520c2..c296893 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" + "errors" "io/fs" "log/slog" "net/http" @@ -11,6 +12,7 @@ import ( "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" ) // reservedPrefixes are owned by the API router and are never answered with the @@ -26,9 +28,13 @@ type RouteMounter interface { // 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; 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, assets fs.FS) (http.Handler, error) { +// /media; settings serves /api/v1/settings/household; 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, assets fs.FS) (http.Handler, error) { + if settings == nil { + return nil, errors.New("server: nil household settings service") + } notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found") methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -55,6 +61,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a sub.NotFound(notFound) } apiV1.Get("/me", me(logger)) + apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) + apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) @@ -80,19 +88,8 @@ type apiError struct { // apiErrorHandler responds with status and a fixed, non-retryable API error. func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc { - body, err := json.Marshal(errorBody{Error: apiError{ - Code: code, - Message: message, - }}) - if err != nil { - panic(err) // fixed string and bool fields cannot fail to marshal - } return func(w http.ResponseWriter, r *http.Request) { - 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) - } + writeError(w, r, logger, status, code, message) } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7419658..5b59d2d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "io" "log/slog" @@ -12,6 +13,10 @@ import ( "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/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) const ( @@ -21,13 +26,18 @@ const ( ) func newTestRouter(t *testing.T, version string) http.Handler { + t.Helper() + return newTestRouterWith(t, version, newTestSettings(t)) +} + +func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) 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{}, assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets) if err != nil { t.Fatal(err) } @@ -60,6 +70,38 @@ func TestAuthRoutesMounted(t *testing.T) { } } +// 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() + 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) + } + return household.NewSettingsService(st) +} + +func TestNewRouterRequiresSettings(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil settings service") + } +} + func TestHealth(t *testing.T) { t.Parallel() 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..2fdfbca --- /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) + + 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) + 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), + 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/migrate_test.go b/internal/store/migrate_test.go index bff84c5..cc4f356 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.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" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got) } } @@ -188,15 +188,16 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { t.Fatal(err) } // A database migrated by the bootstrap-only build upgrades to the - // embedded set, which adds 0001_init.sql alongside the unchanged bootstrap. + // 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" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") 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/queries/settings.sql b/migrations/queries/settings.sql index 2b39e69..171be84 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -10,12 +10,12 @@ 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, - created_at, updated_at + updated_by, created_at, updated_at ) VALUES ( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? ) ON CONFLICT (id) DO UPDATE SET timezone = excluded.timezone, @@ -30,6 +30,7 @@ ON CONFLICT (id) DO UPDATE SET 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 *; -- 2.55.0 From 3ba78d47bc3db620de608eba4aadbe1ec2e1f284 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 14:58:30 -0400 Subject: [PATCH 23/33] Feature 04 task 4: Default seeding for AI settings and notification preferences (#28) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/boot_test.go | 38 +++++ cmd/server/main.go | 14 +- internal/household/defaults.go | 93 ++++++++++++ internal/household/defaults_test.go | 220 ++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 internal/household/defaults.go create mode 100644 internal/household/defaults_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 37dc639..082b579 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -330,3 +330,41 @@ func TestSeedSettingsAtBoot(t *testing.T) { 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/main.go b/cmd/server/main.go index e6feacc..e532ea8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,7 +59,7 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() - if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil { + if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil { return err } settings := household.NewSettingsService(db) @@ -153,6 +153,18 @@ func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error 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) +} + // 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) { 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) + } + } +} -- 2.55.0 From e050736178aef191871448ca6536e62cb5e9595c Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 15:24:01 -0400 Subject: [PATCH 24/33] Feature 04 task 5: Async job runner and GET /api/v1/jobs/{id} (#29) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/jobs_test.go | 62 ++++ cmd/server/main.go | 51 ++- internal/db/jobs.sql.go | 166 +++++++++ internal/db/querier.go | 12 + internal/jobs/builtin.go | 44 +++ internal/jobs/jobs.go | 410 ++++++++++++++++++++++ internal/jobs/jobs_test.go | 570 +++++++++++++++++++++++++++++++ internal/server/jobs.go | 29 ++ internal/server/jobs_test.go | 153 +++++++++ internal/server/login_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/server.go | 10 +- internal/server/server_test.go | 44 ++- internal/server/settings_test.go | 6 +- internal/store/migrate_test.go | 55 ++- internal/store/schema_test.go | 4 +- migrations/0003_jobs_status.sql | 32 ++ migrations/queries/jobs.sql | 37 ++ 18 files changed, 1667 insertions(+), 22 deletions(-) create mode 100644 cmd/server/jobs_test.go create mode 100644 internal/db/jobs.sql.go create mode 100644 internal/jobs/builtin.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/jobs/jobs_test.go create mode 100644 internal/server/jobs.go create mode 100644 internal/server/jobs_test.go create mode 100644 migrations/0003_jobs_status.sql create mode 100644 migrations/queries/jobs.sql 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 index e532ea8..4f46d29 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "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" @@ -27,6 +28,9 @@ import ( // 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 { @@ -63,6 +67,10 @@ func run(cfg config.Config, logger *slog.Logger) error { 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 { @@ -80,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets()) + 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, @@ -165,6 +185,35 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error 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) { 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/querier.go b/internal/db/querier.go index bc63ad5..f283584 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -6,17 +6,25 @@ 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. @@ -26,6 +34,7 @@ type Querier interface { 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) @@ -36,8 +45,11 @@ type Querier interface { 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 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..929d7f3 --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,410 @@ +// 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") + +// 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) + +// 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]runFunc + 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]runFunc), + }, 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) + } + r.handlers[kind] = func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) { + var p P + dec := json.NewDecoder(bytes.NewReader([]byte(payload))) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); 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, "", "" + } + return &Kind[P]{r: r, name: kind}, nil +} + +// 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) + } + id := store.NewID() + now := store.Now().String() + if err := db.New(k.r.st.Writer).CreateJob(ctx, db.CreateJobParams{ + ID: id, + Kind: k.name, + Payload: string(data), + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return "", fmt.Errorf("jobs: enqueue %s: %w", k.name, err) + } + select { + case k.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(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..714aac4 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,570 @@ +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) + } +} diff --git a/internal/server/jobs.go b/internal/server/jobs.go new file mode 100644 index 0000000..2fdd15f --- /dev/null +++ b/internal/server/jobs.go @@ -0,0 +1,29 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// 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..6898242 --- /dev/null +++ b/internal/server/jobs_test.go @@ -0,0 +1,153 @@ +package server + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "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) + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 0b8ada9..88b60a5 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index e4d0030..b686164 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index c296893..a01adf3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,6 +13,7 @@ import ( "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 @@ -28,13 +29,17 @@ type RouteMounter interface { // 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; assets is the web build +// /media; settings serves /api/v1/settings/household; runner serves +// /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, assets fs.FS) (http.Handler, error) { +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") @@ -63,6 +68,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/me", me(logger)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) + apiV1.Get("/jobs/{id}", getJob(logger, runner)) r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5b59d2d..136d9f8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -15,6 +15,7 @@ import ( "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" ) @@ -27,17 +28,17 @@ const ( func newTestRouter(t *testing.T, version string) http.Handler { t.Helper() - return newTestRouterWith(t, version, newTestSettings(t)) + return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t)) } -func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler { +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, assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets) if err != nil { t.Fatal(err) } @@ -70,9 +71,8 @@ func TestAuthRoutesMounted(t *testing.T) { } } -// newTestSettings returns a settings service over a migrated temp database -// seeded with zone America/New_York. -func newTestSettings(t *testing.T) *household.SettingsService { +// 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()) @@ -87,21 +87,49 @@ func newTestSettings(t *testing.T) *household.SettingsService { 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 { + 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, fstest.MapFS{"index.html": {}}); err == 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() diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 2fdfbca..e9da996 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -84,7 +84,7 @@ func TestGetHouseholdSettings(t *testing.T) { func TestPatchHouseholdSettings(t *testing.T) { t.Parallel() settings := newTestSettings(t) - router := newTestRouterWith(t, "dev", settings) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) var notified []household.Settings settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) @@ -146,7 +146,7 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() settings := newTestSettings(t) - router := newTestRouterWith(t, "dev", settings) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) notified := 0 settings.Subscribe(func(household.Settings) { notified++ }) @@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index cc4f356..e104996 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.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" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got) + 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) } } @@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { 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" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got) + 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") @@ -262,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) { 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/schema_test.go b/internal/store/schema_test.go index ed61047..2b36ad1 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any { r["attempts"] = 0 case "jobs": r["kind"] = "suggest" - r["status"] = "pending" + r["status"] = "queued" r["payload"] = "{}" case "schedule_runs": r["schedule"] = "plan_draft" @@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"notification_prefs", "chat", 2}, {"notification_outbox", "channel", "email"}, {"notification_outbox", "status", "queued"}, - {"jobs", "status", "queued"}, + {"jobs", "status", "bogus"}, {"schedule_runs", "schedule", "hourly"}, } s := seededStore(t) 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/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 = ?; -- 2.55.0 From bba9033ac5c9f12fd9bcd40f38f47235e9f85f1c Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 15:46:19 -0400 Subject: [PATCH 25/33] Feature 04 integration fix: authenticated job enqueue API and progressive HTTP flow (#31) Co-authored-by: dev_agent Co-committed-by: dev_agent --- internal/jobs/jobs.go | 77 ++++++++++++++++++++++++----- internal/jobs/jobs_test.go | 36 ++++++++++++++ internal/server/jobs.go | 78 ++++++++++++++++++++++++++++++ internal/server/jobs_test.go | 93 ++++++++++++++++++++++++++++++++++++ internal/server/server.go | 3 +- 5 files changed, 273 insertions(+), 14 deletions(-) diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 929d7f3..337addc 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -68,6 +68,14 @@ 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 @@ -106,6 +114,13 @@ type Job struct { // 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 @@ -118,7 +133,7 @@ type Runner struct { claim func(ctx context.Context, now sql.NullString) (db.Job, error) mu sync.Mutex - handlers map[string]runFunc + handlers map[string]handler started bool wg sync.WaitGroup @@ -141,7 +156,7 @@ func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, erro workers: workers, wake: make(chan struct{}, workers), claim: db.New(st.Writer).ClaimNextJob, - handlers: make(map[string]runFunc), + handlers: make(map[string]handler), }, nil } @@ -179,11 +194,9 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC if _, ok := r.handlers[kind]; ok { return nil, fmt.Errorf("jobs: register %q: kind already registered", kind) } - r.handlers[kind] = func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) { - var p P - dec := json.NewDecoder(bytes.NewReader([]byte(payload))) - dec.DisallowUnknownFields() - if err := dec.Decode(&p); err != nil { + 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 { @@ -209,9 +222,23 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC } 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) { @@ -229,19 +256,43 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) { 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(k.r.st.Writer).CreateJob(ctx, db.CreateJobParams{ + if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{ ID: id, - Kind: k.name, - Payload: string(data), + Kind: kind, + Payload: string(payload), CreatedAt: now, UpdatedAt: now, }); err != nil { - return "", fmt.Errorf("jobs: enqueue %s: %w", k.name, err) + return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err) } select { - case k.r.wake <- struct{}{}: + case r.wake <- struct{}{}: default: // every worker already has a token and will look again } return id, nil @@ -365,7 +416,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) { return nil } - result, code, reason := h(ctx, job.Payload, publish) + 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") diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index 714aac4..1ab0822 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -568,3 +568,39 @@ func TestClaimErrorIsRetried(t *testing.T) { 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/server/jobs.go b/internal/server/jobs.go index 2fdd15f..9f33d16 100644 --- a/internal/server/jobs.go +++ b/internal/server/jobs.go @@ -1,7 +1,11 @@ package server import ( + "bytes" + "encoding/json" "errors" + "fmt" + "io" "log/slog" "net/http" @@ -10,6 +14,80 @@ import ( "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 { diff --git a/internal/server/jobs_test.go b/internal/server/jobs_test.go index 6898242..7e8005b 100644 --- a/internal/server/jobs_test.go +++ b/internal/server/jobs_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "testing" "time" @@ -151,3 +152,95 @@ func TestGetJobRequiresSession(t *testing.T) { 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/server.go b/internal/server/server.go index a01adf3..b1f3049 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,7 +30,7 @@ type RouteMounter interface { // 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 -// /api/v1/jobs/{id}; assets is the web build +// 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) { @@ -68,6 +68,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a 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) -- 2.55.0 From 496f2e5375b35580be818e749d0eadbf4aefd54e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 16:16:25 -0400 Subject: [PATCH 26/33] Feature 05 task 1: Design tokens and self-hosted fonts (#32) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/package.json | 6 +- web/scripts/check-dist-fonts.mjs | 32 +++++ web/scripts/lint-colors.mjs | 42 +++++++ web/src/assets/fonts/BagelFatOne-Latin.woff2 | Bin 0 -> 24376 bytes .../assets/fonts/BagelFatOne-LatinExt.woff2 | Bin 0 -> 5048 bytes web/src/assets/fonts/BagelFatOne-OFL.txt | 93 ++++++++++++++ web/src/assets/fonts/Figtree-Bold.woff2 | Bin 0 -> 23620 bytes web/src/assets/fonts/Figtree-OFL.txt | 93 ++++++++++++++ web/src/assets/fonts/Figtree-Regular.woff2 | Bin 0 -> 23412 bytes web/src/assets/fonts/Figtree-SemiBold.woff2 | Bin 0 -> 23580 bytes web/src/main.tsx | 2 + web/src/styles/base.css | 11 ++ web/src/styles/tokens.css | 116 ++++++++++++++++++ 13 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 web/scripts/check-dist-fonts.mjs create mode 100644 web/scripts/lint-colors.mjs create mode 100644 web/src/assets/fonts/BagelFatOne-Latin.woff2 create mode 100644 web/src/assets/fonts/BagelFatOne-LatinExt.woff2 create mode 100644 web/src/assets/fonts/BagelFatOne-OFL.txt create mode 100644 web/src/assets/fonts/Figtree-Bold.woff2 create mode 100644 web/src/assets/fonts/Figtree-OFL.txt create mode 100644 web/src/assets/fonts/Figtree-Regular.woff2 create mode 100644 web/src/assets/fonts/Figtree-SemiBold.woff2 create mode 100644 web/src/styles/base.css create mode 100644 web/src/styles/tokens.css diff --git a/web/package.json b/web/package.json index 3ea1eed..e5bf6d4 100644 --- a/web/package.json +++ b/web/package.json @@ -5,10 +5,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && npm run check:dist-fonts", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run" + "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", 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/assets/fonts/BagelFatOne-Latin.woff2 b/web/src/assets/fonts/BagelFatOne-Latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bd963048947c0090529e062f3b47838027e159c8 GIT binary patch literal 24376 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 D>B?1T literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f3bd5008a0be9020ccdf8388eaa064bcca21f0d3 GIT binary patch literal 5048 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 O4aPIRmsVv2TLA#@xwvNl literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..81bcca4c7412016dc1e59c1d03aa0dbcda755990 GIT binary patch literal 23620 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2c1165fb81cf8f4335586fb11d9f4755f215f3ad GIT binary patch literal 23412 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/Figtree-SemiBold.woff2 b/web/src/assets/fonts/Figtree-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8855ccb99956d6313585bfa1f1b87676810c1211 GIT binary patch literal 23580 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 DUuGa3 literal 0 HcmV?d00001 diff --git a/web/src/main.tsx b/web/src/main.tsx index 1a842db..a2dcd7b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,8 @@ 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' const root = document.getElementById('root') diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..ac545c1 --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,11 @@ +/* App-wide base styles. Values come only from tokens.css. */ +body { + 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; } +} -- 2.55.0 From 02f1dd6491a072fee429d6d5243dd33bb6a7d8e8 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 16:34:59 -0400 Subject: [PATCH 27/33] Feature 05 task 2: Core UI primitives (#34) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/test/hitArea.ts | 26 +++++++++ web/src/ui/Button.module.css | 63 ++++++++++++++++++++++ web/src/ui/Button.test.tsx | 53 +++++++++++++++++++ web/src/ui/Button.tsx | 16 ++++++ web/src/ui/Card.module.css | 13 +++++ web/src/ui/Card.test.tsx | 18 +++++++ web/src/ui/Card.tsx | 14 +++++ web/src/ui/Checkbox.module.css | 63 ++++++++++++++++++++++ web/src/ui/Checkbox.test.tsx | 36 +++++++++++++ web/src/ui/Checkbox.tsx | 17 ++++++ web/src/ui/Chip.module.css | 35 ++++++++++++ web/src/ui/Chip.test.tsx | 26 +++++++++ web/src/ui/Chip.tsx | 24 +++++++++ web/src/ui/IconButton.module.css | 74 ++++++++++++++++++++++++++ web/src/ui/IconButton.test.tsx | 34 ++++++++++++ web/src/ui/IconButton.tsx | 33 ++++++++++++ web/src/ui/ListRow.module.css | 69 ++++++++++++++++++++++++ web/src/ui/ListRow.test.tsx | 57 ++++++++++++++++++++ web/src/ui/ListRow.tsx | 45 ++++++++++++++++ web/src/ui/Segmented.module.css | 35 ++++++++++++ web/src/ui/Segmented.test.tsx | 91 ++++++++++++++++++++++++++++++++ web/src/ui/Segmented.tsx | 76 ++++++++++++++++++++++++++ web/src/ui/Slider.module.css | 61 +++++++++++++++++++++ web/src/ui/Slider.test.tsx | 69 ++++++++++++++++++++++++ web/src/ui/Slider.tsx | 30 +++++++++++ web/src/ui/Tag.module.css | 21 ++++++++ web/src/ui/Tag.test.tsx | 18 +++++++ web/src/ui/Tag.tsx | 14 +++++ web/src/ui/TextInput.module.css | 46 ++++++++++++++++ web/src/ui/TextInput.test.tsx | 34 ++++++++++++ web/src/ui/TextInput.tsx | 21 ++++++++ web/src/ui/Toggle.module.css | 57 ++++++++++++++++++++ web/src/ui/Toggle.test.tsx | 63 ++++++++++++++++++++++ web/src/ui/Toggle.tsx | 42 +++++++++++++++ web/src/ui/index.ts | 11 ++++ web/vite.config.ts | 3 ++ 36 files changed, 1408 insertions(+) create mode 100644 web/src/test/hitArea.ts create mode 100644 web/src/ui/Button.module.css create mode 100644 web/src/ui/Button.test.tsx create mode 100644 web/src/ui/Button.tsx create mode 100644 web/src/ui/Card.module.css create mode 100644 web/src/ui/Card.test.tsx create mode 100644 web/src/ui/Card.tsx create mode 100644 web/src/ui/Checkbox.module.css create mode 100644 web/src/ui/Checkbox.test.tsx create mode 100644 web/src/ui/Checkbox.tsx create mode 100644 web/src/ui/Chip.module.css create mode 100644 web/src/ui/Chip.test.tsx create mode 100644 web/src/ui/Chip.tsx create mode 100644 web/src/ui/IconButton.module.css create mode 100644 web/src/ui/IconButton.test.tsx create mode 100644 web/src/ui/IconButton.tsx create mode 100644 web/src/ui/ListRow.module.css create mode 100644 web/src/ui/ListRow.test.tsx create mode 100644 web/src/ui/ListRow.tsx create mode 100644 web/src/ui/Segmented.module.css create mode 100644 web/src/ui/Segmented.test.tsx create mode 100644 web/src/ui/Segmented.tsx create mode 100644 web/src/ui/Slider.module.css create mode 100644 web/src/ui/Slider.test.tsx create mode 100644 web/src/ui/Slider.tsx create mode 100644 web/src/ui/Tag.module.css create mode 100644 web/src/ui/Tag.test.tsx create mode 100644 web/src/ui/Tag.tsx create mode 100644 web/src/ui/TextInput.module.css create mode 100644 web/src/ui/TextInput.test.tsx create mode 100644 web/src/ui/TextInput.tsx create mode 100644 web/src/ui/Toggle.module.css create mode 100644 web/src/ui/Toggle.test.tsx create mode 100644 web/src/ui/Toggle.tsx create mode 100644 web/src/ui/index.ts 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/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/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/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/Slider.module.css b/web/src/ui/Slider.module.css new file mode 100644 index 0000000..a557abf --- /dev/null +++ b/web/src/ui/Slider.module.css @@ -0,0 +1,61 @@ +/* The input is the 44 px tall hit area; the track and knob are drawn by the + vendor pseudo-elements. --slider-fill is set inline from the value. */ +.slider { + appearance: none; + display: block; + width: 100%; + min-width: 44px; + height: 44px; + margin: 0; + background: transparent; + cursor: pointer; +} + +.slider:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.slider::-webkit-slider-runnable-track { + height: 8px; + border-radius: var(--radius-pill); + background: linear-gradient( + to right, + var(--color-accent) var(--slider-fill), + var(--color-neutral-300) var(--slider-fill) + ); +} + +.slider::-webkit-slider-thumb { + appearance: none; + box-sizing: border-box; + width: 24px; + height: 24px; + margin-top: -8px; + border: 3px solid var(--color-accent); + border-radius: var(--radius-pill); + background: var(--color-bg); + box-shadow: var(--shadow-sm); +} + +.slider::-moz-range-track { + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-neutral-300); +} + +.slider::-moz-range-progress { + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-accent); +} + +.slider::-moz-range-thumb { + box-sizing: border-box; + width: 24px; + height: 24px; + border: 3px solid var(--color-accent); + border-radius: var(--radius-pill); + background: var(--color-bg); + box-shadow: var(--shadow-sm); +} diff --git a/web/src/ui/Slider.test.tsx b/web/src/ui/Slider.test.tsx new file mode 100644 index 0000000..ce95efb --- /dev/null +++ b/web/src/ui/Slider.test.tsx @@ -0,0 +1,69 @@ +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 { Slider } from './Slider.tsx' + +function Harness() { + const [value, setValue] = useState(0.7) + return ( + setValue(Number(event.target.value))} + /> + ) +} + +describe('Slider', () => { + it('is a native slider exposing its range and value', () => { + render() + const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' }) + expect(slider).toHaveAttribute('type', 'range') + expect(slider).toHaveAttribute('min', '0') + expect(slider).toHaveAttribute('max', '1') + expect(slider).toHaveValue('0.7') + }) + + it('updates the grape fill with the value', () => { + render() + const slider = screen.getByRole('slider') + expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%') + fireEvent.change(slider, { target: { value: '0.2' } }) + expect(slider).toHaveValue('0.2') + expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%') + }) + + it('has a ≥ 44 px hit area', () => { + render() + expectMinHitArea(screen.getByRole('slider')) + }) + + it('fails fast on an empty range', () => { + expect(() => render()).toThrow( + 'max (1) must be greater than min (1)', + ) + }) + + it('accepts values at both ends of the range', () => { + render( + <> + + + , + ) + expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%') + expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%') + }) + + it.each([ + [2, 'value (2) must be within [0, 1]'], + [-0.1, 'value (-0.1) must be within [0, 1]'], + [Number.NaN, 'value (NaN) must be within [0, 1]'], + ])('fails fast when the value %s is outside the range', (value, message) => { + expect(() => render()).toThrow(message) + }) +}) diff --git a/web/src/ui/Slider.tsx b/web/src/ui/Slider.tsx new file mode 100644 index 0000000..af430c8 --- /dev/null +++ b/web/src/ui/Slider.tsx @@ -0,0 +1,30 @@ +import type { CSSProperties, ComponentPropsWithRef } from 'react' +import styles from './Slider.module.css' + +export type SliderProps = Omit, 'type' | 'value' | 'min' | 'max'> & { + value: number + min: number + max: number +} + +// Native range input, styled: 8 px track, grape fill up to the value, 24 px +// knob with a 3 px grape ring. Controlled, because the fill follows `value`. +// Name it with aria-label or aria-labelledby. +export function Slider({ value, min, max, className, style, ...rest }: SliderProps) { + if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`) + if (!(value >= min && value <= max)) { + throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`) + } + const fill = `${((value - min) / (max - min)) * 100}%` + return ( + + ) +} diff --git a/web/src/ui/Tag.module.css b/web/src/ui/Tag.module.css new file mode 100644 index 0000000..a3df5e4 --- /dev/null +++ b/web/src/ui/Tag.module.css @@ -0,0 +1,21 @@ +.tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-pill); + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.grape { + background: var(--color-accent-100); + color: var(--color-accent-800); +} + +.teal { + background: var(--color-accent-2-200); + color: var(--color-accent-2-800); +} diff --git a/web/src/ui/Tag.test.tsx b/web/src/ui/Tag.test.tsx new file mode 100644 index 0000000..8c6f795 --- /dev/null +++ b/web/src/ui/Tag.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { Tag } from './Tag.tsx' +import styles from './Tag.module.css' + +describe('Tag', () => { + it('renders a static, non-interactive label', () => { + render(Draft) + const tag = screen.getByText('Draft') + expect(tag).toHaveClass(styles.grape!) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('supports the teal tone', () => { + render(Synced) + expect(screen.getByText('Synced')).toHaveClass(styles.teal!) + }) +}) diff --git a/web/src/ui/Tag.tsx b/web/src/ui/Tag.tsx new file mode 100644 index 0000000..5d26c95 --- /dev/null +++ b/web/src/ui/Tag.tsx @@ -0,0 +1,14 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Tag.module.css' + +export type TagTone = 'grape' | 'teal' + +export type TagProps = ComponentPropsWithRef<'span'> & { + tone?: TagTone +} + +// Static label such as "Draft", "Side quest" or "Synced". Not interactive. +export function Tag({ tone = 'grape', className, ...rest }: TagProps) { + const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ') + return +} diff --git a/web/src/ui/TextInput.module.css b/web/src/ui/TextInput.module.css new file mode 100644 index 0000000..e44b927 --- /dev/null +++ b/web/src/ui/TextInput.module.css @@ -0,0 +1,46 @@ +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.label { + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.input { + box-sizing: border-box; + min-width: 44px; + height: 48px; + padding: 0 20px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-body); + font-size: 16px; +} + +.input::placeholder { + color: var(--color-neutral-700); +} + +.input:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/web/src/ui/TextInput.test.tsx b/web/src/ui/TextInput.test.tsx new file mode 100644 index 0000000..f4c7a6b --- /dev/null +++ b/web/src/ui/TextInput.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { hitArea, expectMinHitArea } from '../test/hitArea.ts' +import { TextInput } from './TextInput.tsx' + +describe('TextInput', () => { + it('is a textbox named by its label', () => { + const onChange = vi.fn() + render() + const input = screen.getByRole('textbox', { name: 'Dinner name' }) + fireEvent.change(input, { target: { value: 'Chili' } }) + expect(onChange).toHaveBeenCalledOnce() + expect(input).toHaveValue('Chili') + }) + + it('keeps the accessible name when the label is visually hidden', () => { + render() + expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument() + }) + + it('is a 48 px pill with a ≥ 44 px hit area', () => { + render() + const input = screen.getByRole('textbox', { name: 'Dinner name' }) + expectMinHitArea(input) + expect(hitArea(input).height).toBe(48) + }) + + it('forwards the ref', () => { + const ref = createRef() + render() + expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' })) + }) +}) diff --git a/web/src/ui/TextInput.tsx b/web/src/ui/TextInput.tsx new file mode 100644 index 0000000..fd6ea01 --- /dev/null +++ b/web/src/ui/TextInput.tsx @@ -0,0 +1,21 @@ +import { useId, type ComponentPropsWithRef } from 'react' +import styles from './TextInput.module.css' + +export type TextInputProps = Omit, 'id'> & { + label: string + // Hide the label visually; it stays the input's accessible name. + labelHidden?: boolean +} + +// 48 px pill input on the surface fill, always associated with its label. +export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) { + const id = useId() + return ( +
+ + +
+ ) +} diff --git a/web/src/ui/Toggle.module.css b/web/src/ui/Toggle.module.css new file mode 100644 index 0000000..ddcd414 --- /dev/null +++ b/web/src/ui/Toggle.module.css @@ -0,0 +1,57 @@ +/* The button is a 44 px hit box around the 40×24 track; negative margins keep + the 40×24 layout footprint. */ +.toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 44px; + height: 44px; + margin: -10px -2px; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + cursor: pointer; +} + +.toggle:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.track { + position: relative; + width: 40px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--color-neutral-300); + transition: background-color 150ms var(--ease-out-soft); +} + +.knob { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: var(--radius-pill); + background: var(--color-bg); + box-shadow: var(--shadow-sm); + transition: transform 180ms var(--ease-spring); +} + +.toggle[aria-checked='true'] .track { + background: var(--color-accent-2-600); +} + +.toggle[aria-checked='true'] .knob { + transform: translateX(16px); +} + +@media (prefers-reduced-motion: reduce) { + .track, + .knob { + transition: none; + } +} diff --git a/web/src/ui/Toggle.test.tsx b/web/src/ui/Toggle.test.tsx new file mode 100644 index 0000000..8237227 --- /dev/null +++ b/web/src/ui/Toggle.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { hitArea, expectMinHitArea } from '../test/hitArea.ts' +import { Toggle } from './Toggle.tsx' + +function Harness() { + const [checked, setChecked] = useState(false) + return +} + +function toggle() { + return screen.getByRole('switch', { name: 'Push: New weekly plan ready' }) +} + +describe('Toggle', () => { + it('is a switch reflecting its state in aria-checked', () => { + render() + expect(toggle()).toHaveAttribute('aria-checked', 'false') + fireEvent.click(toggle()) + expect(toggle()).toHaveAttribute('aria-checked', 'true') + fireEvent.click(toggle()) + expect(toggle()).toHaveAttribute('aria-checked', 'false') + }) + + it.each([' ', 'Enter'])('toggles with the %j key', (key) => { + render() + fireEvent.keyDown(toggle(), { key }) + expect(toggle()).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(toggle(), { key }) + expect(toggle()).toHaveAttribute('aria-checked', 'false') + }) + + it('suppresses native activation for Space and Enter so a key press toggles once', () => { + render() + expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false) + expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false) + expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false) + expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true) + }) + + it('ignores other keys', () => { + const onCheckedChange = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' }) + expect(onCheckedChange).not.toHaveBeenCalled() + }) + + it('is disabled when asked', () => { + const onCheckedChange = vi.fn() + render() + const sw = screen.getByRole('switch', { name: 'Chat' }) + expect(sw).toBeDisabled() + fireEvent.click(sw) + expect(onCheckedChange).not.toHaveBeenCalled() + }) + + it('draws a 40×24 track inside a ≥ 44 px hit area', () => { + render() + expectMinHitArea(toggle()) + expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 }) + }) +}) diff --git a/web/src/ui/Toggle.tsx b/web/src/ui/Toggle.tsx new file mode 100644 index 0000000..e45979b --- /dev/null +++ b/web/src/ui/Toggle.tsx @@ -0,0 +1,42 @@ +import type { ComponentPropsWithRef, KeyboardEvent } from 'react' +import styles from './Toggle.module.css' + +export type ToggleProps = Omit< + ComponentPropsWithRef<'button'>, + 'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children' +> & { + checked: boolean + onCheckedChange: (checked: boolean) => void +} + +// 40×24 pill switch (role="switch"). Name it with aria-label or +// aria-labelledby. Space and Enter are handled here and their native button +// activation is suppressed, so each key press toggles exactly once. +export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) { + function onKeyDown(event: KeyboardEvent) { + if (event.key !== ' ' && event.key !== 'Enter') return + event.preventDefault() + onCheckedChange(!checked) + } + + function onKeyUp(event: KeyboardEvent) { + if (event.key === ' ') event.preventDefault() + } + + return ( + + ) +} diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts new file mode 100644 index 0000000..f0e6b63 --- /dev/null +++ b/web/src/ui/index.ts @@ -0,0 +1,11 @@ +export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx' +export { Card, type CardProps, type CardTone } from './Card.tsx' +export { Checkbox, type CheckboxProps } from './Checkbox.tsx' +export { Chip, type ChipProps } from './Chip.tsx' +export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx' +export { ListRow, type ListRowProps } from './ListRow.tsx' +export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx' +export { Slider, type SliderProps } from './Slider.tsx' +export { Tag, type TagProps, type TagTone } from './Tag.tsx' +export { TextInput, type TextInputProps } from './TextInput.tsx' +export { Toggle, type ToggleProps } from './Toggle.tsx' diff --git a/web/vite.config.ts b/web/vite.config.ts index 1a9192b..09250bb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ }, test: { environment: 'jsdom', + // Inject CSS Modules into jsdom so tests can assert the sizes the + // stylesheets actually declare (for example 44 px hit areas). + css: true, setupFiles: ['./src/test/setup.ts'], }, }) -- 2.55.0 From 732aa92a8c9b4ab9ecf8969d125a71a90657b714 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:22:59 -0400 Subject: [PATCH 28/33] Sheet, ErrorBanner, Skeleton and EmptyState (#36) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/test/cssRules.ts | 50 ++++++ web/src/ui/EmptyState.module.css | 30 ++++ web/src/ui/EmptyState.test.tsx | 27 +++ web/src/ui/EmptyState.tsx | 30 ++++ web/src/ui/ErrorBanner.module.css | 16 ++ web/src/ui/ErrorBanner.test.tsx | 32 ++++ web/src/ui/ErrorBanner.tsx | 21 +++ web/src/ui/Sheet.module.css | 57 +++++++ web/src/ui/Sheet.test.tsx | 267 ++++++++++++++++++++++++++++++ web/src/ui/Sheet.tsx | 123 ++++++++++++++ web/src/ui/Skeleton.module.css | 24 +++ web/src/ui/Skeleton.test.tsx | 36 ++++ web/src/ui/Skeleton.tsx | 12 ++ web/src/ui/index.ts | 4 + 14 files changed, 729 insertions(+) create mode 100644 web/src/test/cssRules.ts create mode 100644 web/src/ui/EmptyState.module.css create mode 100644 web/src/ui/EmptyState.test.tsx create mode 100644 web/src/ui/EmptyState.tsx create mode 100644 web/src/ui/ErrorBanner.module.css create mode 100644 web/src/ui/ErrorBanner.test.tsx create mode 100644 web/src/ui/ErrorBanner.tsx create mode 100644 web/src/ui/Sheet.module.css create mode 100644 web/src/ui/Sheet.test.tsx create mode 100644 web/src/ui/Sheet.tsx create mode 100644 web/src/ui/Skeleton.module.css create mode 100644 web/src/ui/Skeleton.test.tsx create mode 100644 web/src/ui/Skeleton.tsx diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts new file mode 100644 index 0000000..e8413aa --- /dev/null +++ b/web/src/test/cssRules.ts @@ -0,0 +1,50 @@ +// 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 +} 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/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