From e3d8c4d73d08df49d27f979344aa1f60d568b6dd Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:20:34 -0400 Subject: [PATCH 1/7] 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) + } +} From f0b2696059da18706ecf39646b7a94fa11b04c49 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:26:34 -0400 Subject: [PATCH 2/7] 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'], + }, +}) From 85569b451f090061fda2c73e7300140a9cb1d0e0 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 22:47:01 -0400 Subject: [PATCH 3/7] 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()) } } From c28dff18cd723091c1efea89f10d66f89f1aebfb Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 2 Oct 2026 23:06:58 -0400 Subject: [PATCH 4/7] 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) . From ec13423b6fd668e93d56822a47424279ab9be1b9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:12:39 -0400 Subject: [PATCH 5/7] 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 From 9f4537d0cb5013afdcfcc434b713aeb2e773b9c5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:26:15 -0400 Subject: [PATCH 6/7] 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. From 645b3c00b22d20cfb4184e97a1427e74e97dae19 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 00:36:48 -0400 Subject: [PATCH 7/7] 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