Pre-fill the SearXNG URL from the deployment default
CI / ci (push) Successful in 1m56s
CI / ci (pull_request) Successful in 2m3s

The service now reads the optional MEALQUEST_SEARXNG_URL, validates it at
startup like any SearXNG URL, and returns it in the recipe search settings
as the read-only default_base_url. base_url stays the saved URL only, and
PATCH is unchanged: nothing is stored until the household saves.

The recipe search card pre-fills the SearXNG URL from default_base_url
while no URL is saved, and an edit now clears the last Test result so a
stale count or error never sits beside "Save your changes to test them."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:41:31 -04:00
co-authored by Claude Opus 5.5
parent cb42ffa280
commit 938b408deb
15 changed files with 250 additions and 44 deletions
+6 -2
View File
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
@@ -53,8 +56,9 @@ make dev
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and
`MEALQUEST_DEFAULT_TZ`, and
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+2 -2
View File
@@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
search, err := recipes.NewSearchSettingsService(db, box)
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
if err != nil {
return err
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
}
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
+4 -4
View File
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. Planned; not
# read by the service yet. The app takes its recipe-search provider and
# base URL from PATCH /api/v1/settings/recipe-search; see
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
+16 -8
View File
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
instance for recipe search. On the compose network it is reachable at
`http://searxng:8080`; that is the address to configure in the app.
Starting the sidecar does not connect the app to it. The service does not
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
wiring, and today it does nothing. The recipe-search provider and base URL
live in the `recipe_search_settings` row and are set through
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
screen's recipe search; the screen itself is not built yet). Until that row
exists, recipe search answers 409 `not_configured`.
Starting the sidecar does not connect the app to it. The recipe-search
provider and base URL live in the `recipe_search_settings` row and are set
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
recipe search card on Settings → AI assistant). Until that row exists,
recipe search answers 409 `not_configured`.
After the first deploy, from a signed-in session (every non-`GET` request
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
The service only suggests it: `GET` returns it as `default_base_url`, and
the recipe search card pre-fills the SearXNG URL with it while no URL is
saved. Nothing is stored until the card is saved, and a saved `base_url`
always wins. A set value must be an absolute `http://` or `https://` URL
without credentials, a query or a fragment, or the service refuses to
start; unset, `default_base_url` is `null`.
To set it up, open Settings → AI assistant, check that the recipe search
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
and then **Test**. Or, from a signed-in session (every non-`GET` request
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
`GET /api/v1/me`):
+3
View File
@@ -149,6 +149,9 @@ automatic** (D-021).
the Brave key (`api_key`, write-only: responses carry only
`api_key_set` and `api_key_last4`; an empty string removes it). The
result must be usable: SearXNG needs a base_url, Brave a key.
Responses also carry the read-only `default_base_url`, the deployment's
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
base_url is saved; only a PATCH of base_url stores a URL.
`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.
+11
View File
@@ -35,6 +35,8 @@ const (
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
@@ -73,6 +75,13 @@ type Config struct {
// validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset.
DefaultTZ string
// SearXNGURL is the deployment's SearXNG instance, which the Settings
// screen suggests while no recipe-search URL is saved. It is optional
// and validated by recipes.NewSearchSettingsService: the compose
// network's http://searxng:8080 is neither https nor loopback, so the
// secure-URL rule does not apply. Empty when unset.
SearXNGURL string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ),
SearXNGURL: r.optional(EnvSearXNGURL),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
+5
View File
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
if cfg.SearXNGURL != "http://searxng:8080" {
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
}
}
func TestSecretRedacted(t *testing.T) {
+40 -17
View File
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
type SearchSettings struct {
// Provider is "searxng" or "brave", nil until first chosen.
Provider *string `json:"provider"`
// BaseURL is the SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// BaseURL is the saved SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
// DefaultBaseURL is the deployment's SearXNG URL
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
// not set. It is never saved by itself: only a PATCH of base_url saves
// a URL. Nil when the deployment names none.
DefaultBaseURL *string `json:"default_base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// UpdatedAt is nil until the settings are first saved.
UpdatedAt *string `json:"updated_at"`
}
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
type SearchSettingsService struct {
st *store.Store
box *secretbox.Box
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
defaultBaseURL string
// newProvider is NewSearchProvider; tests point Brave elsewhere.
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
}
// NewSearchSettingsService returns the service over st, sealing keys with
// box.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
// one must be a valid SearXNG URL.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
if st == nil {
return nil, errors.New("recipes: nil store")
}
if box == nil {
return nil, errors.New("recipes: nil secret box")
}
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
if defaultBaseURL != "" {
if _, err := NewSearXNG(defaultBaseURL); err != nil {
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
}
}
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
}
// Get returns the settings; before any are saved every field is unset.
// Get returns the settings; before any are saved every field but
// DefaultBaseURL is unset.
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return SearchSettings{}, nil
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
}
if err != nil {
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
}
return searchSettingsFrom(row), nil
return s.settingsFrom(row), nil
}
func (s *SearchSettingsService) defaultBase() *string {
if s.defaultBaseURL == "" {
return nil
}
u := s.defaultBaseURL
return &u
}
// Update applies patch and returns the settings it leaves. The first update
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
if err != nil {
return SearchSettings{}, err
}
return searchSettingsFrom(saved), nil
return s.settingsFrom(saved), nil
}
// apply returns row with patch applied, validated.
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
return len(cands), nil
}
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
provider, updated := row.Provider, row.UpdatedAt
return SearchSettings{
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
DefaultBaseURL: s.defaultBase(),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
}
}
+76 -1
View File
@@ -19,6 +19,13 @@ import (
const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
t.Helper()
return newTestSearchSettingsWithDefault(t, "")
}
// newTestSearchSettingsWithDefault is newTestSearchSettings with
// defaultBaseURL as the deployment's SearXNG URL.
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
t.Helper()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
if err != nil {
t.Fatal(err)
}
svc, err := NewSearchSettingsService(st, box)
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
if err != nil {
t.Fatal(err)
}
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
}
}
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
t.Parallel()
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
got, err := svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
}
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
}
// The default is a suggestion: SearXNG still needs a saved URL.
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
t.Error("choosing SearXNG without a URL succeeded with a default set")
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
}
got, err = svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" {
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
}
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
if err != nil {
t.Fatal(err)
}
if row.BaseUrl.String != "https://search.example" {
t.Errorf("stored base_url = %q", row.BaseUrl.String)
}
}
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
t.Parallel()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
if _, err := NewSearchSettingsService(st, box, u); err == nil {
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
} else if strings.Contains(err.Error(), "pw") {
t.Errorf("error for %q echoes the URL: %v", u, err)
}
}
}
func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
}
rec, body := call(http.MethodGet, recipeSearchPath, "")
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
+1 -1
View File
@@ -132,7 +132,7 @@ 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()
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
if err != nil {
t.Fatal(err)
}
+67 -5
View File
@@ -462,16 +462,52 @@ describe('Recipe search card', () => {
expect(testSearch()).toBeEnabled()
})
it('starts on SearXNG with an empty URL before recipe search is set up', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () =>
jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: null }),
const UNCONFIGURED = {
provider: null,
base_url: null,
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: null,
}
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('')
expect(url()).toHaveValue('http://searxng:8080')
expect(testSearch()).toBeDisabled()
expect(saveSearch()).toBeEnabled()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/recipe-search',
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
},
])
})
it('starts with an empty URL when the deployment names no SearXNG', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
})
await renderCard()
expect(url()).toHaveValue('')
})
it('shows the saved URL, not the deployment\'s', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
})
await renderCard()
expect(url()).toHaveValue('https://search.example')
expect(saveSearch()).toBeDisabled()
})
it('switches providers, keeping each provider\'s field', async () => {
@@ -587,6 +623,32 @@ describe('Recipe search card', () => {
)
})
it('clears a test result when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
})
it('clears a test error when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
fireEvent.click(brave())
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
})
it('asks before leaving with an unsaved recipe search change', async () => {
stubAPI()
await renderCard()
+10 -2
View File
@@ -61,9 +61,14 @@ type Draft = {
}
// draftOf starts a household that has not set up recipe search on SearXNG,
// the sidecar every deployment runs.
// the sidecar every deployment runs. With no URL saved the field holds the
// deployment's SearXNG URL, which saving stores.
function draftOf(saved: RecipeSearchSettings): Draft {
return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null }
return {
provider: saved.provider ?? 'searxng',
base_url: saved.base_url ?? saved.default_base_url ?? '',
api_key: null,
}
}
// patchOf is the PATCH body for what draft changes. Only the chosen
@@ -88,8 +93,11 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
// An edit clears the last save and test results: they describe the saved
// settings, not the draft.
function edit(change: Partial<Draft>) {
save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change }))
}
+7 -1
View File
@@ -209,8 +209,12 @@ export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
export type RecipeSearchSettings = {
// Null until recipe search is first set up.
provider: RecipeSearchProvider | null
// The SearXNG instance's URL, null when none is set.
// The saved SearXNG instance's URL, null when none is set.
base_url: string | null
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
// base_url is null; null when the deployment names none. Only saving
// base_url stores a URL.
default_base_url: string | null
api_key_set: boolean
api_key_last4: string | null
updated_at: string | null
@@ -237,6 +241,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
!isRecord(body) ||
!isStringOrNull(body.provider) ||
!isStringOrNull(body.base_url) ||
!isStringOrNull(body.default_base_url) ||
typeof body.api_key_set !== 'boolean' ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.updated_at)
@@ -249,6 +254,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
return {
provider: body.provider,
base_url: body.base_url,
default_base_url: body.default_base_url,
api_key_set: body.api_key_set,
api_key_last4: body.api_key_last4,
updated_at: body.updated_at,
+1
View File
@@ -21,6 +21,7 @@ export const AI_SETTINGS = {
export const RECIPE_SEARCH_SETTINGS = {
provider: 'searxng',
base_url: 'http://searxng:8080',
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: '2026-10-01T15:00:00.000Z',