Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 04f12f52b9 Show every settings home status line (mock 1p review fixes)
CI / ci (pull_request) Successful in 1m56s
CI / ci (push) Successful in 1m55s
The Regular dinners, Google Calendar and Notifications & Google Chat rows
now show status lines and read them from the server:

- Regular dinners: "N on rotation" from GET /api/v1/dinners?status=regular
  (Feature 12). Until that endpoint exists the server answers 404 and the
  row says "Rotation count unavailable"; no count is invented.
- Google Calendar: "Connected · <calendar name>" from
  GET /api/v1/settings/calendar (Feature 16). Until then a 404 shows the
  truthful "Not connected".
- Notifications & Google Chat: "Push on · <Google Chat space>" from
  GET /api/v1/settings/notifications. Until then a 404 shows
  "Push off · Google Chat not connected".

Only a 404 not_found counts as a missing endpoint; any other failure or a
malformed response shows in the error banner.

The AI assistant row names the model as the handoff does ("Anthropic ·
Claude Sonnet"), shows an unknown model by its id, and says "No model
chosen" for the seeded empty model instead of a trailing separator.

Maestro task: 97b50e29-b8a7-4e99-918d-99fa1b239313

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:51 -04:00
dev_agentandClaude Opus 5.5 ef1bfcbf01 Add the settings home screen (mock 1p)
CI / ci (push) Successful in 1m57s
CI / ci (pull_request) Successful in 1m55s
/settings now renders the Settings home: the "Our kitchen" shared
household banner and grouped cards for Household (Regular dinners,
Google Calendar, Household), Alerts (Notifications & Google Chat) and
Admin (AI assistant, with the Admin tag and grape-300 inset ring). Each
row links to its settings page.

The Household row shows the live timezone and dinner time from
GET /api/v1/settings/household, and the AI assistant row the live
provider and model from GET /api/v1/settings/ai. Regular dinners,
Google Calendar and Notifications show no status line: the server has
no dinners, Google Calendar or notification endpoint yet.

Shell tests now render the routes inside the API and query providers.

Maestro task: 97b50e29-b8a7-4e99-918d-99fa1b239313

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:24:47 -04:00
dev_agent 8855091b30 AI settings API with key management (#80)
CI / ci (push) Successful in 2m1s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 09:16:09 -04:00
27 changed files with 2369 additions and 37 deletions
+7 -1
View File
@@ -13,6 +13,7 @@ import (
"syscall"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
@@ -83,6 +84,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -99,7 +105,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets())
if err != nil {
return err
}
+23
View File
@@ -152,6 +152,29 @@ automatic** (D-021).
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
masala" and returns `{query, count}`, 409 `not_configured`, or 502
`search_failed` with the provider's error.
- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider,
base_url, model, temperature, system_prompt, plus the read-only
`api_key_last4` and `key_verified_at`. PATCH requires a preset name as
provider, temperature 0–1 in 0.1 steps, a non-empty model and a
system_prompt of at most 8000 characters; a fixed-URL preset's base_url
must be empty or its own. Provider and base_url cannot change while a
key is stored (remove the key first), so a stored key is never sent to
an endpoint it was not stored for.
`PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least
8 characters, sealed with the secret box; an empty string removes it) and
clears `key_verified_at`; a preset without a fixed base_url takes a key
only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls
`Provider.Verify` and records `key_verified_at`;
`GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}`
live from the provider, with the key redacted from every id and
display_name. Both work before a model is chosen
(`presets.ForAccount`), answer 409 `not_configured` when the settings
cannot build a provider, and 502 with the provider's typed error
(`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504
`timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt`
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
+478
View File
@@ -0,0 +1,478 @@
// Package aisettings reads and changes the household's AI settings — the
// ai_settings row seeded by household.SeedAISettings — and builds the
// provider and harness.Config they describe. The API key is sealed with the
// secret box and never leaves the package except to the provider.
package aisettings
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"math"
"net/url"
"strings"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// MaxSystemPromptChars is the longest system prompt accepted, in
// characters.
MaxSystemPromptChars = 8000
// MinKeyLength is the shortest API key accepted, so its last four
// characters never give away most of it.
MinKeyLength = 8
)
var (
// ErrNotConfigured is returned when the stored AI settings cannot
// build a provider, e.g. there is no API key yet. It is joined with
// the llm configuration error that says why.
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
// ErrProviderFailed is returned when a call to the provider fails. It
// is joined with the provider's error, so the llm typed error it
// matches says why.
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
// ErrChanged is returned when the AI settings changed while
// their key was being verified, so the result applies to old settings.
ErrChanged = errors.New("aisettings: AI settings changed during verification")
)
// Settings is the household's AI configuration as the API shows it. The
// API key is never part of it: only its last four characters.
type Settings struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
// SystemPrompt is the admin persona, nil when none is stored.
SystemPrompt *string `json:"system_prompt"`
// APIKeyLast4 is nil when no key is stored.
APIKeyLast4 *string `json:"api_key_last4"`
// KeyVerifiedAt is when the stored key was last verified with these
// provider settings, nil when it has not been.
KeyVerifiedAt *string `json:"key_verified_at"`
UpdatedAt string `json:"updated_at"`
}
// Patch changes the settings. A nil field is left as it is.
type Patch struct {
Provider *string `json:"provider"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Temperature *float64 `json:"temperature"`
SystemPrompt *string `json:"system_prompt"`
}
// Model is one model the provider offers.
type Model struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every problem with an Patch or API
// key, or with the settings it would leave. No problem quotes the key.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid AI settings: " + strings.Join(parts, "; ")
}
// Service reads and changes the ai_settings row and builds the provider it
// names. API keys are sealed with its secret box and never leave it except
// to the provider.
type Service struct {
st *store.Store
box *secretbox.Box
publicURL string
// newProvider and newAccount are presets.FromSettings and
// presets.ForAccount; tests substitute a fake provider.
newProvider harness.NewProvider
newAccount func(llm.Settings, string) (presets.Account, error)
}
// New returns the service over st, sealing keys with box.
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("aisettings: nil store")
case box == nil:
return nil, errors.New("aisettings: nil secret box")
case publicURL == "":
return nil, errors.New("aisettings: empty public URL")
}
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
}
// Get returns the settings.
func (s *Service) Get(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
return settingsFrom(row), nil
}
// Update applies patch and returns the settings it leaves. The provider
// and base URL cannot change while an API key is stored, so the key never
// reaches an endpoint it was not stored for. Every problem is reported in
// a *ValidationError and nothing changes.
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
return applyPatch(row, patch)
})
}
// SetAPIKey seals key and records its last four characters, clearing
// key_verified_at. An empty key removes the stored one. A preset without
// a fixed base URL takes a key only when its base URL is secure (see
// secureurl.Check).
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
if key == "" {
return row, nil
}
p, _ := presets.Lookup(row.Provider)
var reason string
switch {
case p.Key == presets.KeyNone:
reason = row.Provider + " takes no API key"
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
case !validHeaderValue(key):
reason = "has a character not allowed in a header"
case len(key) < MinKeyLength:
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
}
if reason != "" {
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
}
ct, err := s.box.Encrypt([]byte(key))
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
}
row.ApiKeyCt = ct
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
return row, nil
})
}
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
return row, nil
})
}
// Verify checks the stored key with the provider and, when it is
// accepted, records key_verified_at. Settings that cannot build a
// provider fail with ErrNotConfigured; a provider failure matches
// ErrProviderFailed and the llm typed error the provider returned. If the
// settings change while the provider is asked, nothing is recorded and the
// error is ErrChanged.
func (s *Service) Verify(ctx context.Context) (Settings, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return Settings{}, err
}
acct, key, err := s.account(row)
if err != nil {
return Settings{}, err
}
if err := acct.Verify(ctx); err != nil {
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
}
verified := store.Now().String()
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
return db.AiSetting{}, ErrChanged
}
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
return now, nil
})
}
// Models returns the provider's live model list with the API key removed
// from every model's ID and display name. Its errors are Verify's.
func (s *Service) Models(ctx context.Context) ([]Model, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return nil, err
}
acct, key, err := s.account(row)
if err != nil {
return nil, err
}
models, err := acct.ListModels(ctx)
if err != nil {
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
}
out := make([]Model, len(models))
for i, m := range models {
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
}
return out, nil
}
// HarnessConfig returns the harness.Config for a skill run with the
// current settings, sharing guard. Read it for each run, so a run always
// uses the latest saved provider, model, temperature and persona.
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
row, err := s.read(ctx, db.New(s.st.Reader))
if err != nil {
return harness.Config{}, err
}
key, err := s.key(row)
if err != nil {
return harness.Config{}, err
}
return harness.Config{
Settings: s.llmSettings(row),
APIKey: key,
NewProvider: s.newProvider,
Persona: row.SystemPrompt.String,
Store: s.st,
Guard: guard,
}, nil
}
// account builds the provider's Account from row, returning the key too
// so the caller can redact it.
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
key, err := s.key(row)
if err != nil {
return nil, "", err
}
acct, err := s.newAccount(s.llmSettings(row), key)
if err != nil {
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
}
return acct, key, nil
}
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
return llm.Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
PublicURL: s.publicURL,
}
}
// key opens row's API key; "" when none is stored.
func (s *Service) key(row db.AiSetting) (string, error) {
if len(row.ApiKeyCt) == 0 {
return "", nil
}
key, err := s.box.Decrypt(row.ApiKeyCt)
if err != nil {
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
}
return string(key), nil
}
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
row, err := q.GetAISettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
}
if err != nil {
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
}
return row, nil
}
// change saves edit's result for the current row in one transaction.
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
var saved db.AiSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := s.read(ctx, q)
if err != nil {
return err
}
next, err := edit(row)
if err != nil {
return err
}
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
Provider: next.Provider,
BaseUrl: next.BaseUrl,
Model: next.Model,
Temperature: next.Temperature,
SystemPrompt: next.SystemPrompt,
ApiKeyCt: next.ApiKeyCt,
ApiKeyLast4: next.ApiKeyLast4,
KeyVerifiedAt: next.KeyVerifiedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: store.Now().String(),
})
if err != nil {
return fmt.Errorf("aisettings: save AI settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
return settingsFrom(saved), nil
}
// applyPatch returns row with patch applied, validated.
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
if patch == (Patch{}) {
fail("settings", "no settings to change")
}
before := row
if patch.Provider != nil {
if _, ok := presets.Lookup(*patch.Provider); ok {
row.Provider = *patch.Provider
} else {
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
}
}
if patch.BaseURL != nil {
row.BaseUrl = *patch.BaseURL
}
if patch.Model != nil {
switch m := *patch.Model; {
case strings.TrimSpace(m) == "":
fail("model", "must not be empty")
case !validHeaderValue(m):
fail("model", "must not contain control characters")
default:
row.Model = m
}
}
if patch.Temperature != nil {
t := *patch.Temperature
tenths := math.Round(t * 10)
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
fail("temperature", "must be between 0 and 1 in steps of 0.1")
} else {
row.Temperature = tenths / 10
}
}
if patch.SystemPrompt != nil {
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
} else {
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
}
}
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
switch {
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
case p.BaseURL == "" && row.BaseUrl != "":
// The preset's own check of the URL; its errors never quote it.
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
fail("base_url", err.Error())
}
}
}
// The stored key is only ever sent where it was stored for: moving it
// to another provider or server would let a changed base URL receive
// it. Remove the key, change the endpoint, then store the new key.
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
if row.Provider != before.Provider {
fail("provider", "cannot change while an API key is stored: remove the key first")
}
if row.BaseUrl != before.BaseUrl {
fail("base_url", "cannot change while an API key is stored: remove the key first")
}
}
if len(problems) > 0 {
return db.AiSetting{}, &ValidationError{Problems: problems}
}
return row, nil
}
func settingsFrom(row db.AiSetting) Settings {
return Settings{
Provider: row.Provider,
BaseURL: row.BaseUrl,
Model: row.Model,
Temperature: row.Temperature,
SystemPrompt: nullString(row.SystemPrompt),
APIKeyLast4: nullString(row.ApiKeyLast4),
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
UpdatedAt: row.UpdatedAt,
}
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
// secureBaseURL reports whether base is a URL a key may be sent to.
func secureBaseURL(base string) bool {
u, err := url.Parse(base)
return base != "" && err == nil && secureurl.Check(u) == nil
}
// validHeaderValue reports whether value has no control character other
// than tab.
func validHeaderValue(value string) bool {
for i := 0; i < len(value); i++ {
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
return false
}
}
return true
}
// redactedError is an error with the API key removed from its text. It
// does not unwrap, because the original may hold the key; Is still matches
// the sentinels the original wraps.
type redactedError struct {
text string
err error
}
func (e *redactedError) Error() string { return e.text }
// Is reports whether the original error matches target.
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
// redact returns err with key removed from its text; an empty key returns
// err unchanged.
func redact(err error, key string) error {
if key == "" {
return err
}
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
}
+463
View File
@@ -0,0 +1,463 @@
package aisettings
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const testKey = "sk-ant-test-secret-key-41ab"
// newService returns a Service over a migrated store with the seed AI
// settings.
func newService(t *testing.T) (*Service, *store.Store) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedAISettings(ctx, st); err != nil {
t.Fatal(err)
}
raw := make([]byte, secretbox.KeySize)
rand.Read(raw)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatal(err)
}
s, err := New(st, box, "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return s, st
}
// useFake makes s build f for every run, verification and model list, and
// returns a func reporting the settings and key each build was given.
func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) {
var mu sync.Mutex
var settings []llm.Settings
var keys []string
record := func(set llm.Settings, key string) {
mu.Lock()
defer mu.Unlock()
settings = append(settings, set)
keys = append(keys, key)
}
s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) {
record(set, key)
return f, nil
}
s.newAccount = func(set llm.Settings, key string) (presets.Account, error) {
record(set, key)
return f, nil
}
return func() ([]llm.Settings, []string) {
mu.Lock()
defer mu.Unlock()
return settings, keys
}
}
func ptr[T any](v T) *T { return &v }
// echoSkill asks the model to repeat a word; its prompt is the harness's
// test template prompts/echo.v1.tmpl.
type echoSkill struct{}
type echoInput struct{ Word string }
type echoOutput struct {
Echo string `json:"echo"`
}
func (echoSkill) Name() string { return "echo" }
func (echoSkill) Version() int { return 1 }
func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) {
return harness.PromptInput{Data: in}, nil
}
func (echoSkill) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "echo", Schema: json.RawMessage(
`{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)}
}
func (echoSkill) Validate(echoOutput) []string { return nil }
func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil }
func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout }
// A PATCHed model and temperature are what the next skill run uses: the
// provider is built with them, the request carries the temperature and the
// ai_runs row records both. A second PATCH changes the run after it.
func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
f := &llm.Fake{}
built := useFake(s, f)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for i, want := range []struct {
model string
temp float64
}{{"claude-patched", 0.3}, {"claude-repatched", 1}} {
if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil {
t.Fatal(err)
}
cfg, err := s.HarnessConfig(ctx, guard)
if err != nil {
t.Fatal(err)
}
f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}})
if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil {
t.Fatalf("run %d: %v", i, err)
}
settings, keys := built()
if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey {
t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp)
}
req := f.Requests()[i]
if req.Temperature == nil || *req.Temperature != want.temp {
t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp)
}
if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) {
t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System)
}
var model string
var temp float64
err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp)
if err != nil {
t.Fatal(err)
}
if model != want.model || temp != want.temp {
t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp)
}
}
}
func TestUpdateValidates(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
name string
patch Patch
field string
}{
{"empty", Patch{}, "settings"},
{"unknown provider", Patch{Provider: ptr("gemini")}, "provider"},
{"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"},
{"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"},
{"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"},
{"empty model", Patch{Model: ptr(" ")}, "model"},
{"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"},
{"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"},
{"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"},
} {
t.Run(c.name, func(t *testing.T) {
s, _ := newService(t)
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
_, err = s.Update(ctx, c.patch)
var invalid *ValidationError
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field {
t.Fatalf("Update = %v, want one %s problem", err, c.field)
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("settings changed by a refused patch: %+v, %v", after, err)
}
})
}
}
func TestUpdateAccepts(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} {
got, err := s.Update(ctx, Patch{Temperature: ptr(temp)})
if err != nil || got.Temperature != temp {
t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err)
}
}
prompt := strings.Repeat("ü", MaxSystemPromptChars)
if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt {
t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err)
}
for _, p := range presets.Names() {
patch := Patch{Provider: ptr(p), BaseURL: ptr("")}
if pr, _ := presets.Lookup(p); pr.BaseURL == "" {
patch.BaseURL = ptr("http://llm.local:11434")
}
if got, err := s.Update(ctx, patch); err != nil || got.Provider != p {
t.Errorf("provider %s: %+v, %v", p, got, err)
}
}
}
// While a key is stored the provider and base URL cannot change, so the
// key never reaches an endpoint it was not stored for; the model,
// temperature and system prompt can, keeping the verification.
func TestEndpointLockedWhileKeyStored(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(nil)
if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil {
t.Fatalf("Verify = %+v, %v", got, err)
}
got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")})
if err != nil || got.KeyVerifiedAt == nil {
t.Errorf("model change = %+v, %v; want verification kept", got, err)
}
before, err := s.Get(ctx)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
patch Patch
field string
}{
{Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"},
{Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"},
{Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"},
} {
var invalid *ValidationError
_, err := s.Update(ctx, c.patch)
if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) {
t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field)
}
}
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err)
}
// Removing the key frees the endpoint; a new key is unverified.
if _, err := s.SetAPIKey(ctx, ""); err != nil {
t.Fatal(err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil {
t.Fatal(err)
}
got, err = s.SetAPIKey(ctx, "sk-another-key-9999")
if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" {
t.Errorf("new key = %+v, %v; want it unverified", got, err)
}
}
// A key is never stored for a base URL it would cross the network to in
// plain text.
func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) {
ctx := context.Background()
for _, c := range []struct {
base string
ok bool
}{
{"", false},
{"http://llm.lan:8080/v1", false},
{"http://127.0.0.1:8080/v1", true},
{"https://llm.example/v1", true},
} {
s, _ := newService(t)
patch := Patch{Provider: ptr("openai_compatible")}
if c.base != "" {
patch.BaseURL = ptr(c.base)
}
if _, err := s.Update(ctx, patch); err != nil {
t.Fatal(err)
}
_, err := s.SetAPIKey(ctx, testKey)
var invalid *ValidationError
if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) {
t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok)
}
}
}
func TestVerifyReturnsProviderTypedError(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} {
f.QueueVerify(want)
_, err := s.Verify(ctx)
if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) {
t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want)
}
if err != nil && strings.Contains(err.Error(), testKey) {
t.Errorf("Verify error holds the key: %v", err)
}
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil {
t.Errorf("failed verification recorded: %+v, %v", got, err)
}
}
// A key error that quotes the key, as a misbehaving adapter might, is
// redacted by the service.
func TestVerifyRedactsKey(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
f := &llm.Fake{}
useFake(s, f)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey)))
_, err := s.Verify(ctx)
if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) {
t.Errorf("Verify = %v, want ErrAuth without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)})
if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) {
t.Errorf("Models = %v, want ErrProviderFailed without the key", err)
}
f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}})
models, err := s.Models(ctx)
if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) {
t.Errorf("Models = %+v, %v, want the key redacted", models, err)
}
}
// Settings changed while the provider verifies the key are not marked
// verified.
func TestVerifyRefusesChangedSettings(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
t.Fatal(err)
}
s.newAccount = func(llm.Settings, string) (presets.Account, error) {
return changingAccount{s: s}, nil
}
if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) {
t.Fatalf("Verify = %v, want ErrChanged", err)
}
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" {
t.Errorf("settings after a changed verification = %+v, %v", got, err)
}
}
// changingAccount replaces the stored key while it verifies.
type changingAccount struct{ s *Service }
func (a changingAccount) Verify(ctx context.Context) error {
_, err := a.s.SetAPIKey(ctx, "sk-replaced-5678")
return err
}
func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil }
func TestNotConfigured(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
// The seed is anthropic with no key; the real presets refuse it.
if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) {
t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err)
}
if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) {
t.Errorf("Models = %v, want ErrNotConfigured", err)
}
}
func TestSetAPIKey(t *testing.T) {
ctx := context.Background()
s, st := newService(t)
got, err := s.SetAPIKey(ctx, testKey)
if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" {
t.Fatalf("SetAPIKey = %+v, %v", got, err)
}
var ct []byte
if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil {
t.Fatal(err)
}
if strings.Contains(string(ct), testKey) {
t.Error("the key is stored in plain text")
}
if key, err := s.key(mustRow(t, s)); err != nil || key != testKey {
t.Errorf("stored key opens to %q, %v", key, err)
}
for _, bad := range []string{"short", "sk-bad\r\nkey"} {
var invalid *ValidationError
if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err)
}
}
// Ollama takes no key: neither storing one nor switching to it with one.
var invalid *ValidationError
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) {
t.Errorf("switch to ollama with a key = %v, want a ValidationError", err)
}
if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil {
t.Fatalf("remove key = %+v, %v", got, err)
}
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil {
t.Fatal(err)
}
if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) {
t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err)
}
}
func TestResetPrompt(t *testing.T) {
ctx := context.Background()
s, _ := newService(t)
if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil {
t.Fatal(err)
}
got, err := s.ResetPrompt(ctx)
if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt {
t.Errorf("ResetPrompt = %+v, %v", got, err)
}
}
func mustRow(t *testing.T, s *Service) db.AiSetting {
t.Helper()
row, err := s.read(context.Background(), db.New(s.st.Reader))
if err != nil {
t.Fatal(err)
}
return row
}
+34 -6
View File
@@ -15,6 +15,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"math"
"regexp"
"slices"
@@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) {
return p, ok
}
// Names returns every preset's name, sorted.
func Names() []string {
return slices.Sorted(maps.Keys(all))
}
// all is every preset, by Settings.Provider name.
var all = map[string]Preset{
"anthropic": {
@@ -181,19 +187,40 @@ var all = map[string]Preset{
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
// Generate retries transient vendor failures and has the default timeout.
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
cp, err := fromSettings(s, key)
cp, err := fromSettings(s, key, true)
if err != nil {
return nil, redact(err, key)
}
return llm.NewRetrying(cp), nil
}
func fromSettings(s llm.Settings, key string) (*configured, error) {
// Account is what a provider offers before a model is chosen: the live
// model list and a check of the credentials.
type Account interface {
ListModels(ctx context.Context) ([]llm.Model, error)
Verify(ctx context.Context) error
}
// ForAccount returns the Account s describes, using key as its API key. It
// checks s and key as FromSettings does except that s.Model may be empty,
// so the household can verify its key and pick from the live model list
// before it has chosen a model. Neither call is retried.
func ForAccount(s llm.Settings, key string) (Account, error) {
cp, err := fromSettings(s, key, false)
if err != nil {
return nil, redact(err, key)
}
return cp, nil
}
// fromSettings builds the provider s describes; needModel is whether
// s.Model is required.
func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) {
p, ok := Lookup(s.Provider)
if !ok {
return nil, llm.ErrUnknownProvider
}
if err := check(p, s, key); err != nil {
if err := check(p, s, key, needModel); err != nil {
return nil, fmt.Errorf("%s: %w", p.Name, err)
}
base := p.BaseURL
@@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) {
return cp, nil
}
// check validates s and key against p. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string) error {
// check validates s and key against p, and that s names a model when
// needModel is set. Its errors never quote a value.
func check(p Preset, s llm.Settings, key string, needModel bool) error {
switch {
case p.Key == KeyRequired && key == "":
return llm.ErrMissingKey
@@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error {
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
}
if s.Model == "" {
if needModel && s.Model == "" {
return llm.ErrMissingModel
}
if p.RequiresPublicURL && s.PublicURL == "" {
+33
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"testing"
@@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
t.Errorf("sent %d requests, want 2", st.sent)
}
}
// ForAccount builds without a model, so a key can be verified and the
// model list read before a model is chosen, but keeps every other check.
func TestForAccount(t *testing.T) {
if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil {
t.Errorf("ForAccount without a model = %v", err)
}
for _, c := range []struct {
s llm.Settings
key string
want error
}{
{llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider},
{llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey},
{llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL},
{llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL},
} {
if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) {
t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want)
}
}
if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) {
t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err)
}
}
func TestNames(t *testing.T) {
want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"}
if got := Names(); !slices.Equal(got, want) {
t.Errorf("Names = %v, want %v", got, want)
}
}
+2 -1
View File
@@ -11,7 +11,8 @@ import (
)
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
// SeedAISettings stores it on first boot and the Admin reset restores it.
// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt
// restores it.
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
similar to their regular rotation, nudging them to try something a little new each
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
// the stored key.
type apiKeyBody struct {
APIKey *string `json:"api_key"`
}
// modelsResponse is the GET /settings/ai/models body.
type modelsResponse struct {
Models []aisettings.Model `json:"models"`
}
// No response from these handlers holds the API key: the settings carry
// only its last four characters, and provider errors and model lists are
// redacted by package aisettings.
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
}
}
// putAIKey takes the API key in api_key; the response, like GET's, holds
// only its last four characters.
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err == nil && body.APIKey == nil {
err = errors.New("api_key: is required")
}
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
}
}
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
}
}
// postAIVerify checks the stored key with the provider and returns the
// settings with key_verified_at, or the failure (see writeAIFailure).
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Verify(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "verify AI key", err)
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
// getAIModels returns the provider's live model list, or the failure (see
// writeAIFailure).
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
models, err := svc.Models(r.Context())
if err != nil {
writeAIFailure(w, r, logger, "list AI models", err)
return
}
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
}
}
// writeAISettings returns a func writing a Service result: the settings,
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
return func(settings aisettings.Settings, err error) {
var invalid *aisettings.ValidationError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
case err != nil:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
default:
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
}
// writeAIFailure writes a Verify or Models error: 409 not_configured when
// the settings cannot build a provider, 409 settings_changed when they
// changed during verification, and for a failed provider call a 502 (504
// for a timeout) whose code names the llm typed error: auth_failed,
// rate_limited, timeout, bad_output or provider_error. Rate limits and
// timeouts are retryable. Anything else is 500.
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, aisettings.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
case errors.Is(err, aisettings.ErrChanged):
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
case errors.Is(err, aisettings.ErrProviderFailed):
logger.WarnContext(r.Context(), action+" failed", "error", err)
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
switch {
case errors.Is(err, llm.ErrAuth):
e.Code = "auth_failed"
case errors.Is(err, llm.ErrRateLimited):
e.Code, e.Retryable = "rate_limited", true
case errors.Is(err, llm.ErrTimeout):
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
case errors.Is(err, llm.ErrBadOutput):
e.Code = "bad_output"
}
writeAPIError(w, r, logger, status, e)
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+261
View File
@@ -0,0 +1,261 @@
package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/fstest"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const (
aiPath = "/api/v1/settings/ai"
aiKeyPath = "/api/v1/settings/ai/api-key"
aiVerifyPath = "/api/v1/settings/ai/verify"
aiModelsPath = "/api/v1/settings/ai/models"
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
testAIKey = "sk-test-secret-ai-key-7d21"
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
)
// newAIRouter returns a router with a fresh, seeded AI settings service.
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
// only testAIKey. While echo is set it answers 500 with a body quoting the
// Authorization header it received, so a response that passed the vendor
// body through unredacted would leak the key. Its model list holds a model
// whose ID and name quote the key.
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
switch {
case r.URL.Path != "/v1/models":
http.NotFound(w, r)
case echo.Load():
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`)
case auth != "Bearer "+testAIKey:
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
default:
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
}
}))
t.Cleanup(srv.Close)
return srv
}
func errorCode(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
// TestAISettingsAPI drives every /settings/ai route and scans every
// response body for the API keys.
func TestAISettingsAPI(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
var echo atomic.Bool
vendor := newModelsServer(t, &echo)
var bodies []string
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
rec, decoded := serve(t, router, signedIn(method, target, r))
bodies = append(bodies, rec.Body.String())
return rec, decoded
}
// The seed: anthropic, the default persona, no key.
rec, body := call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt ||
body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("GET seed = %d %v", rec.Code, body)
}
for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} {
if _, ok := body[field]; !ok {
t.Errorf("GET has no %s field: %v", field, body)
}
}
for _, path := range []string{aiVerifyPath, aiModelsPath} {
method := http.MethodPost
if path == aiModelsPath {
method = http.MethodGet
}
if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" {
t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body)
}
}
for _, bad := range []string{
`{}`,
`{"provider":"google"}`,
`{"provider":"openai_compatible","base_url":"not a url"}`,
`{"base_url":"https://evil.example"}`,
`{"temperature":1.1}`,
`{"temperature":-0.1}`,
`{"temperature":0.25}`,
`{"temperature":"0.5"}`,
`{"model":""}`,
`{"model":null}`,
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
`{"api_key":"` + testAIKey + `"}`,
} {
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body)
}
}
// The longest prompt fits the body limit however its 8,000 characters
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
}
}
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body)
}
for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} {
if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest {
t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body)
}
}
// A rejected key is a typed auth failure and records nothing.
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil {
t.Errorf("PUT wrong key = %d %v", rec.Code, body)
}
if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
}
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`)
if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" {
t.Errorf("PUT key = %d %v", rec.Code, body)
}
if _, ok := body["api_key"]; ok {
t.Errorf("PUT response has an api_key field: %v", body)
}
// The vendor quoting the key in an error body: the body excerpt
// reaches the response redacted.
echo.Store(true)
for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} {
rec, body := call(c.method, c.path, "")
if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") {
t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body)
}
}
echo.Store(false)
rec, body = call(http.MethodPost, aiVerifyPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" {
t.Errorf("POST verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiPath, "")
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 {
t.Errorf("GET after verify = %d %v", rec.Code, body)
}
rec, body = call(http.MethodGet, aiModelsPath, "")
models, _ := body["models"].([]any)
if rec.Code != http.StatusOK || len(models) != 3 {
t.Fatalf("GET models = %d %v", rec.Code, body)
}
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
t.Errorf("GET models[1] = %v", m)
}
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
t.Errorf("GET models[2] = %v, want the key redacted", m)
}
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {
t.Errorf("PATCH system_prompt = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, aiResetPromptPath, "")
if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt {
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
}
// The stored key is never pointed at another server or provider.
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
}
}
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("PUT empty key = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body)
}
if len(bodies) < 30 {
t.Fatalf("scanned only %d response bodies", len(bodies))
}
for _, b := range bodies {
for _, key := range []string{testAIKey, testWrongAIKey} {
if strings.Contains(b, key) {
t.Errorf("response holds an API key: %s", b)
}
}
}
}
func TestAISettingsWritesNeedCSRF(t *testing.T) {
t.Parallel()
router := newAIRouter(t)
for _, c := range []struct{ method, path, body string }{
{http.MethodPatch, aiPath, `{"temperature":0.5}`},
{http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`},
{http.MethodPost, aiVerifyPath, ""},
{http.MethodPost, aiResetPromptPath, ""},
} {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code)
}
}
rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil))
if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 {
t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+14 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -31,18 +32,23 @@ type RouteMounter interface {
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; runner serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if search == nil {
return nil, errors.New("server: nil recipe search settings service")
}
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -80,6 +86,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
apiV1.Get("/settings/ai", getAISettings(logger, ai))
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+43 -12
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -42,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -131,24 +132,54 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
// configured, over a migrated temp database and a random secret box.
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
search, err := recipes.NewSearchSettingsService(newTestStore(t), box)
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
if err != nil {
t.Fatal(err)
}
return search
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
return box
}
// newTestAI returns an AI settings service over a migrated temp database
// with the seed AI settings and a random secret box.
func newTestAI(t *testing.T) *aisettings.Service {
t.Helper()
st := newTestStore(t)
if err := household.SeedAISettings(context.Background(), st); err != nil {
t.Fatal(err)
}
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
if err != nil {
t.Fatal(err)
}
return ai
}
func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -157,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -166,7 +197,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -175,7 +206,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+15 -3
View File
@@ -9,14 +9,21 @@ import (
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// maxSettingsBody bounds a PATCH /settings/household or
// /settings/recipe-search request body.
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
const maxSettingsBody = 16 << 10
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
// longest valid system prompt at 12 bytes of JSON per character (a
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
// settingsPatch is the PATCH /settings/household body. Every field is
// optional; unknown fields and null values are refused.
type settingsPatch struct {
@@ -124,13 +131,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
// writeError responds with status and a non-retryable API error.
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
}
// writeAPIError responds with status and e.
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
body, err := json.Marshal(errorBody{Error: e})
if err != nil {
panic(err) // string and bool fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+15 -2
View File
@@ -4,8 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
import { SETTINGS_API } from './test/ApiProviders.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -46,7 +47,7 @@ describe('App routes', () => {
])
})
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
@@ -57,6 +58,18 @@ describe('App routes', () => {
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
},
)
it('/settings renders the settings home inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
...SETTINGS_API,
})
renderAt('/settings')
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
})
describe('App bootstrap', () => {
+8 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
@@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -27,13 +28,18 @@ export const routes = [
{ path: '/settings/calendar', title: 'Calendar' },
] as const
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
export function AppRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
+153
View File
@@ -0,0 +1,153 @@
/* Settings home, handoff mock 1p. */
.page {
max-width: 560px;
display: flex;
flex-direction: column;
}
.title {
margin: 0;
padding-top: 6px;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.banner {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
}
.avatar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-heading);
font-size: 18px;
}
.bannerText {
flex: 1;
display: flex;
flex-direction: column;
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.35;
}
.bannerTitle {
font-size: 15px;
font-weight: 700;
}
.alert {
margin-top: 14px;
}
.groupLabel {
margin: 0;
padding: 18px 4px 8px;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.group {
padding: 0;
border-radius: 26px;
}
.admin {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
/* Rows in one card are split by a hairline inset to the row padding. */
.item + .item {
border-top: 1px solid var(--color-neutral-300);
margin: 0 16px;
}
.item + .item .row {
padding-left: 0;
padding-right: 0;
}
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 14px;
min-width: 44px;
min-height: 44px;
padding: 14px 16px;
border-radius: 26px;
color: var(--color-text);
text-decoration: none;
}
.icon {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--color-bg);
}
.icon svg {
width: 18px;
height: 18px;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-family: var(--font-body);
}
.rowTitle {
font-size: 15px;
font-weight: 700;
}
.status {
font-size: 12px;
color: var(--color-neutral-700);
}
.statusSkeleton {
width: 60%;
height: 12px;
margin-top: 3px;
background: var(--color-neutral-300);
}
.tag {
flex: none;
}
+258
View File
@@ -0,0 +1,258 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' }
afterEach(() => {
vi.unstubAllGlobals()
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
const CALENDAR_PATH = '/api/v1/settings/calendar'
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
// stubSettings answers as today's server does (see SETTINGS_API), with
// overrides for the paths a test changes.
function stubSettings(overrides: Record<string, () => Response> = {}) {
return stubFetchByPath({
...SETTINGS_API,
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD),
'/api/v1/settings/ai': () => jsonResponse(200, AI),
...overrides,
})
}
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
function renderSettings() {
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/settings']}>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function row(name: string) {
return screen.getByRole('link', { name: new RegExp(`^${name}`) })
}
describe('SettingsPage', () => {
it('renders the title and the shared household banner', async () => {
stubSettings()
renderSettings()
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('groups the rows under Household, Alerts and Admin', async () => {
stubSettings()
renderSettings()
const group = (name: string) => within(screen.getByRole('region', { name }))
expect(group('Household').getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/settings/regulars',
'/settings/calendar',
'/settings/household',
])
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
await screen.findByText('Anthropic · Claude Sonnet')
})
it('reads every status line from the server', async () => {
const fetchMock = stubSettings()
renderSettings()
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
'/api/v1/settings/ai',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
])
})
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings()
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push on · Dinner Crew space'),
).toBeInTheDocument()
})
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
})
it('reports a status endpoint failure other than a missing endpoint', async () => {
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
})
it('refuses a connected calendar without a name', async () => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
})
it('shows another provider and model as the settings name them', async () => {
stubSettings({
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }),
})
renderSettings()
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
})
it('says no model is chosen for the seeded AI settings', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) })
renderSettings()
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
})
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
stubSettings()
renderSettings()
expect(within(row('AI assistant')).getByText('Admin')).toBeInTheDocument()
const adminCard = screen.getByRole('region', { name: 'Admin' }).querySelector('ul')!.parentElement!
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
expect(declaredStyle(householdCard).boxShadow).toBe('')
await screen.findByText('Anthropic · Claude Sonnet')
})
it.each([
['Regular dinners', '/settings/regulars'],
['Google Calendar', '/settings/calendar'],
['Household', '/settings/household'],
['Notifications & Google Chat', '/settings/notifications'],
['AI assistant', '/settings/ai'],
])('the %s row navigates to %s', async (name, path) => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
fireEvent.click(row(name))
expect(screen.getByTestId('path')).toHaveTextContent(path)
})
it('gives every row a tap target of at least 44 px', async () => {
stubSettings()
renderSettings()
await screen.findByText('Anthropic · Claude Sonnet')
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
})
it('reports a failed status with a retry that loads it', async () => {
let householdCalls = 0
stubSettings({
'/api/v1/settings/household': () => {
householdCalls += 1
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
},
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
expect(screen.getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(row('Household')).toHaveAccessibleName('Household')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('refuses an AI provider it has no name for', async () => {
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'mystery', model: 'm1' }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
})
it('refuses a dinner time that is not HH:MM', async () => {
stubSettings({
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }),
})
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
})
})
describe('modelDisplayName', () => {
it.each([
['claude-sonnet-4-5', 'Claude Sonnet'],
['claude-opus-4-1-20250805', 'Claude Opus'],
['claude-3-5-haiku-20241022', 'Claude Haiku'],
['claude-fable-5-1', 'Claude Fable'],
['gpt-4o', 'gpt-4o'],
['anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5'],
])('names %s %s', (model, name) => {
expect(modelDisplayName(model)).toBe(name)
})
})
describe('formatClockTime', () => {
it.each([
['00:05', '12:05 am'],
['09:00', '9:00 am'],
['12:00', '12:00 pm'],
['17:30', '5:30 pm'],
['23:59', '11:59 pm'],
])('formats %s as %s', (time, formatted) => {
expect(formatClockTime(time)).toBe(formatted)
})
it('refuses a time that is not HH:MM', () => {
expect(() => formatClockTime('24:00')).toThrow('not a 24-hour HH:MM time')
})
})
+151
View File
@@ -0,0 +1,151 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { LucideIcon } from 'lucide-react'
import { Bell, Calendar, ChevronRight, Crosshair, Heart, House } from 'lucide-react'
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
aiStatus,
calendarStatus,
formatClockTime,
notificationsStatus,
regularDinnersStatus,
useAISettings,
useCalendarStatus,
useHouseholdSettings,
useNotificationsStatus,
useRegularDinnersCount,
} from './api.ts'
import styles from './SettingsPage.module.css'
// Settings home (handoff mock 1p). Each row opens its settings page and
// shows a live status line. Regular dinners, Google Calendar and
// Notifications & Google Chat read endpoints their features have not served
// yet; until then each shows its not-yet-available state (see api.ts).
export function SettingsPage() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
const calendar = useCalendarStatus()
const notifications = useNotificationsStatus()
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
return (
<div className={styles.page}>
<h1 className={styles.title}>Settings</h1>
<div className={styles.banner}>
<span className={styles.avatar} aria-hidden="true">
K
</span>
<p className={styles.bannerText}>
<strong className={styles.bannerTitle}>Our kitchen</strong>
<span>Shared household · changes apply to everyone</span>
</p>
</div>
{failed.length > 0 && (
<div className={styles.alert}>
<ErrorBanner onRetry={() => failed.forEach((query) => void query.refetch())}>
Settings could not load: {failed.map((query) => query.error?.message).join('; ')}
</ErrorBanner>
</div>
)}
<Group label="Household">
<SettingsRow
to="/settings/regulars"
icon={Heart}
title="Regular dinners"
status={status(regulars, regularDinnersStatus)}
/>
<SettingsRow
to="/settings/calendar"
icon={Calendar}
title="Google Calendar"
status={status(calendar, calendarStatus)}
/>
<SettingsRow
to="/settings/household"
icon={House}
title="Household"
status={status(household, (s) => `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)}
/>
</Group>
<Group label="Alerts">
<SettingsRow
to="/settings/notifications"
icon={Bell}
title="Notifications & Google Chat"
status={status(notifications, notificationsStatus)}
/>
</Group>
<Group label="Admin" admin>
<SettingsRow
to="/settings/ai"
icon={Crosshair}
title="AI assistant"
status={status(ai, aiStatus)}
tag={<Tag className={styles.tag}>Admin</Tag>}
/>
</Group>
</div>
)
}
// status is a row's status line: a skeleton while its query loads, the
// formatted data once it has, and nothing when it failed (the error banner
// says why).
function status<T>(query: UseQueryResult<T>, format: (data: T) => string): ReactNode {
if (query.isPending) return <Skeleton className={styles.statusSkeleton} />
if (query.isError) return null
return format(query.data)
}
type GroupProps = {
label: string
// The Admin group's card carries the grape-300 inset ring.
admin?: boolean
children: ReactNode
}
function Group({ label, admin = false, children }: GroupProps) {
const id = useId()
return (
<section aria-labelledby={id}>
<h2 id={id} className={styles.groupLabel}>
{label}
</h2>
<Card className={[styles.group, admin && styles.admin].filter(Boolean).join(' ')}>
<ul className={styles.rows}>{children}</ul>
</Card>
</section>
)
}
type SettingsRowProps = {
to: string
icon: LucideIcon
title: string
status?: ReactNode
tag?: ReactNode
}
function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) {
return (
<li className={styles.item}>
<Link to={to} className={styles.row}>
<span className={styles.icon}>
<Icon icon={icon} />
</span>
<span className={styles.body}>
<span className={styles.rowTitle}>{title}</span>
{status && <span className={styles.status}>{status}</span>}
</span>
{tag}
<Icon icon={ChevronRight} />
</Link>
</li>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useQuery } from '@tanstack/react-query'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
// serves it the server answers 404, which is the truth: not connected.
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
// TODO(notifications feature): the Web Push and Google Chat status. Until it
// is served the server answers 404, which is the truth: push is off and no
// Google Chat space is connected.
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
// HouseholdSettings is the part of GET /api/v1/settings/household the
// settings pages read.
export type HouseholdSettings = {
timezone: string
// Zero-padded 24-hour HH:MM.
dinner_time: string
}
// aiProviderLabels names every AI provider preset the server accepts
// (internal/ai/llm/presets) as the household reads it.
export const aiProviderLabels = {
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
xai: 'xAI',
ollama: 'Ollama',
openai_compatible: 'OpenAI-compatible',
} as const
export type AIProvider = keyof typeof aiProviderLabels
// AISettings is the part of GET /api/v1/settings/ai the settings pages read.
export type AISettings = {
provider: AIProvider
model: string
}
function isAIProvider(value: string): value is AIProvider {
return Object.hasOwn(aiProviderLabels, value)
}
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
// formatClockTime never sees one.
export function useHouseholdSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'household'],
queryFn: async () => {
const settings = await apiFetch<HouseholdSettings>(HOUSEHOLD_SETTINGS_PATH)
if (!clockTime.test(settings.dinner_time)) {
throw new Error(`GET ${HOUSEHOLD_SETTINGS_PATH}: dinner_time "${settings.dinner_time}" is not HH:MM`)
}
return { timezone: settings.timezone, dinner_time: settings.dinner_time } satisfies HouseholdSettings
},
})
}
// useAISettings refuses a provider this client has no name for, so the
// settings never show a value the household cannot read.
export function useAISettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'ai'],
queryFn: async () => {
const settings = await apiFetch<{ provider: string; model: string }>(AI_SETTINGS_PATH)
if (!isAIProvider(settings.provider)) {
throw new Error(`GET ${AI_SETTINGS_PATH}: unknown AI provider "${settings.provider}"`)
}
return { provider: settings.provider, model: settings.model } satisfies AISettings
},
})
}
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: AISettings): string {
const provider = aiProviderLabels[settings.provider]
if (settings.model === '') return `${provider} · No model chosen`
return `${provider} · ${modelDisplayName(settings.model)}`
}
// Claude model ids, current ("claude-sonnet-4-5") and older
// ("claude-3-5-sonnet-20241022"), name their family.
const claudeModel = /^claude-(?:[0-9-]+-)?(opus|sonnet|haiku|fable)(?:-|$)/
const claudeFamilies: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable' }
// modelDisplayName names a model as the handoff does ("Claude Sonnet"); a
// model with no known family is shown by its id.
export function modelDisplayName(model: string): string {
const family = claudeModel.exec(model)?.[1]
return family === undefined ? model : `Claude ${claudeFamilies[family]}`
}
// fetchUnlessAbsent GETs path and parses it, or returns null when the server
// has no such endpoint yet (404 not_found). Every other failure is an error.
async function fetchUnlessAbsent<T>(
apiFetch: ApiClient['apiFetch'],
path: string,
parse: (body: unknown) => T,
): Promise<T | null> {
let body: unknown
try {
body = await apiFetch<unknown>(path)
} catch (error) {
if (error instanceof ApiError && error.status === 404 && error.code === 'not_found') return null
throw error
}
return parse(body)
}
// useRegularDinnersCount is how many Regulars the household has, or null
// while the dinners read API does not exist.
export function useRegularDinnersCount() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['dinners', 'regular', 'count'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, REGULAR_DINNERS_PATH, (body) => {
if (!isRecord(body) || !Array.isArray(body.dinners)) {
throw new Error(`GET ${REGULAR_DINNERS_PATH}: response has no dinners list`)
}
return body.dinners.length
}),
})
}
export function regularDinnersStatus(count: number | null): string {
if (count === null) return 'Rotation count unavailable'
if (count === 0) return 'None on rotation yet'
return `${count} on rotation`
}
// CalendarStatus is the Google Calendar integration status; calendar_name
// is the connected calendar's name.
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
export function useCalendarStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'calendar'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
if (isRecord(body) && body.connected === false) return { connected: false }
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
return { connected: true, calendar_name: body.calendar_name }
}
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
}),
})
}
export function calendarStatus(status: CalendarStatus | null): string {
if (status === null || !status.connected) return 'Not connected'
return `Connected · ${status.calendar_name}`
}
// NotificationsStatus is whether Web Push is on and the connected Google
// Chat space's name, null when none is.
export type NotificationsStatus = { push: boolean; chat_space: string | null }
export function useNotificationsStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'notifications'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
if (
!isRecord(body) ||
typeof body.push !== 'boolean' ||
(body.chat_space !== null && typeof body.chat_space !== 'string')
) {
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
}
return { push: body.push, chat_space: body.chat_space }
}),
})
}
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
export function notificationsStatus(status: NotificationsStatus | null): string {
const push = status?.push === true ? 'Push on' : 'Push off'
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
}
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
export function formatClockTime(time: string): string {
const match = clockTime.exec(time)
if (match === null) throw new Error(`not a 24-hour HH:MM time: "${time}"`)
const hours = Number(match[1])
const suffix = hours < 12 ? 'am' : 'pm'
return `${hours % 12 === 0 ? 12 : hours % 12}:${match[2]} ${suffix}`
}
+8 -2
View File
@@ -1,9 +1,11 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import bottomBarStyles from './BottomBar.module.css'
import railStyles from './Rail.module.css'
@@ -16,13 +18,17 @@ const DESKTOP = 1440
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
<ApiProviders>
<AppRoutes />
</ApiProviders>
</MemoryRouter>,
)
return resize
+7 -1
View File
@@ -2,7 +2,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -65,13 +67,17 @@ afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
<ApiProviders>
<AppRoutes />
</ApiProviders>
</MemoryRouter>,
)
}
+30
View File
@@ -0,0 +1,30 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
}
// ApiProviders gives the pages under test what App gives them: an API client
// on https://mealquest.test and a fresh query client.
export function ApiProviders({ children }: { children: ReactNode }) {
const [api] = useState(() =>
createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)),
)
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
}
+14
View File
@@ -47,3 +47,17 @@ export function request(fetchMock: Mock<typeof fetch>, n: number) {
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}
// stubFetchByPath replaces the global fetch with a mock that answers each
// path from responses with a fresh response from its factory, so requests
// sent in parallel need no order; a path not in responses fails the test.
// Undo with vi.unstubAllGlobals.
export function stubFetchByPath(responses: Record<string, () => Response>): Mock<typeof fetch> {
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const respond = Object.hasOwn(responses, String(input)) ? responses[String(input)] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}