Files
menu/docs/plan.md
T
tanshu 25ef94d7af Use the old static menu directory as the app's data root
That was the intent of the uncommitted edit, and it is a better location than a
second directory: the app inherits the place the menus were already served from
and only ever reads and writes its own data/ and site.env inside it.

app_root is written as /var/lib/{{ host }}, quoted, because the unquoted form is
what failed to parse. Every documented path follows, and the deployment readme
now says that emptying the old menu files means deleting those files rather than
the directory, which holds the live content.
2026-09-22 23:38:21 +05:30

12 KiB

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 <img> 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
Categories Content, not configuration: added, renamed, reordered and removed in the admin (ADR 0007, superseding ADR 0005)
Lifecycle Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006)
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. This Site starts with Food, Bar, Wine, Straight Drinks, Delivery and What's On; the owner adds and renames categories from the admin. One Current Menu Set per Section, and 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.hopsngrains.com/
├── site.env                        # ADMIN_PASSWORD_HASH, SESSION_SECRET (mode 600, untracked)
└── data/
    ├── originals/<section>/<set-id>/<page-id>.<hash>.<ext>   # never served
    ├── drafts/<section>/<set-id>/                  # manifest.json + variants
    └── published/<section>/<slug>/                 # manifest.json + variants
  • <slug> 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.
  • Editing rules: a Draft accepts anything; the Current set accepts reordering only (order is presentation, ADR 0006); an Archive is frozen.
  • 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: the shelves, one per Section with a Current set
GET /<section>/ Current Menu Set
GET /<section>/<slug>/ Archived Menu Set (unlisted, still reachable)
GET /<section>/#page-N Deep link to one Page
GET /media/<page-id>.<hash>.webp|.jpg Viewing image, immutable cache; keyed by Page id so the URL survives publish
GET /admin/* Admin, session required, noindex
GET /admin/sets/<id>/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

The look is the Atelier Dining & Hospitality system from Stitch (docs/design/stitch/): warm ivory linen and espresso for the guest surfaces, burnished gold for the one active thing, Playfair Display carrying the restaurant's voice, Plus Jakarta Sans carrying everything transactional. The landing is an editorial directory — each category a hairline row with a small plate of that menu's paper, its name in Playfair, a dot leader and a tabular page count. The bar names the restaurant, links every menu as a pill (the current one espresso-filled), and a gold hairline under it tracks progress through the pages. The administrative side is the same system in its dark mode: espresso workspace, obsidian pods, gold actions.

┌────────────────────────────┐   ┌────────────────────────────┐
│ 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, docs/ (this plan, CONTEXT.md, the ADRs), node --test tests, and deploy.sh (tests, build, push).
  • The container is node:24-alpine running TypeScript directly, so there is no build step; sharp's musl binaries come from npm ci on the image's own platform. It writes only under MENU_ROOT and healthchecks its own /healthz.
  • The design system is documented in docs/design/stitch/atelier-dining-hospitality.md, with the four screens Stitch produced beside it. config/<site>.site.json holds the theme and the seed for everything the owner can change: it seeds data/sections.json (ADR 0007) and data/site.json, which holds the name, tagline, icon and date switch (ADR 0008). Colours stay in the config, so changing them is a rebuild; nothing else does.
  • Build locally and push registry.tanshu.com/menu-admin:<tag>, 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.<site>, start container, health check, then verify the data directory) and caddy (snippet upsert).
  • The existing roles/caddy only adds a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so blockinfile updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname.
  • One-time human step on the server: a scripts/provision-site-env.sh prompts for the admin password, hashes it with the image's own hasher, and writes /var/lib/menu.<site>/site.env (mode 600, never in git). Until it exists the admin is disabled and the public menu still serves.
  • A small CLI: verify reports integrity problems in the data directory, repair regenerates missing variants from the Originals.

Sequence

  1. Storage layer: config, manifest read/write, atomic writes, boot validation, tests.
  2. Image pipeline: sharp variants, content-hashed filenames, tests.
  3. Public renderer: landing, Section and archive routes, design tokens and CSS.
  4. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete.
  5. Container, compose dev fixtures, Ansible role.
  6. Deploy: make build-production TAG=<tag> builds a multi-arch podman manifest and pushes it; the playbook in ansible/ runs one Site to completion — it starts the container and points the Site's web address at it. There is no older system kept serving and nothing to retire, so there is no staged rollout; deploying an earlier tag is the rollback.
  7. Create the admin password once per Site (scripts/provision-site-env.sh), then npm run check-site and a phone.

Out of scope

Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, per-Section theming, migrating menu.hngmohali.com and menu.greatbear.in, and git history for the image content.