Files
foodster/PRD.md
T
Esa Kataja eb95bd0a03 Serve behind Traefik and rate limit password guesses
The deployment is a public hostname behind Traefik rather than a LAN-only
box, which changes two things.

TLS is now terminated by the proxy, so Basic credentials are no longer in
cleartext. The container publishes no ports: doing so would leave an
unencrypted copy of the app on the host, bypassing the proxy. The hostname
lives in .env rather than compose.yaml, so no infrastructure detail is
committed and the MIT publication option stays open.

The password is now the only thing between the internet and the app, and a
500 ms sleep is not a defence at that exposure. Wrong guesses are rate
limited per client address: five in a burst, then one per ten seconds,
answered with 429.

Two details that decide whether this works at all:

- a request with no Authorization header is not charged. That is the
  handshake every browser session opens with, and counting it would lock the
  household out for simply opening the app a few times.
- X-Forwarded-For is believed only when the connection arrived from a private
  address, i.e. through the proxy, and then only its last entry, which is the
  one the proxy observed. A direct client could otherwise forge a fresh
  address per attempt and walk past the limiter entirely.

None of this substitutes for a strong password. It removes brute force as a
practical route, nothing more. PRD §3, §9 and §10 are updated: "no external
internet exposure" is no longer true.
2026-09-05 20:12:28 +03:00

20 KiB
Raw Permalink Blame History

Foodster — Product Requirements Document

1. Overview

Foodster is a self-hosted web app that helps a single household decide what to eat for the next seven dinners. Given a database of meals the family already likes, the app proposes a week's worth of dinners (main dish, optional side dishes) using their eating history as input. The proposal is a suggestion, not a schedule — the user can swap items, ignore the suggestions entirely, or log what was actually eaten after the fact.

The project is built for one family's use. If it matures into something polished, it may be released as FOSS under MIT.

2. Goals

  • Reduce weekly "what's for dinner?" decision fatigue.
  • Keep meal variety high while still leaning on family favorites.
  • Capture a history of what was actually eaten (not what was planned) so the suggester gets better over time.
  • Stay boring to run: a single docker compose up on a home server.

3. Non-goals (MVP)

  • No grocery list generation (possible future add-on).
  • No per-recipe ingredient tracking — meals are just names.
  • No calendar/scheduling with times, reminders, or calendar exports.
  • No user accounts, per-person profiles, or permissions. A single shared password gates the whole app (§9).
  • No nutrition tracking, calorie counting, or dietary-goal optimization.
  • No mobile-native apps. Web only (mobile-friendly responsive is enough).
  • No per-user accounts or sessions. The app is reachable from the internet (§9, §10), gated by a single shared password over TLS.

4. Delivery stages

The app ships in two stages. The suggester needs real eating history to produce useful weightings, so history capture comes first and the family seeds the database by logging meals for a few weeks before the suggester has anything meaningful to do.

Stage 1 — Eating history

Everything needed to manage the meal catalog and log what was eaten. Concretely:

  • Domain model: main dishes (with categories and has_sides), side dishes, meal log entries (§6).
  • Admin view: add / edit / delete mains and sides, JSON mass import (§7.3).
  • Meal log view ("Mark"): date picker, main + sides selector, recent entries list with edit/delete (§7.2).
  • Deployment scaffolding: compose.yaml, SQLite, schema applied on app start (§9, §10).

At the end of stage 1 the app is useful as a "what did we eat?" journal even without any suggestions. There is no Plan view yet.

Stage 2 — Meal suggester

Layered on top of the stage 1 foundation:

  • Current suggestion cache (§6) persisted server-side.
  • Plan view with seven suggested meals, two swap modes per slot, and regenerate-all (§7.1).
  • Suggestion algorithm: cooldown, category coverage with multi-category mains, favorites-biased weighting with a floor, side attachment rules (§8).
  • Settings page for the cooldown window (§7.3).

Stage 2 can start once stage 1 has collected enough history for the weighting to be meaningful (a few weeks of logged meals).

5. Users

A single household. One shared instance, no per-person accounts. Anyone on the home network who knows the shared password can open the app and interact with it.

The interface is written in Finnish — every user of this instance is a Finnish speaker, so there is no i18n layer and no language switcher. Strings are hardcoded in the templates. This PRD, the source, and code comments stay in English.

6. Domain model

