# Menu app — agreed plan Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-hosted app that uploads, orders, previews and publishes Menu Sets. Verified against the current environment by read-only reconnaissance of `monoco`. ## What exists today - Caddy (80/443, TLS) → `reverse_proxy nginx-menu.hopsngrains.com:80` → `nginx:alpine` with `-v /var/lib/menu.hopsngrains.com:/usr/share/nginx/html:ro`. - Each Section is a hand-written `index.html` listing one `` per JPG, with a vendored copy of Materialize CSS/JS in every Section folder. - The read-only mount is why content can only be changed over SSH. `static-menu/Caddyfile` is dead config from the retired `caddy-menu` playbook and is read by nothing. - The same playbook serves three Sites: `menu.hopsngrains.com` (319M, this folder), `menu.hngmohali.com` (208M), `menu.greatbear.in` (405M). Remote disk: 15G free, 82% used. - `nginx-menu/` is untracked in the `ansible-playbooks` repo (`git.tanshu.com:tanshu/playbooks`). Sibling projects build an image locally, push to `registry.tanshu.com`, then run `ansible-playbook -e tag=`. ## Decisions | Question | Decision | | --- | --- | | Upload path | Small self-hosted app on the same host, replacing `scp` | | Serving | The app serves HTML **and** images; nginx is retired per Site (ADR 0001) | | Rendering | Per request, server-rendered HTML (ADR 0003) | | Stack | Node + Fastify + `sharp`, one container | | Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) | | Scope | One app instance per Site (ADR 0004); other two Sites migrate later | | Sections | Per-Site config in git; not editable in the admin (ADR 0005) | | Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable | | Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server | | Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served | | Logging | JSON to stdout only; no Prometheus, no Promtail | | Backups | Owner's existing backups; out of scope | ## Domain `CONTEXT.md` is the glossary. Sections for this Site: Food, Bar, Wine, Straight Drinks, Delivery, What's On. One Current Menu Set per Section; a Section with no Current set does not appear on the landing page. ## Architecture ### Topology ``` caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000 ├── renders landing, Section, archive pages └── serves viewing images (nginx-menu.hopsngrains.com retired after cutover) ``` ### Storage ``` /var/lib/menu-app.hopsngrains.com/ ├── site.env # ADMIN_PASSWORD_HASH, SESSION_SECRET (mode 600, untracked) └── data/ ├── originals/
//.. # never served ├── drafts/
// # manifest.json + variants └── published/
// # manifest.json + variants ``` - `` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision. - Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable. - `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename. - **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift. - Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist. ### Routes | Route | Purpose | | --- | --- | | `GET /` | Landing page: Sections with a Current set, text rows | | `GET /
/` | Current Menu Set | | `GET /
//` | Archived Menu Set (unlisted, still reachable) | | `GET /
/#page-N` | Deep link to one Page | | `GET /media/..webp\|.jpg` | Viewing image, `immutable` cache; keyed by Page id so the URL survives publish | | `GET /admin/*` | Admin, session required, `noindex` | | `GET /admin/sets//preview` | Draft rendered exactly as customers would see it | Unknown Sections and Sections without a Current set 404 rather than redirect, so a stale printed link fails loudly instead of showing the wrong menu. Media for a Draft is served only to a session holder, which is what keeps Drafts unreachable in practice. ### Image pipeline - Accept JPEG/PNG/WebP up to 30MB, up to 40 Pages per set; anything else is rejected with a message naming the file. - The browser pre-shrinks to 3000px on the long edge before upload (fast over restaurant wifi); if the browser cannot decode the file, the file is uploaded untouched. - Server: apply EXIF orientation, strip all other metadata, write the Original, generate a 1600px WebP (q80) + JPEG (q82) Viewing image and a 480px WebP Thumbnail. - Variant filenames carry a short content hash and are keyed by Page id, so a replaced Page cannot be served from a stale cache and a published URL keeps working across a re-publish. ### Admin Log in; see each Section with its Current set and state; create a Menu Set (choose Section, add Pages, drag to reorder, set label and optional valid-until, thumbnail grid); preview; publish (archives the previous set); restore an Archive as Current; delete a Draft or Archive. No user management, no scheduling, no Section editing, no password-change flow (edit `site.env` and restart). Protection: a `scrypt` hash (from `node:crypto`, so no native build dependency) in `site.env`, httpOnly + Secure + SameSite=Lax session cookie, login throttled per IP, `noindex` on all admin routes. Originals and Drafts have no public route at all. ### Customer design Text-led landing page; Sections as tap-list rows with copper hairlines; no hero photograph, no cards, no shadows. ``` ┌────────────────────────────┐ ┌────────────────────────────┐ │ HOPS N GRAINS │ │ HOPS N GRAINS ⌂ │ │ Food 12 pages │ │ Bar Food Wine Drinks │ │ ────────────────────────── │ │ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░ │ │ Bar 20 pages │ ├────────────────────────────┤ │ ────────────────────────── │ │ ┌──────────────────────┐ │ │ Wine │ │ │ menu page photo │ │ └────────────────────────────┘ │ └──────────────────────┘ │ │ 3 / 12 │ └────────────────────────────┘ ``` - Colour: `#14181b` steel, `#1f262b` rail, `#f2efe9` paper, `#0088aa` logo teal, `#b2703b` copper. - Type: self-hosted variable **Archivo** — expanded 600 for the brand and Section names, normal width for body and UI, tabular numerals for the counter. - The one memorable element: a copper "pour" line in the sticky Section rail that fills as you scroll, doubling as position in a 20-page menu. - Tapping the page counter opens a text page index for jumping; a quiet "Older menus" line at the foot of a Section page lists Archives. - Roughly 2KB of progressive-enhancement JS for the lightbox and counter. Visible focus, `alt` text ("Bar menu, page 3 of 12"), `prefers-reduced-motion` respected, self-hosted fonts so it works on restaurant wifi. ## Repos, build and deploy - New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev against a `fixtures/` folder, `docs/` (this plan, `CONTEXT.md`, the ADRs), and `node --test` tests for the storage and image layers. - Build locally and push `registry.tanshu.com/menu-admin:`, matching the sibling `deploy.sh` / `make build-production TAG=` convention. - New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.`, start container), `caddy` (snippet upsert). - The existing `roles/caddy` only *adds* a snippet when missing, which cannot change a proxy target; it is fixed to update an existing managed block in place. - One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it, and writes `/var/lib/menu-app./site.env` (mode 600, never in git). - A small CLI: `verify` reports integrity problems in the data directory, `repair` regenerates missing variants from the Originals. ## Sequence 1. Commit `nginx-menu/` to git so the current state is reproducible and revertible; create the `menu-admin` repo and move `CONTEXT.md`, `docs/`, and the ADRs into it. 2. Storage layer: config, manifest read/write, atomic writes, boot validation, tests. 3. Image pipeline: sharp variants, content-hashed filenames, tests. 4. Public renderer: landing, Section and archive routes, design tokens and CSS. 5. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete. 6. Container, compose dev fixtures, Ansible role, deploy to `menu-new.hopsngrains.com`, verify from a phone on restaurant wifi. 7. Cutover: re-upload the current Food, Bar and Delivery menus, create What's On from the posters, switch `menu.hopsngrains.com` to the app, stop the old nginx container. 8. After a week: remove the old container and `/var/lib/menu.hopsngrains.com`, delete the dated folders, drop the dead `Caddyfile`, and reduce this folder to dev fixtures. Deleting the old 319MB directory is the only irreversible step, so it is last. ## Out of scope Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, admin-editable Sections, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.