From edea83228cd7ee0bc5b32fd4ecf5d665c3c5cc34 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 21:15:00 +0530 Subject: [PATCH] Menu Set store, image pipeline and Site config The store keeps a Menu Set's state in the directory layout (drafts/ versus published/) and its metadata in a manifest, with publishing as a single directory rename. Editing is allowed on Drafts only, so a published menu can never change under a customer. Stages 2 and 3 of docs/plan.md, with tests. --- .gitignore | 5 + CONTEXT.md | 68 + README.md | 48 + .../0001-app-serves-menus-instead-of-nginx.md | 5 + .../0002-menu-metadata-in-json-manifests.md | 5 + .../0003-menu-pages-rendered-per-request.md | 5 + docs/adr/0004-one-app-instance-per-site.md | 5 + .../0005-sections-come-from-configuration.md | 5 + docs/plan.md | 136 ++ package-lock.json | 1766 +++++++++++++++++ package.json | 25 + src/config.ts | 195 ++ src/images/pipeline.ts | 105 + src/storage/manifest.ts | 194 ++ src/storage/sets.ts | 405 ++++ src/storage/validate.ts | 119 ++ test/config.test.ts | 101 + test/pipeline.test.ts | 112 ++ test/sets.test.ts | 276 +++ test/support/harness.ts | 66 + test/validate.test.ts | 102 + tsconfig.json | 22 + 22 files changed, 3770 insertions(+) create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 README.md create mode 100644 docs/adr/0001-app-serves-menus-instead-of-nginx.md create mode 100644 docs/adr/0002-menu-metadata-in-json-manifests.md create mode 100644 docs/adr/0003-menu-pages-rendered-per-request.md create mode 100644 docs/adr/0004-one-app-instance-per-site.md create mode 100644 docs/adr/0005-sections-come-from-configuration.md create mode 100644 docs/plan.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/images/pipeline.ts create mode 100644 src/storage/manifest.ts create mode 100644 src/storage/sets.ts create mode 100644 src/storage/validate.ts create mode 100644 test/config.test.ts create mode 100644 test/pipeline.test.ts create mode 100644 test/sets.test.ts create mode 100644 test/support/harness.ts create mode 100644 test/validate.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8e394a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.npm-cache/ +.tmp/ +*.log +site.env diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..954ebd5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# Menu Publishing + +Publishing the customer-facing menus of several brewery-restaurant Sites. A Site serves photographed pages of its printed menus, grouped into Sections and published as Menu Sets. + +## Language + +### Structure + +**Site**: +One restaurant's customer-facing menu deployment, with its own brand, Sections and content. Each Site runs its own instance of the app. +_Avoid_: brand, tenant, deployment, instance + +**Section**: +A top-level category of menu content that customers choose between, such as Food, Bar or What's On. +_Avoid_: type, category, tab, folder + +**Menu Set**: +One complete set of Pages published together for a Section, with a label and an optional valid-until date. +_Avoid_: menu, version, batch, upload, album + +**Page**: +One image within a Menu Set, in a deliberate order; usually a photographed page of a printed menu. +_Avoid_: sheet, slide, image, photo + +**Poster**: +A Page whose content is an event or seasonal creative rather than a menu, published in the What's On Section. +_Avoid_: flyer, banner, creative + +### Publication + +**Draft**: +A Menu Set that has been uploaded but is not visible to customers. + +**Current**: +The single published Menu Set per Section that customers see. +_Avoid_: live, active, main + +**Archive**: +A Menu Set that was once Current and has been superseded. Reachable by direct URL, never listed in customer navigation. +_Avoid_: old menu, history, expired, past + +**Publish**: +The act of making a Draft Current, which archives the Menu Set that was Current before it. +_Avoid_: release, deploy, go live + +### Images + +**Original**: +The uploaded image as stored, kept so that viewing images and thumbnails can be regenerated. Never served to customers. +_Avoid_: source, master, full-size + +**Viewing image**: +The wide image shown on a Section page and opened by tap-to-zoom. +_Avoid_: preview, display image + +**Thumbnail**: +The small image used only inside the admin. +_Avoid_: preview, grid image + +### Surfaces + +**Landing page**: +The Site root, listing the Sections that have a Current Menu Set. +_Avoid_: home, index + +**Admin**: +The password-protected area where a Menu Set is uploaded, ordered, previewed and published. +_Avoid_: dashboard, CMS, back office diff --git a/README.md b/README.md new file mode 100644 index 0000000..979c729 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# menu-admin + +Uploads, orders and publishes the customer-facing menus of a restaurant Site as +photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site; +`docs/plan.md` is the agreed design and `CONTEXT.md` is the glossary. + +## Status + +Built so far: Site config parsing, the Menu Set store and the image pipeline, +with tests. Not yet built: the public renderer, the admin, the container and the +Ansible role. + +## Layout + +``` +src/config.ts Site config: brand, theme, Sections, limits +src/storage/manifest.ts Manifest type, validation, atomic JSON writes +src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete +src/storage/validate.ts Boot validation: fatal problems and warnings +src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail +test/ node:test suites +docs/plan.md The agreed plan +docs/adr/ Decisions worth remembering +``` + +## Working on it + +Node 24 or newer runs the TypeScript sources directly. + +```sh +npm install +npm test # node:test +npm run typecheck # tsc --noEmit +``` + +## Storage model + +``` +data/ +├── originals/
//.. # kept, never served +├── drafts/
// # manifest.json + variants +└── published/
// # manifest.json + variants +``` + +The directory decides a Menu Set's state; the manifest carries metadata only. +`` is the valid-until date, else the publication date. The Current Menu +Set for a Section is the published one with the newest `publishedAt`. Published +sets are immutable: changing a menu means publishing a new set. diff --git a/docs/adr/0001-app-serves-menus-instead-of-nginx.md b/docs/adr/0001-app-serves-menus-instead-of-nginx.md new file mode 100644 index 0000000..d853ff8 --- /dev/null +++ b/docs/adr/0001-app-serves-menus-instead-of-nginx.md @@ -0,0 +1,5 @@ +# Menus are served by the app, not by nginx + +Each Site's `nginx:alpine` container and its read-only bind mount are retired: the app renders the menu HTML and serves the images itself, with Caddy reverse-proxying `menu.` to it. The read-only mount was the reason menus could only be changed by `scp`, and keeping a file server alongside a dynamic renderer would leave two views of one directory free to drift. + +**Considered Options**: a hybrid where Caddy routes HTML to the app and image paths to nginx; and an app that renders HTML only and proxies image bytes to nginx. Both were rejected for preserving a second moving part whose only job is reading files the app already owns. diff --git a/docs/adr/0002-menu-metadata-in-json-manifests.md b/docs/adr/0002-menu-metadata-in-json-manifests.md new file mode 100644 index 0000000..41cc8ad --- /dev/null +++ b/docs/adr/0002-menu-metadata-in-json-manifests.md @@ -0,0 +1,5 @@ +# Menu metadata lives in JSON manifests, not a database + +Menu Sets, their Page order, labels, dates and state are recorded in JSON manifests beside the images rather than in SQLite. The state is small and single-writer, and a manifest the owner can read and repair by hand was preferred over a database file that needs a tool to inspect. + +**Consequences**: invariants such as "exactly one Current Menu Set per Section" are enforced by the app at boot rather than by a storage engine, and `publish`/`archive` serialize behind an in-process lock instead of a transaction. Publication state is carried by the directory layout, so manifests can be rebuilt from disk if one is lost. diff --git a/docs/adr/0003-menu-pages-rendered-per-request.md b/docs/adr/0003-menu-pages-rendered-per-request.md new file mode 100644 index 0000000..8ac5163 --- /dev/null +++ b/docs/adr/0003-menu-pages-rendered-per-request.md @@ -0,0 +1,5 @@ +# Menu pages are rendered per request + +The landing page and Section pages are rendered by the app on each request instead of being generated as static HTML at publish time. Publication becomes a single directory rename, and a Draft is previewed through the same renderer customers use, at the cost of every customer page view depending on the app being up. + +**Considered Options**: static generation at publish time, rejected because it would mean two renderers to keep in step and a separate path for previewing Drafts. diff --git a/docs/adr/0004-one-app-instance-per-site.md b/docs/adr/0004-one-app-instance-per-site.md new file mode 100644 index 0000000..3d89266 --- /dev/null +++ b/docs/adr/0004-one-app-instance-per-site.md @@ -0,0 +1,5 @@ +# One app instance per Site + +The same app is deployed once per Site — hopsngrains, hngmohali and greatbear — each with its own configuration, admin password and data directory, rather than one instance serving all Sites with a Site dimension in its data model. The three menu deployments share a serving pattern but not their brands, Sections or content, and separate instances keep one Site's mistake away from the others. + +**Considered Options**: a single multi-Site app with Site above Section in the data model, rejected as a retrofit risk with no benefit at three Sites. diff --git a/docs/adr/0005-sections-come-from-configuration.md b/docs/adr/0005-sections-come-from-configuration.md new file mode 100644 index 0000000..20023c1 --- /dev/null +++ b/docs/adr/0005-sections-come-from-configuration.md @@ -0,0 +1,5 @@ +# Sections come from configuration, not from the admin + +A Site's Sections are declared in its config file and change only with a redeploy; the admin manages the Menu Sets inside Sections and cannot create, rename or reorder Sections. Sections are few and stable, and keeping them out of the admin removed a whole class of editing, reordering and deletion edge cases from the content model. + +**Consequences**: adding a Section to a live Site is a tracked change in `ansible-playbooks` rather than a click, and the admin's surface stays limited to publishing content. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..0cb112a --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,136 @@ +# 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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1974c6b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1766 @@ +{ + "name": "menu-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "menu-admin", + "version": "0.1.0", + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/multipart": "^10.1.2", + "fastify": "^5.12.5", + "sharp": "^0.35.4" + }, + "devDependencies": { + "@types/node": "^26.6.2", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.2.tgz", + "integrity": "sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^6.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa3b255 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "menu-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node src/server.ts", + "dev": "node --watch src/server.ts", + "typecheck": "tsc --noEmit", + "test": "node --test test/*.test.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/multipart": "^10.1.2", + "fastify": "^5.12.5", + "sharp": "^0.35.4" + }, + "devDependencies": { + "@types/node": "^26.6.2", + "typescript": "^7.0.2" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8636162 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,195 @@ +/** + * Site configuration: what makes one deployment of this app a particular + * restaurant's menu site. Sections come from here and never from the admin. + */ + +export interface SectionConfig { + slug: string; + label: string; + shortLabel: string; + note: string | null; + order: number; +} + +export interface ThemeConfig { + steel: string; + rail: string; + paper: string; + accent: string; + copper: string; +} + +export interface BrandConfig { + name: string; + tagline: string | null; + logo: string | null; +} + +export interface LimitsConfig { + maxUploadBytes: number; + maxPagesPerSet: number; +} + +export interface SiteConfig { + site: string; + brand: BrandConfig; + theme: ThemeConfig; + sections: SectionConfig[]; + limits: LimitsConfig; +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +/** Slugs the router claims for itself; a Section may not use one. */ +export const RESERVED_SLUGS = new Set([ + 'admin', + 'media', + 'assets', + 'healthz', + 'favicon.ico', + 'robots.txt', +]); + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const HEX_COLOUR = /^#[0-9a-f]{6}$/i; + +const DEFAULT_LIMITS: LimitsConfig = { + maxUploadBytes: 30 * 1024 * 1024, + maxPagesPerSet: 40, +}; + +const DEFAULT_THEME: ThemeConfig = { + steel: '#14181b', + rail: '#1f262b', + paper: '#f2efe9', + accent: '#0088aa', + copper: '#b2703b', +}; + +function fail(message: string): never { + throw new ConfigError(message); +} + +function requireString(value: unknown, where: string): string { + if (typeof value !== 'string' || value.trim() === '') { + fail(`${where} must be a non-empty string`); + } + return value; +} + +function optionalString(value: unknown, where: string): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string') fail(`${where} must be a string when present`); + return value; +} + +function requireObject(value: unknown, where: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail(`${where} must be an object`); + } + return value as Record; +} + +function optionalPositiveInt(value: unknown, where: string, fallback: number): number { + if (value === undefined || value === null) return fallback; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + fail(`${where} must be a positive whole number`); + } + return value; +} + +function parseTheme(value: unknown): ThemeConfig { + if (value === undefined || value === null) return { ...DEFAULT_THEME }; + const raw = requireObject(value, 'theme'); + const theme = { ...DEFAULT_THEME }; + for (const key of Object.keys(DEFAULT_THEME) as (keyof ThemeConfig)[]) { + const candidate = raw[key]; + if (candidate === undefined || candidate === null) continue; + if (typeof candidate !== 'string' || !HEX_COLOUR.test(candidate)) { + fail(`theme.${key} must be a #rrggbb colour`); + } + theme[key] = candidate; + } + return theme; +} + +function parseSections(value: unknown): SectionConfig[] { + if (!Array.isArray(value) || value.length === 0) { + fail('sections must be a non-empty array'); + } + const seenSlugs = new Set(); + const seenOrders = new Set(); + const sections = value.map((entry, index) => { + const raw = requireObject(entry, `sections[${index}]`); + const slug = requireString(raw['slug'], `sections[${index}].slug`); + if (!SLUG.test(slug)) { + fail(`sections[${index}].slug ${JSON.stringify(slug)} must be lower-case words joined by dashes`); + } + if (RESERVED_SLUGS.has(slug)) { + fail(`sections[${index}].slug ${JSON.stringify(slug)} is reserved by the router`); + } + if (seenSlugs.has(slug)) fail(`duplicate Section slug ${JSON.stringify(slug)}`); + seenSlugs.add(slug); + + const label = requireString(raw['label'], `sections[${index}].label`); + const order = raw['order']; + if (typeof order !== 'number' || !Number.isSafeInteger(order)) { + fail(`sections[${index}].order must be a whole number`); + } + if (seenOrders.has(order)) fail(`duplicate Section order ${order}`); + seenOrders.add(order); + + return { + slug, + label, + shortLabel: optionalString(raw['shortLabel'], `sections[${index}].shortLabel`) ?? label, + note: optionalString(raw['note'], `sections[${index}].note`), + order, + }; + }); + return sections.sort((a, b) => a.order - b.order); +} + +export function parseSiteConfig(raw: unknown): SiteConfig { + const root = requireObject(raw, 'config'); + const site = requireString(root['site'], 'site'); + + const brandRaw = requireObject(root['brand'], 'brand'); + const brand: BrandConfig = { + name: requireString(brandRaw['name'], 'brand.name'), + tagline: optionalString(brandRaw['tagline'], 'brand.tagline'), + logo: optionalString(brandRaw['logo'], 'brand.logo'), + }; + + const limitsRaw = root['limits'] === undefined || root['limits'] === null + ? {} + : requireObject(root['limits'], 'limits'); + + return { + site, + brand, + theme: parseTheme(root['theme']), + sections: parseSections(root['sections']), + limits: { + maxUploadBytes: optionalPositiveInt( + limitsRaw['maxUploadBytes'], + 'limits.maxUploadBytes', + DEFAULT_LIMITS.maxUploadBytes, + ), + maxPagesPerSet: optionalPositiveInt( + limitsRaw['maxPagesPerSet'], + 'limits.maxPagesPerSet', + DEFAULT_LIMITS.maxPagesPerSet, + ), + }, + }; +} + +export function sectionBySlug(config: SiteConfig, slug: string): SectionConfig | undefined { + return config.sections.find((section) => section.slug === slug); +} diff --git a/src/images/pipeline.ts b/src/images/pipeline.ts new file mode 100644 index 0000000..3eb6d8a --- /dev/null +++ b/src/images/pipeline.ts @@ -0,0 +1,105 @@ +/** + * The image pipeline: turn an uploaded photo into the three representations a + * Page needs — the Original (kept, never served), a Viewing image and a + * Thumbnail. Metadata is stripped; EXIF orientation is applied first so the + * pixels are already upright. + */ + +import { createHash } from 'node:crypto'; +import sharp from 'sharp'; +import type { ImageExt } from '../storage/manifest.ts'; +import type { ImageDerivatives, ImagePipeline } from '../storage/sets.ts'; + +export const VIEW_WIDTH = 1600; +export const THUMB_WIDTH = 480; +export const MAX_SOURCE_EDGE = 3000; + +/** Accepted upload formats, mapped to the extension we store. */ +export const ACCEPTED_FORMATS: Readonly> = { + jpeg: 'jpg', + png: 'png', + webp: 'webp', +}; + +export const ACCEPTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const; + +export class UnsupportedImageError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedImageError'; + } +} + +export interface ImagePipelineOptions { + viewWidth?: number; + thumbWidth?: number; + webpQuality?: number; + jpegQuality?: number; + thumbQuality?: number; +} + +export function contentHash(data: Buffer): string { + return createHash('sha256').update(data).digest('hex').slice(0, 12); +} + +export function createImagePipeline(options: ImagePipelineOptions = {}): ImagePipeline { + const viewWidth = options.viewWidth ?? VIEW_WIDTH; + const thumbWidth = options.thumbWidth ?? THUMB_WIDTH; + const webpQuality = options.webpQuality ?? 80; + const jpegQuality = options.jpegQuality ?? 82; + const thumbQuality = options.thumbQuality ?? 75; + + return { + async process(data: Buffer, originalName: string): Promise { + const source = sharp(data, { failOn: 'error' }); + let format: string | undefined; + try { + format = (await source.metadata()).format; + } catch (error) { + throw new UnsupportedImageError( + `${originalName} could not be read as an image: ${(error as Error).message}`, + ); + } + const sourceExt = format === undefined ? undefined : ACCEPTED_FORMATS[format]; + if (sourceExt === undefined) { + const found = format ?? 'unknown'; + throw new UnsupportedImageError( + `${originalName} is ${found}; upload a JPEG, PNG or WebP`, + ); + } + + // .rotate() with no angle auto-orients from EXIF; omitting withMetadata() + // strips the tag afterwards. + const upright = sharp(data, { failOn: 'error' }).rotate(); + const view = await upright + .clone() + .resize({ width: viewWidth, withoutEnlargement: true }) + .webp({ quality: webpQuality }) + .toBuffer({ resolveWithObject: true }); + + const jpeg = await sharp(data, { failOn: 'error' }) + .rotate() + .resize({ width: viewWidth, withoutEnlargement: true }) + .jpeg({ quality: jpegQuality, mozjpeg: true }) + .toBuffer(); + + const thumb = await sharp(data, { failOn: 'error' }) + .rotate() + .resize({ width: thumbWidth, withoutEnlargement: true }) + .webp({ quality: thumbQuality }) + .toBuffer(); + + return { + sourceExt, + hash: contentHash(data), + // The Viewing image's dimensions: what a browser needs to reserve space. + width: view.info.width, + height: view.info.height, + original: data, + webp: view.data, + jpeg, + thumb, + }; + }, + }; +} diff --git a/src/storage/manifest.ts b/src/storage/manifest.ts new file mode 100644 index 0000000..7003421 --- /dev/null +++ b/src/storage/manifest.ts @@ -0,0 +1,194 @@ +/** + * Manifests are the JSON half of the storage model: the directory layout + * carries a Menu Set's state, the manifest carries its metadata. + */ + +import { open, readFile, rename } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export const MANIFEST_FILE = 'manifest.json'; + +export type ImageExt = 'jpg' | 'png' | 'webp'; + +export interface PageRecord { + /** Stable across publish: media URLs are keyed by it. */ + id: string; + sourceExt: ImageExt; + /** Short content hash, so a replaced Page cannot be served from a stale cache. */ + hash: string; + width: number; + height: number; + originalName: string; +} + +export interface SetManifest { + id: string; + section: string; + label: string; + validUntil: string | null; + createdAt: string; + /** Set when published; the newest publishedAt per Section is Current. Null while a Draft. */ + publishedAt: string | null; + pages: PageRecord[]; +} + +export class ManifestError extends Error { + constructor(message: string) { + super(message); + this.name = 'ManifestError'; + } +} + +export function newId(): string { + return randomUUID(); +} + +export function variantFileNames(page: PageRecord): { webp: string; jpeg: string; thumb: string } { + return { + webp: `${page.id}.${page.hash}.webp`, + jpeg: `${page.id}.${page.hash}.jpg`, + thumb: `thumb-${page.id}.${page.hash}.webp`, + }; +} + +export function originalFileName(page: PageRecord): string { + return `${page.id}.${page.hash}.${page.sourceExt}`; +} + +function isIsoDate(value: unknown): value is string { + return typeof value === 'string' && !Number.isNaN(Date.parse(value)); +} + +function isCalendarDate(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + return !Number.isNaN(Date.parse(`${value}T00:00:00Z`)); +} + +const EXTENSIONS: readonly ImageExt[] = ['jpg', 'png', 'webp']; + +function parsePage(raw: unknown, where: string): PageRecord { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new ManifestError(`${where} must be an object`); + } + const page = raw as Record; + const id = page['id']; + const hash = page['hash']; + const sourceExt = page['sourceExt']; + const width = page['width']; + const height = page['height']; + const originalName = page['originalName']; + if (typeof id !== 'string' || id === '') throw new ManifestError(`${where}.id must be a string`); + if (typeof hash !== 'string' || !/^[0-9a-f]{8,}$/.test(hash)) { + throw new ManifestError(`${where}.hash must be a lowercase hex digest`); + } + if (typeof sourceExt !== 'string' || !EXTENSIONS.includes(sourceExt as ImageExt)) { + throw new ManifestError(`${where}.sourceExt must be one of ${EXTENSIONS.join(', ')}`); + } + if (typeof width !== 'number' || !Number.isSafeInteger(width) || width <= 0) { + throw new ManifestError(`${where}.width must be a positive whole number`); + } + if (typeof height !== 'number' || !Number.isSafeInteger(height) || height <= 0) { + throw new ManifestError(`${where}.height must be a positive whole number`); + } + if (typeof originalName !== 'string') throw new ManifestError(`${where}.originalName must be a string`); + return { id, sourceExt: sourceExt as ImageExt, hash, width, height, originalName }; +} + +export function parseManifest(raw: unknown, where: string): SetManifest { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new ManifestError(`${where} must be an object`); + } + const manifest = raw as Record; + const id = manifest['id']; + const section = manifest['section']; + const label = manifest['label']; + const validUntil = manifest['validUntil']; + const createdAt = manifest['createdAt']; + const publishedAt = manifest['publishedAt']; + const pages = manifest['pages']; + + if (typeof id !== 'string' || id === '') throw new ManifestError(`${where}.id must be a string`); + if (typeof section !== 'string' || section === '') { + throw new ManifestError(`${where}.section must be a string`); + } + if (typeof label !== 'string' || label.trim() === '') { + throw new ManifestError(`${where}.label must be a non-empty string`); + } + if (validUntil !== null && !isCalendarDate(validUntil)) { + throw new ManifestError(`${where}.validUntil must be null or a YYYY-MM-DD date`); + } + if (!isIsoDate(createdAt)) throw new ManifestError(`${where}.createdAt must be an ISO timestamp`); + if (publishedAt !== null && !isIsoDate(publishedAt)) { + throw new ManifestError(`${where}.publishedAt must be null or an ISO timestamp`); + } + if (!Array.isArray(pages)) throw new ManifestError(`${where}.pages must be an array`); + + const parsedPages = pages.map((page, index) => parsePage(page, `${where}.pages[${index}]`)); + const ids = new Set(); + for (const page of parsedPages) { + if (ids.has(page.id)) throw new ManifestError(`${where} has two Pages with id ${page.id}`); + ids.add(page.id); + } + + return { + id, + section, + label, + validUntil: validUntil === null ? null : validUntil, + createdAt, + publishedAt: publishedAt === null ? null : publishedAt, + pages: parsedPages, + }; +} + +export async function readManifest(dir: string): Promise { + const file = path.join(dir, MANIFEST_FILE); + let text: string; + try { + text = await readFile(file, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') throw new ManifestError(`${file} does not exist`); + throw error; + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new ManifestError(`${file} is not valid JSON: ${(error as Error).message}`); + } + return parseManifest(raw, file); +} + +/** + * Writes JSON beside the target and renames it into place, so a reader never + * sees a half-written manifest. The directory fsync is best-effort: some + * filesystems refuse to open a directory for this. + */ +export async function writeJsonAtomic(file: string, value: unknown): Promise { + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + const handle = await open(tmp, 'w'); + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tmp, file); + const dir = path.dirname(file); + try { + const dirHandle = await open(dir, 'r'); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } catch { + // Directory fsync is a durability nicety, not a correctness requirement. + } +} + +export async function writeManifest(dir: string, manifest: SetManifest): Promise { + await writeJsonAtomic(path.join(dir, MANIFEST_FILE), manifest); +} diff --git a/src/storage/sets.ts b/src/storage/sets.ts new file mode 100644 index 0000000..a640b61 --- /dev/null +++ b/src/storage/sets.ts @@ -0,0 +1,405 @@ +/** + * The Menu Set store. State lives in the directory layout (`drafts/` is + * invisible to customers, `published/` is reachable); the manifest carries + * metadata only. Editing is allowed on Drafts alone, so a published URL can + * never change under a customer's feet. + */ + +import { mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + MANIFEST_FILE, + ManifestError, + newId, + originalFileName, + readManifest, + variantFileNames, + writeManifest, + type ImageExt, + type PageRecord, + type SetManifest, +} from './manifest.ts'; + +export type SetState = 'draft' | 'published'; + +export class StoredSet { + readonly manifest: SetManifest; + readonly state: SetState; + readonly dir: string; + readonly slug: string | null; + + constructor(manifest: SetManifest, state: SetState, dir: string, slug: string | null) { + this.manifest = manifest; + this.state = state; + this.dir = dir; + this.slug = slug; + } + + get id(): string { + return this.manifest.id; + } + + get section(): string { + return this.manifest.section; + } +} + +export interface ImageDerivatives { + sourceExt: ImageExt; + hash: string; + width: number; + height: number; + original: Buffer; + webp: Buffer; + jpeg: Buffer; + thumb: Buffer; +} + +export interface ImagePipeline { + process(data: Buffer, originalName: string): Promise; +} + +export interface SitePaths { + dataDir: string; + originals: string; + drafts: string; + published: string; +} + +export function sitePaths(dataDir: string): SitePaths { + return { + dataDir, + originals: path.join(dataDir, 'originals'), + drafts: path.join(dataDir, 'drafts'), + published: path.join(dataDir, 'published'), + }; +} + +export class StoreError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = 'StoreError'; + this.status = status; + } +} + +export interface SetStoreOptions { + dataDir: string; + pipeline: ImagePipeline; + /** Configured Section slugs; a set may only belong to one of these. */ + sections: readonly string[]; + maxPagesPerSet: number; + timeZone?: string; + now?: () => Date; +} + +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; + +async function listDirectoryNames(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); +} + +async function rmrf(target: string): Promise { + await rm(target, { recursive: true, force: true }); +} + +export class SetStore { + readonly paths: SitePaths; + + private readonly pipeline: ImagePipeline; + private readonly sections: readonly string[]; + private readonly maxPagesPerSet: number; + private readonly timeZone: string; + private readonly now: () => Date; + private lock: Promise = Promise.resolve(); + + constructor(options: SetStoreOptions) { + this.paths = sitePaths(options.dataDir); + this.pipeline = options.pipeline; + this.sections = options.sections; + this.maxPagesPerSet = options.maxPagesPerSet; + this.timeZone = options.timeZone ?? 'Asia/Kolkata'; + this.now = options.now ?? (() => new Date()); + } + + /** Serialises every mutation; the app is single-process, so this is enough. */ + private exclusive(fn: () => Promise): Promise { + const run = this.lock.then(fn, fn); + this.lock = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + calendarDate(date: Date = this.now()): string { + // en-CA formats as YYYY-MM-DD. + return new Intl.DateTimeFormat('en-CA', { + timeZone: this.timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); + } + + async list(): Promise { + const sets: StoredSet[] = []; + for (const state of ['draft', 'published'] as const) { + const base = state === 'draft' ? this.paths.drafts : this.paths.published; + for (const section of await listDirectoryNames(base)) { + for (const key of await listDirectoryNames(path.join(base, section))) { + const dir = path.join(base, section, key); + sets.push( + new StoredSet(await readManifest(dir), state, dir, state === 'published' ? key : null), + ); + } + } + } + return sets.sort((a, b) => a.manifest.createdAt.localeCompare(b.manifest.createdAt)); + } + + async get(setId: string): Promise { + const found = (await this.list()).find((set) => set.manifest.id === setId); + if (!found) throw new StoreError(`no such Menu Set: ${setId}`, 404); + return found; + } + + /** The Current Menu Set per Section: the published set with the newest publishedAt. */ + async currentBySection(): Promise> { + const current = new Map(); + for (const set of await this.list()) { + if (set.state !== 'published') continue; + const existing = current.get(set.manifest.section); + if (!existing || newerThan(set, existing)) current.set(set.manifest.section, set); + } + return current; + } + + async archives(section: string): Promise { + const current = (await this.currentBySection()).get(section); + return (await this.list()) + .filter( + (set) => + set.state === 'published' && + set.manifest.section === section && + set.manifest.id !== current?.manifest.id, + ) + .sort((a, b) => (b.manifest.publishedAt ?? '').localeCompare(a.manifest.publishedAt ?? '')); + } + + async create( + section: string, + input: { label: string; validUntil?: string | null }, + ): Promise { + if (!this.sections.includes(section)) { + throw new StoreError(`unknown Section: ${section}`); + } + const label = input.label.trim(); + if (label === '') throw new StoreError('a Menu Set needs a label'); + const validUntil = input.validUntil ?? null; + if (validUntil !== null && !CALENDAR_DATE.test(validUntil)) { + throw new StoreError('validUntil must be a YYYY-MM-DD date'); + } + return this.exclusive(async () => { + const id = newId(); + const dir = path.join(this.paths.drafts, section, id); + await mkdir(dir, { recursive: true }); + const manifest: SetManifest = { + id, + section, + label, + validUntil, + createdAt: this.now().toISOString(), + publishedAt: null, + pages: [], + }; + await writeManifest(dir, manifest); + return new StoredSet(manifest, 'draft', dir, null); + }); + } + + async addPage( + setId: string, + input: { data: Buffer; originalName: string }, + ): Promise { + return this.exclusive(async () => { + const set = await this.requireDraft(setId); + if (set.manifest.pages.length >= this.maxPagesPerSet) { + throw new StoreError(`a Menu Set holds at most ${this.maxPagesPerSet} Pages`, 409); + } + const derivatives = await this.pipeline.process(input.data, input.originalName); + const page: PageRecord = { + id: newId(), + sourceExt: derivatives.sourceExt, + hash: derivatives.hash, + width: derivatives.width, + height: derivatives.height, + originalName: input.originalName, + }; + const names = variantFileNames(page); + await mkdir(path.join(set.dir), { recursive: true }); + await writeFile(path.join(set.dir, names.webp), derivatives.webp); + await writeFile(path.join(set.dir, names.jpeg), derivatives.jpeg); + await writeFile(path.join(set.dir, names.thumb), derivatives.thumb); + + const originals = this.originalsDir(set); + await mkdir(originals, { recursive: true }); + await writeFile(path.join(originals, originalFileName(page)), derivatives.original); + + set.manifest.pages.push(page); + await writeManifest(set.dir, set.manifest); + return page; + }); + } + + async removePage(setId: string, pageId: string): Promise { + await this.exclusive(async () => { + const set = await this.requireDraft(setId); + const page = set.manifest.pages.find((candidate) => candidate.id === pageId); + if (!page) throw new StoreError(`no such Page: ${pageId}`, 404); + set.manifest.pages = set.manifest.pages.filter((candidate) => candidate.id !== pageId); + const names = variantFileNames(page); + for (const name of [names.webp, names.jpeg, names.thumb]) { + await rmrf(path.join(set.dir, name)); + } + await rmrf(path.join(this.originalsDir(set), originalFileName(page))); + await writeManifest(set.dir, set.manifest); + }); + } + + async reorder(setId: string, pageIds: readonly string[]): Promise { + await this.exclusive(async () => { + const set = await this.requireDraft(setId); + const before = set.manifest.pages.map((page) => page.id); + const after = [...pageIds]; + if (before.length !== after.length) { + throw new StoreError('reorder must name every Page in the set exactly once'); + } + const seen = new Set(); + for (const id of after) { + if (!before.includes(id)) throw new StoreError(`no such Page: ${id}`, 404); + if (seen.has(id)) throw new StoreError(`Page named twice: ${id}`); + seen.add(id); + } + const byId = new Map(set.manifest.pages.map((page) => [page.id, page])); + set.manifest.pages = after.map((id) => byId.get(id)!); + await writeManifest(set.dir, set.manifest); + }); + } + + async update( + setId: string, + patch: { label?: string; validUntil?: string | null }, + ): Promise { + await this.exclusive(async () => { + const set = await this.requireDraft(setId); + if (patch.label !== undefined) { + const label = patch.label.trim(); + if (label === '') throw new StoreError('a Menu Set needs a label'); + set.manifest.label = label; + } + if (patch.validUntil !== undefined) { + if (patch.validUntil !== null && !CALENDAR_DATE.test(patch.validUntil)) { + throw new StoreError('validUntil must be a YYYY-MM-DD date'); + } + set.manifest.validUntil = patch.validUntil; + } + await writeManifest(set.dir, set.manifest); + }); + } + + /** + * Makes a Draft Current. The manifest is written first and the directory + * rename is the commit point, so a crash leaves either a Draft or a + * published set, never a published set with no publishedAt. + */ + async publish(setId: string): Promise { + return this.exclusive(async () => { + const set = await this.requireDraft(setId); + if (set.manifest.pages.length === 0) { + throw new StoreError('a Menu Set needs at least one Page before it can be published', 409); + } + const publishedAt = this.now(); + const taken = new Set(await listDirectoryNames(path.join(this.paths.published, set.manifest.section))); + const base = set.manifest.validUntil ?? this.calendarDate(publishedAt); + let slug = base; + for (let suffix = 2; taken.has(slug); suffix += 1) slug = `${base}-${suffix}`; + + set.manifest.publishedAt = publishedAt.toISOString(); + await writeManifest(set.dir, set.manifest); + + const target = path.join(this.paths.published, set.manifest.section, slug); + await mkdir(path.dirname(target), { recursive: true }); + await rename(set.dir, target); + return new StoredSet(set.manifest, 'published', target, slug); + }); + } + + /** Makes an archived set Current again, keeping the URL it already has. */ + async restore(setId: string): Promise { + return this.exclusive(async () => { + const set = await this.get(setId); + if (set.state !== 'published') throw new StoreError('only a published set can be restored'); + set.manifest.publishedAt = this.now().toISOString(); + await writeManifest(set.dir, set.manifest); + return set; + }); + } + + async remove(setId: string, options: { confirmCurrent?: boolean } = {}): Promise { + await this.exclusive(async () => { + const set = await this.get(setId); + if (set.state === 'published') { + const current = (await this.currentBySection()).get(set.manifest.section); + if (current?.manifest.id === set.manifest.id && options.confirmCurrent !== true) { + throw new StoreError( + 'this is the Current Menu Set; publish another set first or confirm the deletion', + 409, + ); + } + } + await rmrf(set.dir); + await rmrf(this.originalsDir(set)); + }); + } + + /** Page id to owning set, for the media route. */ + async pageIndex(): Promise> { + const index = new Map(); + for (const set of await this.list()) { + for (const page of set.manifest.pages) index.set(page.id, { set, page }); + } + return index; + } + + originalsDir(set: StoredSet): string { + return path.join(this.paths.originals, set.manifest.section, set.manifest.id); + } + + manifestPath(set: StoredSet): string { + return path.join(set.dir, MANIFEST_FILE); + } + + private async requireDraft(setId: string): Promise { + const set = await this.get(setId); + if (set.state !== 'draft') { + throw new StoreError('a published Menu Set is immutable; create a new set instead', 409); + } + return set; + } +} + +function newerThan(candidate: StoredSet, existing: StoredSet): boolean { + return (candidate.manifest.publishedAt ?? '') > (existing.manifest.publishedAt ?? ''); +} + +export { ManifestError }; diff --git a/src/storage/validate.ts b/src/storage/validate.ts new file mode 100644 index 0000000..c9edfb9 --- /dev/null +++ b/src/storage/validate.ts @@ -0,0 +1,119 @@ +/** + * Boot validation. Fatal problems stop the app from serving a menu it knows + * is wrong; warnings are logged and the broken Page is skipped at render. + */ + +import { access, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { ManifestError, readManifest, originalFileName, variantFileNames } from './manifest.ts'; +import { StoredSet, sitePaths } from './sets.ts'; + +export interface Problem { + level: 'fatal' | 'warning'; + message: string; +} + +export interface ValidateOptions { + dataDir: string; + sections: readonly string[]; +} + +async function exists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} + +async function listDirectoryNames(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +export async function validateSite(options: ValidateOptions): Promise { + const paths = sitePaths(options.dataDir); + const problems: Problem[] = []; + const sets: StoredSet[] = []; + + for (const state of ['draft', 'published'] as const) { + const base = state === 'draft' ? paths.drafts : paths.published; + for (const sectionDir of await listDirectoryNames(base)) { + for (const key of await listDirectoryNames(path.join(base, sectionDir))) { + const dir = path.join(base, sectionDir, key); + let manifest; + try { + manifest = await readManifest(dir); + } catch (error) { + const detail = error instanceof ManifestError ? error.message : String(error); + problems.push({ level: 'fatal', message: `${dir}: ${detail}` }); + continue; + } + sets.push(new StoredSet(manifest, state, dir, state === 'published' ? key : null)); + } + } + } + + for (const set of sets) { + const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`; + if (!options.sections.includes(set.manifest.section)) { + problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` }); + } + if (set.manifest.section !== path.basename(path.dirname(set.dir))) { + problems.push({ level: 'fatal', message: `${where}: manifest section does not match its directory` }); + } + if (set.state === 'published' && set.manifest.publishedAt === null) { + problems.push({ level: 'fatal', message: `${where}: published set has no publishedAt` }); + } + if (set.state === 'draft' && set.manifest.publishedAt !== null) { + problems.push({ level: 'fatal', message: `${where}: Draft has a publishedAt` }); + } + if (set.manifest.pages.length === 0) { + problems.push({ + level: set.state === 'published' ? 'fatal' : 'warning', + message: `${where}: no Pages`, + }); + } + + const originals = path.join(paths.originals, set.manifest.section, set.manifest.id); + for (const [index, page] of set.manifest.pages.entries()) { + const names = variantFileNames(page); + const missing: string[] = []; + if (!(await exists(path.join(set.dir, names.webp)))) missing.push('viewing webp'); + if (!(await exists(path.join(set.dir, names.jpeg)))) missing.push('viewing jpeg'); + if (!(await exists(path.join(set.dir, names.thumb)))) missing.push('thumbnail'); + if (!(await exists(path.join(originals, originalFileName(page))))) missing.push('original'); + if (missing.length > 0) { + problems.push({ + level: set.state === 'published' ? 'fatal' : 'warning', + message: `${where}: page ${index + 1} (${page.id}) is missing its ${missing.join(', ')}`, + }); + } + } + } + + // Two published sets in one Section may not share a URL segment. + const slugs = new Map>(); + for (const set of sets) { + if (set.state !== 'published' || set.slug === null) continue; + const key = set.manifest.section; + const seen = slugs.get(key) ?? new Set(); + if (seen.has(set.slug)) { + problems.push({ level: 'fatal', message: `two published sets share the URL ${key}/${set.slug}` }); + } + seen.add(set.slug); + slugs.set(key, seen); + } + + return problems; +} + +export function hasFatal(problems: readonly Problem[]): boolean { + return problems.some((problem) => problem.level === 'fatal'); +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..2792dd7 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,101 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { ConfigError, parseSiteConfig, sectionBySlug } from '../src/config.ts'; + +function minimal(overrides: Record = {}): Record { + return { + site: 'menu.hopsngrains.com', + brand: { name: 'Hops n Grains' }, + sections: [ + { slug: 'food', label: 'Food', order: 2 }, + { slug: 'bar', label: 'Bar', order: 1 }, + ], + ...overrides, + }; +} + +describe('parseSiteConfig', () => { + it('applies defaults and sorts Sections by order', () => { + const config = parseSiteConfig(minimal()); + assert.deepEqual( + config.sections.map((section) => section.slug), + ['bar', 'food'], + ); + assert.equal(config.sections[0]?.shortLabel, 'Bar'); + assert.equal(config.sections[0]?.note, null); + assert.equal(config.brand.tagline, null); + assert.equal(config.theme.accent, '#0088aa'); + assert.equal(config.limits.maxPagesPerSet, 40); + assert.equal(config.limits.maxUploadBytes, 30 * 1024 * 1024); + }); + + it('keeps an explicit theme, short label and note', () => { + const config = parseSiteConfig( + minimal({ + theme: { copper: '#b2703b' }, + sections: [{ slug: 'wine', label: 'Wine', shortLabel: 'Wine', note: 'By the glass', order: 1 }], + }), + ); + assert.equal(config.theme.copper, '#b2703b'); + assert.equal(config.theme.steel, '#14181b'); + assert.equal(config.sections[0]?.note, 'By the glass'); + }); + + it('rejects a slug the router claims', () => { + assert.throws( + () => parseSiteConfig(minimal({ sections: [{ slug: 'admin', label: 'Admin', order: 1 }] })), + ConfigError, + ); + }); + + it('rejects duplicate slugs and duplicate orders', () => { + assert.throws( + () => + parseSiteConfig( + minimal({ + sections: [ + { slug: 'bar', label: 'Bar', order: 1 }, + { slug: 'bar', label: 'Bar again', order: 2 }, + ], + }), + ), + ConfigError, + ); + assert.throws( + () => + parseSiteConfig( + minimal({ + sections: [ + { slug: 'bar', label: 'Bar', order: 1 }, + { slug: 'food', label: 'Food', order: 1 }, + ], + }), + ), + ConfigError, + ); + }); + + it('rejects a malformed slug', () => { + assert.throws( + () => parseSiteConfig(minimal({ sections: [{ slug: 'Straight Drinks', label: 'x', order: 1 }] })), + ConfigError, + ); + }); + + it('rejects a colour that is not #rrggbb', () => { + assert.throws(() => parseSiteConfig(minimal({ theme: { steel: 'black' } })), ConfigError); + }); + + it('rejects a bad limit and a missing brand', () => { + assert.throws(() => parseSiteConfig(minimal({ limits: { maxPagesPerSet: 0 } })), ConfigError); + assert.throws(() => parseSiteConfig({ site: 'x', sections: [] }), ConfigError); + }); +}); + +describe('sectionBySlug', () => { + it('finds a configured Section and reports an unknown one as undefined', () => { + const config = parseSiteConfig(minimal()); + assert.equal(sectionBySlug(config, 'bar')?.label, 'Bar'); + assert.equal(sectionBySlug(config, 'wine'), undefined); + }); +}); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts new file mode 100644 index 0000000..686f135 --- /dev/null +++ b/test/pipeline.test.ts @@ -0,0 +1,112 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import sharp from 'sharp'; +import { + ACCEPTED_MIME_TYPES, + UnsupportedImageError, + createImagePipeline, +} from '../src/images/pipeline.ts'; + +const pipeline = createImagePipeline(); + +async function photo(width: number, height: number, format: 'jpeg' | 'png' | 'webp' = 'jpeg') { + const base = sharp({ + create: { width, height, channels: 3, background: { r: 20, g: 120, b: 170 } }, + }); + if (format === 'png') return base.png().toBuffer(); + if (format === 'webp') return base.webp().toBuffer(); + return base.jpeg().toBuffer(); +} + +describe('createImagePipeline', () => { + it('produces a Viewing image, a JPEG fallback and a Thumbnail', async () => { + const derivatives = await pipeline.process(await photo(3000, 2000), 'IMG_0001.JPG'); + + assert.equal(derivatives.sourceExt, 'jpg'); + assert.match(derivatives.hash, /^[0-9a-f]{12}$/); + assert.equal(derivatives.webp.subarray(8, 12).toString(), 'WEBP'); + + const view = await sharp(derivatives.webp).metadata(); + assert.equal(view.width, 1600); + assert.equal(view.height, 1067); + assert.equal(view.format, 'webp'); + assert.equal(derivatives.width, 1600); + assert.equal(derivatives.height, 1067); + + const jpeg = await sharp(derivatives.jpeg).metadata(); + assert.equal(jpeg.width, 1600); + assert.equal(jpeg.format, 'jpeg'); + + const thumb = await sharp(derivatives.thumb).metadata(); + assert.equal(thumb.width, 480); + assert.equal(thumb.format, 'webp'); + + assert.equal(derivatives.original.length > 0, true); + }); + + it('keeps the Original bytes untouched and hashes them', async () => { + const input = await photo(2400, 1600); + const derivatives = await pipeline.process(input, 'a.jpg'); + assert.equal(Buffer.compare(derivatives.original, input), 0); + + const again = await pipeline.process(input, 'a.jpg'); + assert.equal(again.hash, derivatives.hash); + + const different = await pipeline.process(await photo(2400, 1600, 'png'), 'b.png'); + assert.notEqual(different.hash, derivatives.hash); + }); + + it('accepts PNG and WebP as well as JPEG', async () => { + assert.equal((await pipeline.process(await photo(800, 600, 'png'), 'a.png')).sourceExt, 'png'); + assert.equal((await pipeline.process(await photo(800, 600, 'webp'), 'a.webp')).sourceExt, 'webp'); + assert.deepEqual([...ACCEPTED_MIME_TYPES], ['image/jpeg', 'image/png', 'image/webp']); + }); + + it('does not enlarge an image smaller than the Viewing width', async () => { + const derivatives = await pipeline.process(await photo(200, 150), 'small.jpg'); + assert.equal(derivatives.width, 200); + assert.equal(derivatives.height, 150); + const thumb = await sharp(derivatives.thumb).metadata(); + assert.equal(thumb.width, 200); + }); + + it('applies EXIF orientation so the pixels are upright', async () => { + // 1200x600 with orientation 6 (rotate 90 CW) is 600x1200 once upright. + const rotated = await sharp({ + create: { width: 1200, height: 600, channels: 3, background: { r: 200, g: 30, b: 30 } }, + }) + .withMetadata({ orientation: 6 }) + .jpeg() + .toBuffer(); + + const derivatives = await pipeline.process(rotated, 'rotated.jpg'); + assert.equal(derivatives.width, 600); + assert.equal(derivatives.height, 1200); + + const view = await sharp(derivatives.webp).metadata(); + assert.equal(view.width, 600); + assert.equal(view.height, 1200); + assert.equal(view.orientation, undefined); + }); + + it('rejects a file that is not an accepted image, naming the file', async () => { + await assert.rejects( + () => pipeline.process(Buffer.from('not an image at all'), 'notes.pdf'), + (error: unknown) => + error instanceof UnsupportedImageError && error.message.includes('notes.pdf'), + ); + }); + + it('rejects a GIF, which sharp can read but we do not accept', async () => { + const gif = await sharp({ + create: { width: 40, height: 40, channels: 3, background: '#fff' }, + }) + .gif() + .toBuffer(); + await assert.rejects( + () => pipeline.process(gif, 'anim.gif'), + (error: unknown) => + error instanceof UnsupportedImageError && error.message.includes('gif'), + ); + }); +}); diff --git a/test/sets.test.ts b/test/sets.test.ts new file mode 100644 index 0000000..37a04e9 --- /dev/null +++ b/test/sets.test.ts @@ -0,0 +1,276 @@ +import { strict as assert } from 'node:assert'; +import { readdir, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { StoreError, sitePaths } from '../src/storage/sets.ts'; +import { makeStore, pngBytes, tempDir, testClock } from './support/harness.ts'; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await tempDir('sets'); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +async function filesIn(dir: string): Promise { + return (await readdir(dir)).sort(); +} + +describe('creating a Menu Set', () => { + it('writes a Draft manifest under its Section', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + + assert.equal(set.state, 'draft'); + assert.equal(set.manifest.publishedAt, null); + assert.deepEqual(set.manifest.pages, []); + assert.equal( + set.dir, + path.join(sitePaths(dataDir).drafts, 'bar', set.manifest.id), + ); + assert.deepEqual(await filesIn(set.dir), ['manifest.json']); + + const listed = await store.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.manifest.label, 'Bar Menu'); + }); + + it('refuses an unknown Section, an empty label and a bad date', async () => { + const { store } = makeStore(dataDir); + await assert.rejects(() => store.create('wine', { label: 'Wine' }), StoreError); + await assert.rejects(() => store.create('bar', { label: ' ' }), StoreError); + await assert.rejects( + () => store.create('bar', { label: 'Bar', validUntil: '26-06-2026' }), + StoreError, + ); + }); +}); + +describe('adding Pages', () => { + it('stores both variants and the Original, and records the Page', async () => { + const { store, pipeline } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' }); + const page = await store.addPage(set.id, { + data: pngBytes('one'), + originalName: 'IMG_0001.JPG', + }); + + assert.equal(pipeline.calls, 1); + assert.equal(page.sourceExt, 'jpg'); + assert.equal(page.width, 3000); + assert.equal(page.originalName, 'IMG_0001.JPG'); + + const names = (await filesIn(set.dir)).filter((name) => name !== 'manifest.json'); + assert.equal(names.length, 3); + assert.ok(names.some((name) => name.endsWith('.webp') && !name.startsWith('thumb-'))); + assert.ok(names.some((name) => name.endsWith('.jpg'))); + assert.ok(names.some((name) => name.startsWith('thumb-') && name.endsWith('.webp'))); + + const originals = await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id)); + assert.deepEqual(originals, [`${page.id}.${page.hash}.jpg`]); + assert.deepEqual( + await readFile(path.join(sitePaths(dataDir).originals, 'bar', set.id, originals[0]!)), + pngBytes('one'), + ); + + const reread = await store.get(set.id); + assert.equal(reread.manifest.pages.length, 1); + assert.equal(reread.manifest.pages[0]?.id, page.id); + }); + + it('enforces the per-set Page limit', async () => { + const { store } = makeStore(dataDir, { maxPagesPerSet: 2 }); + const set = await store.create('bar', { label: 'Bar Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + await assert.rejects( + () => store.addPage(set.id, { data: pngBytes('three'), originalName: 'c.jpg' }), + (error: unknown) => error instanceof StoreError && error.status === 409, + ); + }); + + it('reorders Pages and refuses an incomplete order', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + const first = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const second = await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + + await store.reorder(set.id, [second.id, first.id]); + assert.deepEqual( + (await store.get(set.id)).manifest.pages.map((page) => page.id), + [second.id, first.id], + ); + + await assert.rejects(() => store.reorder(set.id, [second.id]), StoreError); + await assert.rejects(() => store.reorder(set.id, [second.id, second.id]), StoreError); + await assert.rejects(() => store.reorder(set.id, [second.id, 'nope']), StoreError); + }); + + it('removes a Page and its files', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + + await store.removePage(set.id, page.id); + assert.deepEqual((await store.get(set.id)).manifest.pages, []); + assert.deepEqual(await filesIn(set.dir), ['manifest.json']); + assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id)), []); + }); + + it('leaves no temporary manifest files behind', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const leftovers = (await filesIn(set.dir)).filter((name) => name.endsWith('.tmp')); + assert.deepEqual(leftovers, []); + }); +}); + +describe('publishing', () => { + it('moves the Draft to its date URL and makes it Current', async () => { + const clock = testClock('2026-01-05T04:00:00.000Z'); + const { store } = makeStore(dataDir, { clock }); + const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + + const published = await store.publish(set.id); + assert.equal(published.slug, '2026-06-26'); + assert.equal( + published.dir, + path.join(sitePaths(dataDir).published, 'bar', '2026-06-26'), + ); + assert.equal(published.manifest.publishedAt, '2026-01-05T04:00:00.000Z'); + assert.deepEqual(await filesIn(sitePaths(dataDir).drafts), ['bar']); + + const current = (await store.currentBySection()).get('bar'); + assert.equal(current?.manifest.id, set.id); + }); + + it('falls back to the publication date, in the Site time zone', async () => { + // 20:00 UTC is already the next day in Asia/Kolkata. + const clock = testClock('2026-03-01T20:00:00.000Z'); + const { store } = makeStore(dataDir, { clock }); + const set = await store.create('food', { label: 'Food Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + + const published = await store.publish(set.id); + assert.equal(published.slug, '2026-03-02'); + }); + + it('suffixes a colliding date URL', async () => { + const clock = testClock('2026-01-05T04:00:00.000Z'); + const { store } = makeStore(dataDir, { clock }); + + const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' }); + await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(first.id); + + clock.advance(60_000); + const second = await store.create('bar', { label: 'Bar Menu, revised', validUntil: '2026-06-26' }); + await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + const published = await store.publish(second.id); + + assert.equal(published.slug, '2026-06-26-2'); + const current = (await store.currentBySection()).get('bar'); + assert.equal(current?.manifest.id, second.id); + + const archives = await store.archives('bar'); + assert.deepEqual( + archives.map((set) => set.manifest.id), + [first.id], + ); + }); + + it('refuses to publish an empty set or one with no Pages', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + await assert.rejects( + () => store.publish(set.id), + (error: unknown) => error instanceof StoreError && error.status === 409, + ); + await assert.rejects(() => store.publish('missing'), StoreError); + }); + + it('freezes a published set against editing', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(set.id); + + await assert.rejects(() => store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }), StoreError); + await assert.rejects(() => store.reorder(set.id, [page.id]), StoreError); + await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError); + await assert.rejects(() => store.removePage(set.id, page.id), StoreError); + }); + + it('restores an archived set to Current without changing its URL', async () => { + const clock = testClock('2026-01-05T04:00:00.000Z'); + const { store } = makeStore(dataDir, { clock }); + const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' }); + await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(first.id); + + clock.advance(60_000); + const second = await store.create('bar', { label: 'Bar Menu, revised' }); + await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' }); + await store.publish(second.id); + assert.equal((await store.currentBySection()).get('bar')?.manifest.id, second.id); + + clock.advance(60_000); + const restored = await store.restore(first.id); + assert.equal(restored.slug, '2026-06-26'); + assert.equal((await store.currentBySection()).get('bar')?.manifest.id, first.id); + assert.deepEqual( + (await store.archives('bar')).map((set) => set.manifest.id), + [second.id], + ); + }); +}); + +describe('deleting', () => { + it('refuses the Current set unless the deletion is confirmed, then removes everything', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(set.id); + + await assert.rejects( + () => store.remove(set.id), + (error: unknown) => error instanceof StoreError && error.status === 409, + ); + + await store.remove(set.id, { confirmCurrent: true }); + assert.deepEqual(await store.list(), []); + assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).published, 'bar')), []); + assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar')), []); + }); + + it('deletes a Draft without confirmation', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('food', { label: 'Food Menu' }); + await store.remove(set.id); + assert.deepEqual(await store.list(), []); + }); +}); + +describe('page index', () => { + it('maps every Page to its owning set, draft or published', async () => { + const { store } = makeStore(dataDir); + const draft = await store.create('bar', { label: 'Bar Menu' }); + const published = await store.create('food', { label: 'Food Menu' }); + const draftPage = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const publishedPage = await store.addPage(published.id, { + data: pngBytes('two'), + originalName: 'b.jpg', + }); + await store.publish(published.id); + + const index = await store.pageIndex(); + assert.equal(index.get(draftPage.id)?.set.state, 'draft'); + assert.equal(index.get(publishedPage.id)?.set.state, 'published'); + assert.equal(index.get(publishedPage.id)?.set.slug, '2026-01-01'); + }); +}); diff --git a/test/support/harness.ts b/test/support/harness.ts new file mode 100644 index 0000000..c0da5b8 --- /dev/null +++ b/test/support/harness.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { SetStore, type ImagePipeline } from '../../src/storage/sets.ts'; + +export const SECTIONS = ['bar', 'food'] as const; + +/** Deterministic stand-in for sharp, so storage tests need no image work. */ +export class FakePipeline implements ImagePipeline { + calls = 0; + + async process(data: Buffer, _originalName: string) { + this.calls += 1; + const hash = createHash('sha256').update(data).digest('hex').slice(0, 8); + return { + sourceExt: 'jpg' as const, + hash, + width: 3000, + height: 2000, + original: data, + webp: Buffer.from(`webp:${hash}`), + jpeg: Buffer.from(`jpeg:${hash}`), + thumb: Buffer.from(`thumb:${hash}`), + }; + } +} + +export function testClock(start = '2026-01-01T00:00:00.000Z') { + let time = Date.parse(start); + return { + now: () => new Date(time), + advance: (ms: number) => { + time += ms; + }, + set: (iso: string) => { + time = Date.parse(iso); + }, + }; +} + +export async function tempDir(prefix: string): Promise { + const base = path.join(process.cwd(), '.tmp'); + await mkdir(base, { recursive: true }); + return mkdtemp(path.join(base, `${prefix}-`)); +} + +export function makeStore( + dataDir: string, + options: { clock?: ReturnType; maxPagesPerSet?: number } = {}, +) { + const pipeline = new FakePipeline(); + const clock = options.clock ?? testClock(); + const store = new SetStore({ + dataDir, + pipeline, + sections: [...SECTIONS], + maxPagesPerSet: options.maxPagesPerSet ?? 40, + timeZone: 'Asia/Kolkata', + now: clock.now, + }); + return { store, pipeline, clock }; +} + +export function pngBytes(label: string): Buffer { + return Buffer.from(`fake-image:${label}`); +} diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 0000000..ce5ee15 --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,102 @@ +import { strict as assert } from 'node:assert'; +import { readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { MANIFEST_FILE } from '../src/storage/manifest.ts'; +import { sitePaths } from '../src/storage/sets.ts'; +import { hasFatal, validateSite } from '../src/storage/validate.ts'; +import { SECTIONS, makeStore, pngBytes, tempDir } from './support/harness.ts'; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await tempDir('validate'); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +function validate() { + return validateSite({ dataDir, sections: [...SECTIONS] }); +} + +describe('validateSite', () => { + it('passes an empty Site and a healthy published set', async () => { + const { store } = makeStore(dataDir); + assert.deepEqual(await validate(), []); + + const set = await store.create('bar', { label: 'Bar Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + await store.publish(set.id); + assert.deepEqual(await validate(), []); + }); + + it('warns about a broken Draft but fails on a broken published set', async () => { + const { store } = makeStore(dataDir); + const draft = await store.create('bar', { label: 'Bar Menu' }); + const page = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const names = await readdir(draft.dir); + const thumbnail = names.find((name) => name.startsWith('thumb-'))!; + await rm(path.join(draft.dir, thumbnail)); + + const draftProblems = await validate(); + assert.equal(draftProblems.length, 1); + assert.equal(draftProblems[0]?.level, 'warning'); + assert.equal(hasFatal(draftProblems), false); + + await store.publish(draft.id); + const publishedProblems = await validate(); + assert.equal(publishedProblems[0]?.level, 'fatal'); + assert.equal(hasFatal(publishedProblems), true); + assert.match(publishedProblems[0]?.message ?? '', new RegExp(page.id)); + }); + + it('fails when a manifest disagrees with its own directory', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + const file = path.join(set.dir, MANIFEST_FILE); + const manifest = JSON.parse(await readFile(file, 'utf8')) as Record; + manifest['section'] = 'food'; + await writeFile(file, JSON.stringify(manifest)); + + const problems = await validate(); + assert.equal(hasFatal(problems), true); + assert.ok( + problems.some((problem) => problem.message.includes('does not match its directory')), + ); + }); + + it('fails on a manifest that cannot be parsed', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + await writeFile(path.join(set.dir, MANIFEST_FILE), '{ not json'); + + const problems = await validate(); + assert.equal(hasFatal(problems), true); + assert.ok(problems.some((problem) => problem.message.includes('not valid JSON'))); + }); + + it('fails on a published set with no Pages', async () => { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu' }); + await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' }); + const published = await store.publish(set.id); + + const manifestPath = path.join(published.dir, MANIFEST_FILE); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; + manifest['pages'] = []; + await writeFile(manifestPath, JSON.stringify(manifest)); + + const problems = await validate(); + assert.equal(hasFatal(problems), true); + assert.ok(problems.some((problem) => problem.message.includes('no Pages'))); + }); + + it('reports the data directory layout it inspected', async () => { + const paths = sitePaths(dataDir); + assert.equal(paths.drafts, path.join(dataDir, 'drafts')); + assert.equal(paths.published, path.join(dataDir, 'published')); + assert.equal(paths.originals, path.join(dataDir, 'originals')); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8094e81 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["es2024"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}