Main dish (stage 1)

  • id
  • name — stored in sentence case: the server collapses whitespace and capitalizes the first letter only, leaving the rest as typed. Finnish capitalizes just the first word of a phrase, so "Keitetyt perunat" is correct and "Keitetyt Perunat" is not. Existing capitals are preserved, so "BBQ-kylkeä" and "Kotipizza" survive.
  • categories — non-empty set drawn from meat, chicken, fish, vegetarian. Most mains will have a single category. Dishes where each diner builds their own plate from a shared spread (e.g., tortillas, build-your-own pizza) list every category present at the meal; one appearance of such a main simultaneously covers all its listed categories for that week, because all the proteins are actually served at the same meal.
  • has_sides — boolean. If false, the suggester never attaches side dishes to this main (covers casseroles, one-pot meals, etc.).
  • created_at
  • deleted_at — nullable. Soft-delete marker: soft-deleted mains are hidden from the admin list and all pickers, but remain resolvable when displaying historical log entries.

Side dish (stage 1)

  • id
  • name — stored in sentence case, as for mains.
  • created_at
  • deleted_at — nullable. Same soft-delete semantics as mains.

Side dishes live in their own table and have no category. The pool is expected to stay small.

Meal log entry ("what was actually eaten") (stage 1)

  • id
  • date — SQL DATE, day granularity only. There is no time-of-day field and no timezone concern; the cooldown and display logic compare dates as pure calendar days.
  • main_dish_id
  • side_dish_ids (zero or more)
  • created_at

A unique constraint enforces one log entry per date — editing an existing entry replaces it; no second meal can be logged for the same day.

The log entry is the source of truth for history. Foreign keys to mains and sides are preserved even after the referenced item is soft-deleted, so old entries still display their meal names.

Current suggestion (cache) (stage 2)

To survive accidental tab/browser closes and crashes, the seven-meal suggestion is persisted server-side until the user regenerates it. A single row keyed by "current suggestion" is enough — there's only ever one active list. Reopening the app shows the same seven meals until the user explicitly regenerates or swaps.

  • id
  • slots — ordered list of { main_dish_id, side_dish_ids[] }
  • updated_at

7. Features

7.1 Suggestion view ("Plan") (stage 2)

  • Displays seven suggested meals.
  • Each meal = one main dish + zero or more side dishes (sides only if the main's has_sides flag is true).
  • Each meal position has two swap controls:
    • Swap (same category) — default. Replaces the main with a different main of the same category.
    • Swap (any category) — replaces the main with any eligible main regardless of category. May break category coverage; see §8.1.
    • Both honor the cooldown.
  • A regenerate all action produces a fresh list of seven and overwrites the cached current suggestion.
  • No dates, no ordering beyond "these are the next seven." Order is not tied to calendar days.
  • The suggestion list is the front page / default view, and is restored from the server-side cache on page load so a browser crash doesn't lose it.

7.2 Meal log view ("Mark") (stage 1)

  • Form to record what was actually eaten:
    • Date picker (defaults to today).
    • Main dish selector.
    • Side dish selector (multi-select, optional).
  • List of recent log entries with edit/delete.
  • Entries are editable and deletable — history is not sacred, typos happen.

7.3 Admin (stage 1 — catalog; stage 2 — settings)

  • Add / edit / delete main dishes (with categories and has_sides). Names are normalized to sentence case on save. Delete is a soft-delete.
  • Add / edit / delete side dishes. Same sentence-case normalization and soft-delete behavior.
  • Duplicate detection on create/edit is case-insensitive against the set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are treated as the same name).
  • Mass import of mains and sides via JSON paste or file upload.
    • JSON shape (proposed):
      {
        "mains": [
          {"name": "Spaghetti Bolognese", "categories": ["meat"], "has_sides": false},
          {"name": "Chicken Curry", "categories": ["chicken"], "has_sides": true},
          {"name": "Tortillas", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
        ],
        "sides": [
          {"name": "Green salad"},
          {"name": "Rice"}
        ]
      }
      
    • Import is best-effort, not atomic: valid rows are inserted, invalid rows (bad categories, missing fields, duplicates) are skipped and reported back to the user in a per-row summary. Names are normalized to sentence case on import; duplicate detection is case-insensitive.
  • Settings page for the cooldown window (default 14 days, configurable). (Stage 2 — only relevant once the suggester exists.)

7.4 Appearance (stage 1)

  • A light / dark theme switch, remembered per device. The instance is shared and has no accounts, so this is a device preference in localStorage, never a server-side setting — the phone in the kitchen and the laptop must be able to disagree.
  • Two states only, dark by default. Following the system was considered and dropped: a third state costs a control that is harder to read than the choice is worth.
  • One button, and its icon is the theme that is currently on — a moon while dark, a sun while light. Not the state a click would produce. A lightswitch does not read "off" while the lights are on. The accessible label names the state first and the action second: "Tumma teema. Vaihda vaaleaan."
  • Implementation is color-scheme on the root element: only light or only dark. The default is in the server-rendered markup, so it survives having no JavaScript. Because the palette is built from light-dark() custom properties, no other CSS changes.

8. Suggestion algorithm (stage 2)

Produce seven mains, then attach sides per meal.

8.1 Hard constraints (mains)

  1. Cooldown: exclude any main dish that appears in the meal log within the last N days, where N is configurable (default 14).
  2. Category coverage: the union of all seven mains' categories sets must include meat, chicken, fish, and vegetarian. A multi-category main covers every category in its set at once, so a single tortillas appearance can satisfy several requirements simultaneously.
  3. If a category cannot be covered by any eligible main (including multi-category ones) after cooldown, the UI surfaces a clear warning and that category is excluded from this round's list. The seven meals are drawn from the remaining mains. Cooldown is never silently relaxed.

8.2 Weighting (mains)

Within the eligible pool, each main's probability of being picked is proportional to how often it appears in the meal log — favorites are picked more often, not less. This is a deliberate inversion of the typical "recommend what you haven't tried" heuristic: the household wants to keep eating what they already like.

To prevent the list from being dominated by the top few favorites, the weight is clamped so that no eligible main has a zero or near-zero probability. Concretely (MVP):

weight(main) = max(base_weight, times_eaten)

where base_weight is a small positive floor (e.g., 1). Sampling is without replacement within the seven-meal list.

This is tunable later; the important invariant is no dead meals.

8.3 Sides

  • Only mains with has_sides = true get sides attached. Mains with has_sides = false (casseroles, one-pots) always get zero sides.
  • For eligible mains, pick 12 side dishes uniformly at random from the side pool. No cooldown, no frequency weighting on sides.

8.4 Swap action

Two swap modes, exposed as separate controls on each slot:

  • Swap within same category (default). The replacement main's categories must be a superset of the slot's current categories, so the week's coverage cannot be weakened by the swap.
    • For a single-category slot, any main sharing that category qualifies.
    • For a multi-category slot (e.g., tortillas), only mains covering the same full set qualify — often only other wildcards. If nothing qualifies, the button is disabled and the user falls back to "any category".
  • Swap any category. The replacement can be any eligible main. If this breaks coverage (e.g., removes the only fish), the UI warns but allows it — the user is explicitly asking for freedom.

Both modes honor the cooldown. After a swap, the cached current suggestion is updated in place.

9. Tech stack

The whole app is one static Go binary. There is no Node.js anywhere in the build and no asset bundler.

  • Language: Go 1.27. No toolchain directive is pinned in go.mod, so moving to a newer Go is a one-line change.
  • HTTP: standard library net/http with http.ServeMux. Its method + path patterns cover the ~15 routes this app needs; no third-party router.
  • Views: templ components, rendered server-side. templ is a go tool dependency and runs at build time; the generated *_templ.go files are not committed.
  • Interactivity: Datastar. One ~11 kB script supplies both the reactive client state and the server-driven DOM patching, replacing what would otherwise be htmx and Alpine.js. Its reference SDK is Go (github.com/starfederation/datastar-go): handlers read client state with datastar.ReadSignals and reply over SSE with PatchElements.
  • Styling: hand-written CSS, one file, no framework and no build step. Light and dark themes come from color-scheme: light dark plus light-dark() custom properties, which also gets native form controls, scrollbars, and focus rings themed for free. Explicitly not Pico, not Tailwind, not SCSS.
  • Fonts: self-hosted woff2 under static/. No external font CDN — the app has to work with no internet access at all.
  • Database: SQLite through modernc.org/sqlite, which is pure Go and so keeps CGO_ENABLED=0 and a fully static binary. One file on a named volume; there is no separate database service. A single household writing one row per day does not need Postgres.
  • Data access: database/sql and hand-written SQL. No ORM.
  • Migrations: numbered .sql files under cmd/foodster/migrations, embedded with embed.FS and applied in filename order on startup. Each one runs inside a transaction and is recorded in a schema_migrations table, so it applies exactly once. No migration library — the runner is about sixty lines of database/sql. There are no down-migrations: restoring the database file is the rollback for a single-household app.
  • Bundle import: the §7.3 mass import is a live feature of the running app, on the Ruoat tab — paste JSON or upload a file, get a per-row report back. A plain multipart form rather than a Datastar round trip, since the response is a whole-page report and a form needs no client code. Uploads are capped at 1 MiB. The same importer is also reachable as foodster -import <file.json> for repopulating a scratch database without starting the server; seeds/testi.json is the committed fixture. One code path serves both, so the format is exercised twice.
  • Time: the TZ environment variable, defaulting to Europe/Helsinki, loaded via time.LoadLocation and fatal on a bad value — a silent fallback to UTC would shift logged dinners to the wrong calendar day. time/tzdata is imported because the runtime image carries no zoneinfo. All date logic uses that location explicitly and never time.Local.
  • Auth: HTTP Basic with one shared household password read from FOODSTER_PASSWORD; the username is ignored. Compared using subtle.ConstantTimeCompare over SHA-256 digests so neither the value nor its length leaks through timing. /healthz is the only route outside auth.
  • Exposure: the app is served on a public hostname behind Traefik, which terminates TLS, so Basic credentials are encrypted in transit. A shared password is therefore the only thing between the internet and the app, and it is guarded by a per-address rate limiter: five wrong guesses, then one per ten seconds, answered with 429. Only requests that actually present a wrong password spend the allowance — a request with no Authorization header is the normal browser handshake that opens every session. X-Forwarded-For is trusted only when the connection arrived from a private address, so a direct client cannot forge a new identity per attempt. None of this substitutes for a strong password; it only removes brute force as a practical route.
  • Containers: built with Podman in development, run under Docker Compose in production. Images are OCI, so one image works with both engines.

Explicitly not React.

10. Deployment

Images are built locally, pushed to a private container registry, then pulled on the server and run with Docker Compose.

  • Branches: main carries released versions only, so its history is the deployment history and every release tag points into it. Development happens on dev and merges into main when a release is cut.
  • Versioning: CalVer vYYYYMMDD-N, where N is the Nth build of that day. make image derives N by counting the day's existing git tags, creates the new tag, and bakes the version into the binary through -ldflags -X main.version. make release builds, tags and pushes.
  • Tooling: a Makefile is the single entry point — make on its own lists every target. Build, test, lint, format, image and compose commands all live there rather than in loose scripts.
  • Image: a two-stage Containerfile. golang:1.27-alpine compiles a static binary; the runtime stage is FROM scratch holding only that binary, running as UID 65534.
  • Compose: a single service. No database container — SQLite lives at /data/foodster.db, bind-mounted from ./data on the host rather than kept in a named volume, so the file can be listed and copied without going through the container engine. Backup is cp -r data. Because the image runs as UID 65534, compose sets user: from FOODSTER_UID/FOODSTER_GID to match whoever owns that directory. restart: unless-stopped.
  • Configuration, entirely through environment variables (see .env.example):
    • FOODSTER_REPO and FOODSTER_TAG — image coordinates.
    • FOODSTER_PASSWORD — the shared password. Required; the app refuses to start without it.
    • FOODSTER_DB — database file path, default ./data/foodster.db. The directory is created on startup if missing.
    • FOODSTER_UID / FOODSTER_GID — host owner of ./data.
    • TZ — default Europe/Helsinki.
  • The registry hostname exists only in .env, which is gitignored, because §11 leaves open the possibility of publishing this repository.
  • Routing: Traefik on an external traefik network, matching on FOODSTER_HOST and terminating TLS. The container publishes no ports — doing so would put an unencrypted copy of the app on the host, bypassing the proxy. The hostname lives in .env rather than compose.yaml, so no infrastructure detail is committed.
  • Health: GET /healthz returns the build version and is exempt from auth. There is no Docker HEALTHCHECK directive, because a scratch image has no shell to run one and restart: unless-stopped already covers a dead process. Adding one would mean giving the binary a -healthcheck flag that calls its own endpoint.
  • Pending migrations are applied on app start.
  • The Datastar client is vendored at cmd/foodster/static/datastar.js and served from the app's own origin — the SDK ships no browser asset, and a CDN link would break an offline LAN. make vendor refreshes it; the pinned version lives in the Makefile and in the file's first line.
  • No internet exposure; the server binds to the LAN.

11. Licensing

MIT from the outset — see LICENSE, copyright Kessinen. The repository stays private during build-out, so releasing it later is a matter of flipping visibility rather than relicensing.

This is why no infrastructure detail (registry hostname, server name, port mapping) may be committed: those live in .env, which is gitignored.

12. Open questions / future work

  • Grocery list generation from the seven-meal suggestion (requires adding ingredients to the meal model — out of scope for MVP).
  • Tags on mains (e.g., "quick", "summer", "comfort") to filter suggestions.
  • Seasonal biasing (e.g., soups in winter).
  • Optional "pin" on a suggested slot so regenerate all doesn't replace it.
  • Backup / export of the meal log (JSON dump).
  • Telemetry-free analytics: a stats page showing most-eaten mains, category distribution over time, etc.