diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7756544
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+# Normalise line endings on anything committed from here on. It does not
+# rewrite files already stored with CRLF — `git add --renormalize .` does that,
+# in a commit of its own — but it stops new ones joining them.
+* text=auto eol=lf
+
+# Binary-ish assets git should not touch.
+*.png binary
+*.jpg binary
+*.ico binary
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..8832f71
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,73 @@
+name: Check
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ # `npm ci` in both packages rather than `install:all`: CI should install
+ # exactly what the lockfiles say, not resolve fresh.
+ - name: Install
+ run: npm ci && npm --prefix backend ci
+
+ # No `format:check` step yet: seven files predating the Husky/lint-staged
+ # hook still carry CRLF line endings, so it fails on `main` today. Fixing
+ # that is `git add --renormalize .` plus one commit — worth doing on its
+ # own, not folded into an architecture change.
+ - name: Lint
+ run: npm run lint
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ # Frontend unit tests and backend tests both.
+ - name: Tests
+ run: npm test
+
+ # Catches the failure mode a typecheck cannot: a build that trips over
+ # the shared/ imports crossing the package boundary.
+ - name: Build the application
+ run: npm run build
+
+ - name: Build the documentation
+ run: npm run build:docs
+
+ # The documentation site has no Worker behind it, so shipping /app,
+ # /auth or /i there would publish a copy of the product that looks real
+ # and fails at sign-in. astro.config.mjs strips them; this checks it.
+ - name: Documentation build must contain no application
+ run: |
+ failed=0
+ for route in app auth i; do
+ if [ -e "dist/$route" ]; then
+ echo "::error::dist/$route is in the documentation build — it needs the Worker, and GitHub Pages has none"
+ failed=1
+ fi
+ done
+ exit $failed
+
+ # These pages exist on both hosts. Without a canonical they are duplicate
+ # content on two domains, and a missing one fails silently — nothing in
+ # the build or the browser complains.
+ - name: Documentation pages must point their canonical at the app
+ run: |
+ failed=0
+ while IFS= read -r page; do
+ if ! grep -q '> "$GITHUB_OUTPUT"
+ else
+ echo "ready=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Cloudflare secrets are not set — skipping deploy."
+ fi
+
+ worker:
+ needs: configured
+ if: needs.configured.outputs.ready == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - run: npm --prefix backend ci
+
+ # Migrations before the deploy: a Worker that is live against a schema it
+ # expects and does not have is a broken sign-in, and D1 migrations here
+ # only ever add.
+ - name: Apply D1 migrations
+ run: npm --prefix backend run db:migrate:remote
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+
+ - name: Deploy Worker
+ run: npm --prefix backend run deploy:production
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+
+ pages:
+ needs: [configured, worker]
+ if: needs.configured.outputs.ready == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - run: npm ci
+
+ # No BUILD_TARGET and no BASE_PATH: this is the application build, served
+ # at the root, and it is the default in astro.config.mjs. It carries
+ # everything — the free tier and the paid one both run from here.
+ - run: npm run build
+
+ - name: Deploy Pages
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ # --functions-directory is explicit: the proxy in functions/ lives at
+ # the repo root, not inside dist/, and losing it would deploy a
+ # frontend that quietly falls back to the free tier.
+ command: >-
+ pages deploy dist
+ --project-name=bottlecount
+ --branch=main
+ --functions-directory=functions
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 365c6cd..e870e88 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,4 +1,21 @@
-name: Deploy to GitHub Pages
+# GitHub Pages — the documentation site.
+#
+# This is *not* the application. The app lives on Cloudflare Pages, where the
+# Worker is (see deploy-cloudflare.yml), and both tiers are served from there:
+# free users get it without an account, paying users sign in.
+#
+# What this deploys is the landing page, the docs, pricing and the legal pages,
+# under the repository's path prefix. `npm run build:docs` deletes /app, /auth
+# and /i from the output, because every one of them needs the Worker and there
+# is none behind this host — a half-working copy of the product is worse than no
+# copy. Those pages also exist on the application host, so each one carries a
+# canonical link pointing there.
+#
+# The build settings live in that npm script rather than here, so a local
+# `npm run build:docs` produces exactly what this publishes. That is also why
+# these are explicit steps: `withastro/action` runs `npm run build` and cannot
+# be pointed at another script.
+name: Deploy docs to GitHub Pages
on:
push:
@@ -19,10 +36,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: withastro/action@v3
+ - uses: actions/setup-node@v4
with:
- package-manager: 'npm'
- node-version: 22
+ node-version-file: .nvmrc
+ cache: npm
+
+ - run: npm ci
+ - run: npm run build:docs
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: dist
deploy:
needs: build
diff --git a/.gitignore b/.gitignore
index a3bcb05..782bb90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,12 @@ pnpm-debug.log*
# environment variables
.env
.env.production
+# Wrangler secrets for local dev — never commit these.
+.dev.vars
+backend/.dev.vars
+
+# wrangler local state (its D1 sqlite file lives here)
+.wrangler/
# macOS-specific files
.DS_Store
diff --git a/.prettierignore b/.prettierignore
index c53dea0..1baf21f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,6 @@
-node_modules
-dist
-.astro
-coverage
-
+node_modules
+dist
+.astro
+coverage
+.wrangler
+backend/node_modules
diff --git a/README.md b/README.md
index 8e27d32..6496631 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,53 @@
# 🍾 BottleCount
-Plan your party like an engineer. Configure drinks, cocktail recipes, and headcount — get a precise shopping list, cost range, break-even point, and optional QR-based ticket workflow in a fully static web app.
+Plan your party like an engineer. Configure drinks, cocktail recipes, and headcount — get a precise shopping list, cost range, break-even point, and a QR-based ticket workflow.
+
+Run it free in your browser with no account, pay once for the hosted version, or deploy it to your own Cloudflare account for nothing. See [Pricing](#how-to-run-it).
[](https://www.typescriptlang.org/)
[](https://astro.build/)
[](https://vuejs.org/)
+[](https://hono.dev/)
[](https://dexie.org/)
-[](https://pages.github.com/)
+[](https://developers.cloudflare.com/pages/)
[](LICENSE)
-**Live:** https://fre0grella.github.io/BottleCount
+**App:** https://bottlecount.pages.dev — the product, free and paid tiers alike
+**Docs:** https://fre0grella.github.io/BottleCount — documentation only, no app
---
## What It Does
-BottleCount helps you plan ticketed parties without spreadsheets, guesswork, or backend infrastructure. You define **who's coming**, **what they're drinking**, **how strong the event should be**, and **what things cost** — the app calculates the shopping list and the economics from your menu structure.
+BottleCount helps you plan ticketed parties without spreadsheets or guesswork. You define **who's coming**, **what they're drinking**, **how strong the event should be**, and **what things cost** — the app calculates the shopping list and the economics from your menu structure.
+
+The planning side needs no server and never will: presets ship with the app, your customizations live in the browser, and it works offline. What a server buys you is the part that is inherently shared — a link your guests can open, a party two people can run, and your data on more than one device.
+
+---
-The current version is designed as a **static, offline-first tool**: presets ship with the app, your customizations live in the browser, and optional ticket validation can sync through your own Google Sheet setup when you need multiple scanners.
+## How to run it
+
+| | Browser | Hosted | Self-hosted |
+| -------------------------------- | ----------------- | -------------- | -------------------------- |
+| **Price** | free, forever | one payment | free |
+| **Account** | none | Google sign-in | Google, or a local sign-in |
+| **Your data** | this browser only | your account | your Cloudflare account |
+| Menu, shopping list, budget | ✅ | ✅ | ✅ |
+| Custom ingredients and cocktails | ✅ | ✅ | ✅ |
+| Guest list you type yourself | ✅ | ✅ | ✅ |
+| Signed QR tickets, one scanner | ✅ | ✅ | ✅ |
+| Shareable invite link | — | ✅ | ✅ |
+| RSVP funnel and spread view | — | ✅ | ✅ |
+| Co-organisers on one party | — | ✅ | ✅ |
+| Sync across devices | — | ✅ | ✅ |
+| Several phones on the door | — | ✅ | ✅ |
+
+The free tier is not a trial: no expiry, no account, no card. If planning a party
+in one browser is all you need, that is the finished product.
+
+Self-hosting is free because hosting is the thing being sold, not the software.
+Run the Worker yourself and there is nothing left to charge for — see
+[Self-hosting](#self-hosting).
---
@@ -55,12 +85,17 @@ Alcohol intensity presets still map to pure alcohol targets per person: 🌿 Sof
### 🎟️ Tickets & Validation
- Generate signed QR tickets in the browser.
-- Validate tickets locally with HMAC verification and expiry checks.
-- Optional multi-scanner validation through a **user-owned Google Sheet + Apps Script** setup.
+- Validate tickets locally with HMAC verification and expiry checks. The signing
+ key belongs to the party, so any organiser's phone can check any ticket — and
+ it keeps working with no signal once fetched.
+- Every ticket carries a five-character code as well. When a QR won't scan, the
+ door types the code and the guest's name; both have to match.
+- Several phones on one door share check-in state, so nobody is admitted twice.
+ The server arbitrates, and a mistaken check-in can be undone.
-### 📱 Offline-First UX
+### 📱 Offline-First Planning
-- Static site deployable on GitHub Pages, with no owned backend.
+- The planner runs entirely client-side and keeps working with no network.
- Browser storage keeps your custom catalog, settings, and tickets on-device.
- Export/import backup flow is recommended for portability and recovery.
@@ -68,19 +103,55 @@ Alcohol intensity presets still map to pure alcohol targets per person: 🌿 Sof
## Tech Stack
-BottleCount is a **100% static site** with a TypeScript-first frontend architecture.
+TypeScript everywhere, and a frontend that still runs with the backend switched off.
+
+| Layer | Technology |
+| -------------- | ---------------------------------------------- |
+| Language | TypeScript (strict) |
+| Frontend | Astro + Vue 3, built with Vite |
+| Client storage | Dexie.js on IndexedDB |
+| Crypto | Web Crypto API (HMAC-SHA256) |
+| QR generation | `qrcode` |
+| QR scanning | `nimiq/qr-scanner` |
+| API | Hono on Cloudflare Workers |
+| Database | Cloudflare D1 |
+| Auth | Google OAuth → HS256 JWT in an httpOnly cookie |
+| Frontend host | Cloudflare Pages (docs on GitHub Pages) |
+
+### How the pieces fit
+
+```
+browser ──▶ Cloudflare Pages ──┬──▶ static Astro build
+ │
+ └──▶ Pages Function (functions/)
+ │ service binding, same origin
+ ▼
+ Hono Worker ──▶ D1
+```
+
+`/api/*` and `/auth/*` are forwarded to the Worker over a **service binding**,
+not a public URL. That is an internal dispatch, so the browser only ever talks
+to one origin: the session cookie is first-party and no CORS preflight sits
+between a user and signing in.
+
+Both tiers are served from here. The free tier is not a different deployment —
+it is the same app with nobody signed in, which is why `GET /api/session`
+answers anonymous callers instead of rejecting them.
+
+If the Worker is unreachable — it is down, or a self-hoster has not wired the
+service binding up yet — the app degrades cleanly to the free tier instead of
+failing. That is deliberate, and [`src/lib/session.ts`](src/lib/session.ts) is
+where it is enforced.
-| Layer | Technology |
-| -------------- | ------------------------------------- |
-| Language | TypeScript (strict) |
-| Framework | Astro + Vue 3 |
-| Build | Vite via Astro |
-| Client storage | Dexie.js on IndexedDB |
-| Crypto | Web Crypto API (HMAC-SHA256) |
-| QR generation | `qrcode` |
-| QR scanning | `nimiq/qr-scanner` |
-| Optional sync | User-owned Google Sheet + Apps Script |
-| Deploy | GitHub Pages via `withastro/action` |
+The documentation site on GitHub Pages is a separate build that contains no
+application at all; see [Deploying](#deploying).
+
+### One table decides what is locked
+
+[`shared/tiers.ts`](shared/tiers.ts) is imported by both the Worker and the
+frontend. A capability the UI hides but the API still serves is a paywall that
+leaks; one the API refuses but the UI offers is a bug report. Both sides reading
+the same table is the only version of this that stays honest.
---
@@ -121,43 +192,160 @@ N\_{\text{be}} = \left\lceil \frac{\text{fixed costs}}{\text{ticket price} - \te
```bash
git clone https://github.com/fre0grella/BottleCount
cd BottleCount
-npm install
+npm run install:all
+```
+
+**Frontend only** — the free tier, and all you need for anything on the planning
+side:
+
+```bash
npm run dev
```
-Open the local Astro dev server shown in the terminal.
+**With the backend**, in a second terminal:
+
+```bash
+cp backend/.dev.vars.example backend/.dev.vars # set JWT_SECRET to anything
+npm --prefix backend run db:init:local # apply migrations to local D1
+npm run backend:dev # wrangler dev --env local
+```
+
+The `local` Worker environment sets `SELF_HOSTED=true`, so you can sign in
+without registering a Google OAuth client:
+
+```bash
+curl -X POST http://localhost:8787/auth/dev \
+ -H 'content-type: application/json' \
+ -d '{"email":"you@example.com"}' -c cookies.txt
+```
+
+Checks, all of which CI runs:
+
+```bash
+npm run lint
+npm run typecheck # astro check + backend tsc
+npm test # backend route and tier tests
+npm run build
+```
+
+---
+
+## Deploying
+
+### Cloudflare (the app)
+
+One-time setup:
+
+```bash
+npx wrangler d1 create db # paste the id into backend/wrangler.jsonc
+cd backend
+npx wrangler secret put JWT_SECRET --env production
+npx wrangler secret put GOOGLE_CLIENT_SECRET --env production
+npm run db:migrate:remote
+npm run deploy:production # the Worker must exist before Pages
+```
+
+Then create a Pages project named `bottlecount` pointing at this repository, and
+add the service binding in `wrangler.toml` (`BACKEND` → `bottlecount-backend`).
+Set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` as repository secrets and
+`.github/workflows/deploy-cloudflare.yml` takes over from there. Without those
+secrets the workflow skips rather than failing, so a fork stays green.
+
+Your Google OAuth client's authorised redirect URI is `/auth/google`
+— the **frontend** origin, because the Pages Function proxies it back to the
+Worker.
+
+### GitHub Pages (the documentation)
+
+Push to `main` and `.github/workflows/deploy.yml` runs `npm run build:docs`,
+which publishes the landing page, docs, pricing and legal pages under
+`/BottleCount/` — and **leaves the application out**. `/app`, `/auth` and `/i`
+are deleted from that build, because each needs the Worker and there is none
+behind GitHub Pages; a copy of the product that looks real and fails at sign-in
+is worse than no copy.
+
+Those five pages are built for both hosts, so each carries a
+`` pointing at the Cloudflare copy — that domain is the
+product, and it serves these pages as well as the app. Links to the app from
+the docs point there too. Both come from one setting, `PUBLIC_APP_ORIGIN`
+([`src/lib/links.ts`](src/lib/links.ts)); a separate canonical origin and app
+URL would be two settings obliged to name the same host.
+
+The build settings live in the npm script, not the workflow, so this produces
+exactly what CI publishes:
+
+```bash
+npm run build:docs
+```
---
-## Deploy to GitHub Pages
+## Self-hosting
-1. Push to `main`.
-2. In GitHub repo settings, set Pages source to **GitHub Actions**.
-3. The workflow in `.github/workflows/deploy.yml` deploys the site automatically through `withastro/action`.
+Every paid feature is on, and it costs nothing beyond a Cloudflare account —
+the free plan is more than enough for a party.
+
+```bash
+git clone https://github.com/fre0grella/BottleCount
+cd BottleCount && npm run install:all
+
+npx wrangler d1 create db # paste the id into the `selfhosted` env
+cd backend
+# in wrangler.jsonc, set env.selfhosted.vars.FRONTEND_URL to your Pages domain
+npx wrangler secret put JWT_SECRET --env selfhosted
+npm run db:migrate:remote
+npx wrangler deploy --env selfhosted
+```
+
+Then deploy the frontend to Pages (`npm run build && npx wrangler pages deploy dist`)
+and bind `BACKEND` to your Worker.
+
+`SELF_HOSTED=true` promotes every **signed-in** user to the full feature set. It
+does not promote anonymous visitors: co-organisers and the invite funnel need to
+know who is who even when the server is yours. `POST /auth/dev` lets you sign in
+without a Google client if you would rather not register one.
---
-## Google Sheet Setup
+## Issuing licences
-For multi-device ticket validation at the door, each organizer can connect their own Google Sheet rather than relying on a shared backend.
+Until a checkout provider is wired up, fulfilment on the hosted tier is manual:
-1. Create a Google Sheet with columns `ticketId`, `used`, and `usedAt`.
-2. Open **Extensions → Apps Script** and paste `apps-script/validate.gs`.
-3. Add a script property named `TOKEN`, then deploy the script as a web app.
-4. Paste the Apps Script URL and token into the app's scanner configuration panel.
+```bash
+npm --prefix backend run licence:issue -- --env production --note "ko-fi #128"
+```
-This keeps the static-site architecture intact while allowing atomic ticket validation across multiple scanners.
+It prints a code like `BC-7K2M-QP4X-9DNR` and inserts it into D1. The buyer
+redeems it in the app, which flips their tier to `pro`.
---
## Data, Persistence & Privacy
-BottleCount stores user data in the browser's IndexedDB through Dexie. That means your custom ingredients, cocktails, settings, generated tickets, and local app state stay on your device unless you explicitly use the optional Google Sheet flow.
+BottleCount stores user data in the browser's IndexedDB through Dexie. Your custom ingredients, cocktails, settings, generated tickets and local app state stay on your device.
+
+On the free tier that is the whole story — there is no account and nothing is uploaded, because there is nowhere to upload it to.
+
+Signing in adds an account record (id, email, name, avatar URL, tier) in D1.
+
+Nothing else is uploaded until you **share a party** — with a co-organiser, or with guests through an invite link. Doing either stores that party's planning document (name, date, venue, menu, settings, check-offs) so the people you shared it with can open it, plus a row per guest who RSVPs. Parties you have not shared stay in your browser and nowhere else, on every tier ([ADR 0001](docs/adr/0001-cloudflare-tiers.md), [ADR 0002](docs/adr/0002-invite-links-and-the-funnel.md), [ADR 0003](docs/adr/0003-co-organisers.md)).
Because browser storage is still local storage, export/import backup tools are an important part of the workflow for portability and recovery.
---
+## Architecture decisions
+
+- [ADR 0001 — Cloudflare, and three ways to run BottleCount](docs/adr/0001-cloudflare-tiers.md)
+- [ADR 0002 — Invite links, and what the funnel counts](docs/adr/0002-invite-links-and-the-funnel.md)
+- [ADR 0003 — Co-organisers, and how two people edit one party](docs/adr/0003-co-organisers.md)
+- [ADR 0004 — Shared doors, and a code you can read out loud](docs/adr/0004-shared-doors-and-ticket-codes.md)
+
+The backend has [its own README](backend/README.md) covering routes, local
+setup, deployment and what the tests do and do not cover.
+
+---
+
## 📄 License
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE) —
diff --git a/astro.config.mjs b/astro.config.mjs
index 89bd61b..1030dbc 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,9 +1,72 @@
+import { rm } from 'node:fs/promises';
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
+/**
+ * One source, two deployments, and they are not the same site.
+ *
+ * - **Cloudflare Pages** serves *the application*, at `/`, with the Worker
+ * behind it. Both tiers live here: free users get it without an account,
+ * paying users sign in. This is the default, because it is the product.
+ * - **GitHub Pages** serves *the documentation*, under `/BottleCount/`. It is a
+ * marketing and reference site with no backend, so the application is left
+ * out of that build entirely — see `docsOnly` below.
+ *
+ * Neither root can be hard-coded without breaking the other, so both come from
+ * the environment.
+ */
+const target = process.env.BUILD_TARGET ?? 'app';
+const isDocs = target === 'docs';
+
+const site =
+ process.env.SITE ??
+ (isDocs ? 'https://fre0grella.github.io' : 'https://bottlecount.pages.dev');
+const base = process.env.BASE_PATH ?? (isDocs ? '/BottleCount/' : '/');
+
+/**
+ * Routes that are the application rather than documentation.
+ *
+ * Each one needs the Worker: `/app` for sign-in and every paid feature,
+ * `/auth/callback` for the end of the OAuth round trip, `/i` for guest invite
+ * links, `/join` for co-organiser invitations.
+ * Publishing them to a host with no backend produces a site that looks like the
+ * product and then fails halfway through it, which is worse than not being
+ * there at all. Links to the app in the documentation point at the real one
+ * instead, and those pages carry a canonical link to it — both from
+ * `PUBLIC_APP_ORIGIN` (see `src/lib/links.ts`).
+ */
+const APP_ROUTES = ['app', 'auth', 'i', 'join'];
+
+/**
+ * Leaves the application out of a documentation build.
+ *
+ * This deletes from the output rather than filtering routes beforehand, because
+ * `astro:routes:resolved` is informational — splicing its array is accepted and
+ * then ignored, and the pages are emitted anyway. Doing it here at least keeps
+ * the rule in the config, beside the list it applies, rather than in a step in
+ * a workflow file that a local `BUILD_TARGET=docs` build would not run.
+ */
+function docsOnly() {
+ return {
+ name: 'bottlecount:docs-only',
+ hooks: {
+ 'astro:build:done': async ({ dir, logger }) => {
+ await Promise.all(
+ APP_ROUTES.map((route) =>
+ rm(new URL(`./${route}/`, dir), { recursive: true, force: true }),
+ ),
+ );
+ logger.info(
+ `documentation build — application left out: ${APP_ROUTES.map((r) => `/${r}`).join(', ')}`,
+ );
+ },
+ },
+ };
+}
+
export default defineConfig({
- site: 'https://fre0grella.github.io',
- base: '/BottleCount/',
- integrations: [vue()],
+ site,
+ base,
+ integrations: [vue(), ...(isDocs ? [docsOnly()] : [])],
output: 'static',
});
diff --git a/backend/.dev.vars.example b/backend/.dev.vars.example
new file mode 100644
index 0000000..97a724d
--- /dev/null
+++ b/backend/.dev.vars.example
@@ -0,0 +1,11 @@
+# Copy to backend/.dev.vars for `wrangler dev --env local`.
+# Secrets only — anything non-secret belongs in wrangler.jsonc `vars`.
+
+# Signs the session cookie. Any long random string locally; in production set it
+# with `wrangler secret put JWT_SECRET --env production`.
+JWT_SECRET="dev-only-change-me"
+
+# Optional locally: without them /auth/google returns 500 and you sign in with
+# POST /auth/dev instead.
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..d833692
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.wrangler/
+.dev.vars
+dist/
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..ebaa03c
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,151 @@
+# BottleCount backend
+
+A Hono Worker on Cloudflare, backed by D1. It exists to serve the four things a
+static bundle cannot: who you are, what you have paid for, the party data two
+organisers share, and the one check-in list every phone on the door agrees on.
+
+See [ADR 0001](../docs/adr/0001-cloudflare-tiers.md) for why any of this exists.
+
+## What it serves
+
+| Route | Auth | Purpose |
+| ---------------------------------------------------- | ------------------ | ------------------------------------------------------------------- |
+| `GET /` | — | Liveness, and which environment answered |
+| `GET /auth/google` | — | Google OAuth; sets the `session_token` cookie |
+| `POST /auth/logout` | — | Clears it (the cookie is httpOnly, so the page cannot) |
+| `POST /auth/dev` | — | Sign in without Google. **404 unless local or self-hosted** |
+| `GET /api/session` | optional | Who the caller is and what they may do |
+| `POST /api/licences/redeem` | session | Turns a licence code into `pro` |
+| `GET /api/parties` | session | Parties the caller can open, owned or shared |
+| `POST /api/parties` | session + `pro` | Store a party, or save it again |
+| `GET /api/parties/:id` | member | The full document, members and role |
+| `PATCH /api/parties/:id` | member | One organiser's edit, as a merge patch |
+| `DELETE /api/parties/:id` | owner | Delete it for everyone |
+| `POST /api/parties/:id/invite-link` | member | Open the party to RSVPs |
+| `DELETE /api/parties/:id/invite-link` | owner | Close it |
+| `GET /api/parties/:id/invites` | member | The RSVP funnel |
+| `POST /api/parties/:id/invites` | member | Add a guest by hand (`source: manual`) |
+| `PATCH /api/parties/:id/invites/:inviteId` | member | Override a guest's answer |
+| `POST /api/parties/:id/invites/:inviteId/check-in` | member | They walked in. 409 if already scanned |
+| `DELETE /api/parties/:id/invites/:inviteId/check-in` | member | Undo a check-in |
+| `GET /api/parties/:id/members` | member | Who is on the party |
+| `POST /api/parties/:id/members/invite` | owner | Mint a co-organiser link |
+| `DELETE /api/parties/:id/members/invites` | owner | Revoke outstanding links |
+| `DELETE /api/parties/:id/members/:userId` | owner, or yourself | Remove, or leave |
+| `GET /api/collaborate/:token` | session | What am I being asked to join? |
+| `POST /api/collaborate/:token` | session | Join as an editor |
+| `POST /invite/:slug/open` | — | A guest opened the link. **Writes** — this is what "reached" counts |
+| `POST /invite/:slug/answer` | — | A guest's yes or no |
+
+`/api/session` is the one `/api/*` route served without a session, because the
+free tier _is_ a logged-out browser. The exemption is named explicitly in
+`app.ts` rather than left to mount order.
+
+"member" above means a member of that party — and membership _is_ the
+authorisation. A caller who is not one gets **404, not 403**, so a party id
+cannot be probed for existence. The tier check gates only `POST /api/parties`:
+opening and editing a party you were invited to is deliberately free, because it
+belongs to someone who has already paid
+([ADR 0003](../docs/adr/0003-co-organisers.md)).
+
+`/invite/*` is mounted **outside** `/api/*` entirely. Guests have no account —
+being able to RSVP without signing up is most of what an invite link is for — so
+the URL is the only credential those handlers have, and they are written knowing
+it: they return nothing a link holder should not see. They are also the only
+routes that write without an account behind them, so they sit behind a rate
+limit binding keyed on IP
+([ADR 0002](../docs/adr/0002-invite-links-and-the-funnel.md)).
+
+## Running it locally
+
+```bash
+npm install
+cp .dev.vars.example .dev.vars # then set JWT_SECRET to anything
+npm run db:init:local # applies migrations to the local D1
+npm run dev # wrangler dev --env local
+```
+
+The `local` environment sets `SELF_HOSTED=true`, so you can sign in without a
+Google OAuth client:
+
+```bash
+curl -X POST http://localhost:8787/auth/dev \
+ -H 'content-type: application/json' \
+ -d '{"email":"you@example.com","name":"You"}' -c cookies.txt
+
+curl http://localhost:8787/api/session -b cookies.txt
+```
+
+Run the Astro dev server (`npm run dev` at the repo root) beside it. In
+production the Pages Functions proxy puts both on one origin; in development
+they are two ports, which is the only reason the CORS middleware is there.
+
+## Deploying
+
+First time, per environment:
+
+```bash
+npx wrangler d1 create db # paste the id into wrangler.jsonc
+npx wrangler secret put JWT_SECRET --env production
+npx wrangler secret put GOOGLE_CLIENT_SECRET --env production
+npm run db:migrate:remote
+npm run deploy:production
+```
+
+`GOOGLE_CLIENT_ID` and `FRONTEND_URL` are not secrets and live in
+`wrangler.jsonc`. The Google OAuth client's authorised redirect URI must be
+`/auth/google` — the frontend origin, not the Worker's, because
+the Pages Function proxies it back here.
+
+Afterwards, pushes to `main` deploy through
+`.github/workflows/deploy-cloudflare.yml`.
+
+## Issuing licences
+
+Until a checkout provider is wired up, fulfilment is manual:
+
+```bash
+npm run licence:issue -- --env production --note "ko-fi #128"
+```
+
+It prints a code such as `BC-7K2M-QP4X-9DNR` and inserts it. The buyer redeems
+it in the app. `--print` generates a code and the SQL without touching the
+database.
+
+## Structure
+
+```
+src/
+ app.ts every route, in one place
+ composition.ts which storage target a request uses
+ routes/ HTTP only — no SQL, no business rules
+ repositories/ interfaces, and the D1 implementations behind them
+ tests/ plain Vitest, fake repositories
+migrations/ append-only D1 schema
+```
+
+The repository interfaces are not ceremony: they are the seam a self-hoster who
+would rather run Postgres plugs into, and the reason the route tests can say
+what they are about — a tier, a cookie, a guard — without standing up a
+database.
+
+## What the tests do and do not cover
+
+They run on plain Vitest against in-memory repositories, so they cover routing,
+the `/api/*` guard, tier resolution, the redemption rules, and the whole invite
+flow — depth down a referral chain, capacity refusing a confirmation but never a
+decline, one row per returning browser, owner isolation.
+
+They cannot catch a mistake in a SQL statement, and two of those rules are
+defended _by_ the SQL: the capacity check and the write are one statement, so two
+guests racing for the last place cannot both take it, and `json_patch` merges a
+co-organiser's edit inside the same statement that reads and writes the
+document, so two saves landing at once cannot both read the same version. The
+fakes do the check and the write separately: they reproduce the rule, not the
+atomicity. Both were verified by hand against a local D1.
+
+Covering that needs `@cloudflare/vitest-pool-workers`, which at the time of
+writing peers on Vitest 4 while this project is on 5. When that clears, the
+switch is a config change plus a `tests/support/` swap — nothing in the tests
+reaches for a binding directly. Until then, exercise the SQL with
+`npm run db:init:local` and the dev server.
diff --git a/backend/migrations/0001_users_and_licences.sql b/backend/migrations/0001_users_and_licences.sql
new file mode 100644
index 0000000..f5b2d39
--- /dev/null
+++ b/backend/migrations/0001_users_and_licences.sql
@@ -0,0 +1,52 @@
+-- Accounts and entitlements.
+--
+-- Party data is deliberately absent. The cloud tier will own parties, guests
+-- and tickets (ADR 0001), but until the frontend actually reads them from here
+-- their columns would be a guess, and D1 migrations are append-only — a shape
+-- invented ahead of its first consumer is a shape you migrate away from. This
+-- file covers only what the session endpoint and the licence gate need today.
+
+-- A person. `id` is ours, not the identity provider's, so a user can later gain
+-- a second sign-in method without their parties changing owner.
+CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ name TEXT,
+ picture TEXT,
+ -- 'free' | 'pro'. Mirrors shared/tiers.ts; CHECK keeps a typo in a manual
+ -- `wrangler d1 execute` from silently creating a third tier nothing honours.
+ tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+-- One row per (provider, provider account) pair pointing at a user.
+CREATE TABLE identities (
+ provider TEXT NOT NULL,
+ provider_user_id TEXT NOT NULL,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (provider, provider_user_id)
+);
+
+CREATE INDEX idx_identities_user ON identities(user_id);
+
+-- A one-time purchase, redeemable once.
+--
+-- The checkout provider is not chosen yet, so nothing writes these rows
+-- automatically — `npm run licence:issue` (or a `wrangler d1 execute`) does.
+-- When a provider is picked, its webhook inserts here and the rest of the
+-- system is unchanged: redemption already flips the user's tier.
+CREATE TABLE licence_keys (
+ code TEXT PRIMARY KEY,
+ tier TEXT NOT NULL DEFAULT 'pro' CHECK (tier IN ('free', 'pro')),
+ issued_at TEXT NOT NULL,
+ -- NULL until someone redeems it. "Redeemable once" is enforced by the
+ -- conditional UPDATE in licenceRepositoryD1 (`WHERE redeemed_at IS NULL`,
+ -- checked against `meta.changes`), never by a read-then-write in the service.
+ redeemed_at TEXT,
+ redeemed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
+ note TEXT
+);
+
+CREATE INDEX idx_licence_keys_redeemed_by ON licence_keys(redeemed_by);
diff --git a/backend/migrations/0002_parties_and_invites.sql b/backend/migrations/0002_parties_and_invites.sql
new file mode 100644
index 0000000..9ad6436
--- /dev/null
+++ b/backend/migrations/0002_parties_and_invites.sql
@@ -0,0 +1,66 @@
+-- Published parties and the invite funnel.
+--
+-- This is the first party data the server holds, and it is deliberately only
+-- the part an invite link needs: what an invitation card shows, plus who
+-- followed it. The host's menu, shopping list, costs and locks stay in their
+-- browser — nobody opening a link needs them, and not storing them keeps the
+-- blast radius of a leaked slug down to "someone learns about a party".
+
+-- A party the host has chosen to publish. Unpublishing deletes the row, which
+-- cascades to its invites: turning the link off means the link stops working.
+CREATE TABLE parties (
+ id TEXT PRIMARY KEY,
+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ -- The party's id in the owner's IndexedDB. Unique per owner so republishing
+ -- updates in place instead of littering the table with copies, and so a host
+ -- who clears their browser cannot collide with another host's numbering.
+ local_id INTEGER NOT NULL,
+ -- What appears in the URL. Unguessable on its own: a readable slug plus
+ -- random suffix, because the guest-facing page has no other access control.
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ date TEXT NOT NULL,
+ cover INTEGER NOT NULL DEFAULT 0,
+ venue_place TEXT NOT NULL DEFAULT '',
+ venue_city TEXT NOT NULL DEFAULT '',
+ venue_time TEXT NOT NULL DEFAULT '',
+ allow_forward INTEGER NOT NULL DEFAULT 1,
+ -- NULL when the host set no cap. When set, confirmations stop at it.
+ max_capacity INTEGER,
+ -- The host's own link. Guests who use it are depth 0.
+ root_token TEXT NOT NULL,
+ published_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX idx_parties_slug ON parties(slug);
+CREATE UNIQUE INDEX idx_parties_owner_local ON parties(owner_id, local_id);
+CREATE UNIQUE INDEX idx_parties_root_token ON parties(root_token);
+
+-- One row per person who opened the link, created on open rather than on
+-- answer. That is the whole point of the funnel: "reached" has to count people
+-- who never replied, and a row that only appears on answer cannot.
+CREATE TABLE invites (
+ id TEXT PRIMARY KEY,
+ party_id TEXT NOT NULL REFERENCES parties(id) ON DELETE CASCADE,
+ -- NULL until they answer — opening a link tells us nothing about who they are.
+ name TEXT,
+ status TEXT NOT NULL DEFAULT 'opened'
+ CHECK (status IN ('opened', 'confirmed', 'declined')),
+ -- 0 via the host's link, +1 per forward. The UI's "Direct invites" tier is
+ -- depth 0 and "Friends-of-friends" is everything above it.
+ depth INTEGER NOT NULL DEFAULT 0,
+ referrer_id TEXT REFERENCES invites(id) ON DELETE SET NULL,
+ -- This guest's own forward link, minted on open so a confirmation can hand it
+ -- straight back without a second write.
+ forward_token TEXT NOT NULL,
+ checked_in INTEGER NOT NULL DEFAULT 0,
+ checked_in_at TEXT,
+ opened_at TEXT NOT NULL,
+ answered_at TEXT
+);
+
+CREATE UNIQUE INDEX idx_invites_forward_token ON invites(forward_token);
+CREATE INDEX idx_invites_party ON invites(party_id);
+-- The confirmed-count query behind the capacity check runs on every open.
+CREATE INDEX idx_invites_party_status ON invites(party_id, status);
diff --git a/backend/migrations/0003_collaboration.sql b/backend/migrations/0003_collaboration.sql
new file mode 100644
index 0000000..85294fc
--- /dev/null
+++ b/backend/migrations/0003_collaboration.sql
@@ -0,0 +1,70 @@
+-- Co-organisers: the party itself moves to the server.
+--
+-- ADR 0002 published the invitation card — enough for a guest to RSVP, useless
+-- to a second organiser, who needs the menu, the numbers and the shopping list.
+-- This adds the rest of the party, and the membership that says who may open it.
+
+-- The planning half of a party, as one JSON document.
+--
+-- A document rather than a table per level (categories, spirits, cocktails)
+-- because that is what it already is everywhere else: the client keeps it in
+-- IndexedDB as one object, the calculator reads it whole, and nothing queries
+-- inside it. Normalising it would buy queries nobody makes and cost a join per
+-- slider.
+ALTER TABLE parties ADD COLUMN document TEXT;
+
+-- Whether the guest-facing invite link is open.
+--
+-- Sharing a party with a co-organiser now stores it server-side, and that must
+-- not be the same act as opening it to RSVPs: a party synced so two people can
+-- plan it would otherwise quietly accept guests through a slug its owner has
+-- never shown anyone. Existing rows were all published *for* the invite link,
+-- so they start open.
+ALTER TABLE parties ADD COLUMN invites_open INTEGER NOT NULL DEFAULT 1;
+
+-- Bumped on every accepted write. Clients send the version they were working
+-- from; the server uses it to decide whether to return the full document
+-- (they had fallen behind) or just the new number (they were current).
+ALTER TABLE parties ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
+
+-- Who may open a party.
+--
+-- `parties.owner_id` stays: it is the one role that cannot be transferred or
+-- removed here, and several queries key on it. This table is the general
+-- answer, and the owner gets a row in it too so that membership has exactly one
+-- place to be read from.
+CREATE TABLE party_members (
+ party_id TEXT NOT NULL REFERENCES parties(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL CHECK (role IN ('owner', 'editor')),
+ added_at TEXT NOT NULL,
+ PRIMARY KEY (party_id, user_id)
+);
+
+-- "Which parties can I open?" runs on every sign-in.
+CREATE INDEX idx_party_members_user ON party_members(user_id);
+
+-- Every party that already exists belongs to the person who published it.
+-- Without this they would lose access to their own parties the moment the
+-- membership check starts being enforced.
+INSERT INTO party_members (party_id, user_id, role, added_at)
+SELECT id, owner_id, 'owner', published_at FROM parties;
+
+-- An outstanding invitation to co-organise.
+--
+-- A token rather than an email invitation: the person may not have an account
+-- yet, and asking a host to know which address their friend signed up with is
+-- asking them to guess. The link is the credential, so it is long, single-party
+-- and revocable.
+CREATE TABLE collaborator_invites (
+ token TEXT PRIMARY KEY,
+ party_id TEXT NOT NULL REFERENCES parties(id) ON DELETE CASCADE,
+ created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ -- Set when the owner turns the link off. The row is kept rather than deleted
+ -- so a revoked link stays revoked instead of becoming an unknown token that a
+ -- future invite could be issued for again.
+ revoked_at TEXT
+);
+
+CREATE INDEX idx_collaborator_invites_party ON collaborator_invites(party_id);
diff --git a/backend/migrations/0004_ticket_keys_and_codes.sql b/backend/migrations/0004_ticket_keys_and_codes.sql
new file mode 100644
index 0000000..43bd7cd
--- /dev/null
+++ b/backend/migrations/0004_ticket_keys_and_codes.sql
@@ -0,0 +1,42 @@
+-- Tickets that more than one phone can check.
+--
+-- Until now a ticket was signed with a key generated in the issuing browser and
+-- identified by that browser's own numbering for the guest. Both are private to
+-- one device, so a co-organiser's phone could not verify a ticket the owner's
+-- phone produced — it would reject every guest, not merely miscount them. And
+-- a hand-added guest existed only on the device that typed them in, so the
+-- other organiser never saw them at all.
+
+-- The party's ticket-signing key, as a JWK.
+--
+-- Per party rather than per user: a ticket belongs to a party, and the people
+-- who need to verify it are exactly that party's members. Generated server-side
+-- the first time a party is stored, so no client decides it.
+--
+-- It is a shared secret among organisers, which is the right scope — anyone who
+-- holds it could mint a ticket, and anyone who holds it could also just add a
+-- guest through the API. It grants nothing they did not already have.
+ALTER TABLE parties ADD COLUMN ticket_key TEXT;
+
+-- The five characters printed on the ticket and encoded in its QR.
+--
+-- Unique per party, not globally: two parties may share a code without either
+-- being ambiguous, because a ticket names its party. Scanning and typing both
+-- resolve through this, so the door has one lookup and not two.
+ALTER TABLE invites ADD COLUMN ticket_code TEXT;
+
+CREATE UNIQUE INDEX idx_invites_party_code ON invites(party_id, ticket_code);
+
+-- Where an invite came from.
+--
+-- "Reached" counts people who opened the link. A guest the host typed in never
+-- opened anything, so without this they would inflate the top of the funnel and
+-- make the conversion rate a lie. They still need a server row — that is what
+-- lets a co-organiser see them and a second phone check their ticket.
+ALTER TABLE invites ADD COLUMN source TEXT NOT NULL DEFAULT 'link'
+ CHECK (source IN ('link', 'manual'));
+
+-- Check-in, which was previously only ever written in the scanning browser.
+-- The columns already existed (0002) and nothing wrote them; from here the
+-- server arbitrates, which is what makes "already scanned" true across devices
+-- rather than true on one phone.
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 0000000..c03a812
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,2673 @@
+{
+ "name": "bottlecount-backend",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bottlecount-backend",
+ "dependencies": {
+ "@hono/oauth-providers": "^0.9.0",
+ "hono": "^4.12.8"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20260917.1",
+ "typescript": "^5.9.3",
+ "vitest": "^5.0.1",
+ "wrangler": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260917.1.tgz",
+ "integrity": "sha512-ufFpLi2+WIuifZOiW38N8hzcX1tqfCqxYWgRC3tgT78TzjxUiBPcSkNdwv8dxeQ9xApwQh3BnQiFhfzq67umew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260917.1.tgz",
+ "integrity": "sha512-rbdal3kspPfG5O55I9IVHdBv2SH6UJMh66JkmdLIXuN2oS6WYctP226hKQJIme8mLW7XebRjL+ZnVbVES2SrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260917.1.tgz",
+ "integrity": "sha512-t981nh4Ol5mjzkH6x7x7Oev/UVmF5n3zjm07btl58BxJ1IgUSUPW0XJz07DR7rWss/EmGey7UlRMUM0qRcWkvg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260917.1.tgz",
+ "integrity": "sha512-3nW87yjIxhchhI7ZqbeQsy0Rfzw+7aZVADN/iDF0v1JOH3IxGsExEGGWYB+nQQQG4rUvnBlRTFT4WG21mVB02Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260917.1.tgz",
+ "integrity": "sha512-S3j07o3yMK0gzyEX+oM4/QmdtGdLxTK0mNYkQMZRjO98PjWr55TPiEkwfwcnfUNxnl6bI53MlHp1x5ZWOBg/JA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workers-types": {
+ "version": "5.20260918.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260918.1.tgz",
+ "integrity": "sha512-bUGc9gIdooYPi6lAUoB/eBXmy/ev9ok0XvDLzfJZQVCT8WCEQwv9f6smz6XeE83JzpX6ZSVi9CG1aDORnErYRQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0"
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@hono/oauth-providers": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@hono/oauth-providers/-/oauth-providers-0.9.0.tgz",
+ "integrity": "sha512-OgDPB+AM6OgA5ys1y+7G24bkb8V4OINr+6JFDSuSYni80Mmd7BGpJ0e02StQDMPL9ebuWI8ABhxx1bk+9VBcBA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.4.0"
+ },
+ "peerDependencies": {
+ "hono": ">=3.0.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==",
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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==",
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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==",
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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"
+ ],
+ "dev": true,
+ "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/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.150.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
+ "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
+ "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
+ "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
+ "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
+ "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
+ "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
+ "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
+ "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
+ "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
+ "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
+ "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
+ "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
+ "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
+ "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
+ "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
+ "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
+ "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
+ "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.8",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz",
+ "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "peer": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "5.20260917.0-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260917.0-alpha.tgz",
+ "integrity": "sha512-NpZyBR+h/nonVA/YO/+1/UJpIl8lTwCgpFPVp/KVzAhb8JuCRImPDfyrft040659Zna1RS3Lt2pzTs5Vwy5QSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.35.4",
+ "undici": "7.29.0",
+ "workerd": "1.20260917.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
+ "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.150.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.9",
+ "@rolldown/binding-android-arm64": "1.2.9",
+ "@rolldown/binding-darwin-arm64": "1.2.9",
+ "@rolldown/binding-darwin-x64": "1.2.9",
+ "@rolldown/binding-freebsd-x64": "1.2.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.9",
+ "@rolldown/binding-linux-arm64-musl": "1.2.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-musl": "1.2.9",
+ "@rolldown/binding-openharmony-arm64": "1.2.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.9",
+ "@rolldown/binding-win32-x64-msvc": "1.2.9"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
+ "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "dev": true,
+ "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/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.6",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
+ "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.1",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.1",
+ "@vitest/browser-preview": "5.0.1",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.1",
+ "@vitest/coverage-v8": "5.0.1",
+ "@vitest/ui": "5.0.1",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20260917.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260917.1.tgz",
+ "integrity": "sha512-k072RxsZfRz2cnyAq1Titt+0VpNfnFizSXUkT3r15CDJECBfBXj6JeKK0Bk5CrBLAHGP+dvOHjI//TP7GHXDEw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260917.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260917.1",
+ "@cloudflare/workerd-linux-64": "1.20260917.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260917.1",
+ "@cloudflare/workerd-windows-64": "1.20260917.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.134.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.134.0.tgz",
+ "integrity": "sha512-65JbYVGSt0XpPH8O/y6pNuS0t+AvvwVv+VRPbqYsNI5NMF9oFiJxVoS5GYU47ooamF9ucSXWvGyQ6QnAtXLgRA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260917.0-alpha",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260917.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20260917.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ }
+ }
+}
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..0559f61
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "bottlecount-backend",
+ "type": "module",
+ "private": true,
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "scripts": {
+ "dev": "wrangler dev --env local",
+ "deploy": "npm run deploy:production",
+ "deploy:production": "wrangler deploy --env production --minify",
+ "deploy:preview": "wrangler deploy --env preview --minify",
+ "cf-typegen": "wrangler types --env-interface CloudflareBindings",
+ "test": "vitest run",
+ "db:init:local": "wrangler d1 migrations apply db --local --env local",
+ "db:migrate:preview": "wrangler d1 migrations apply db-preview --remote --env preview",
+ "db:migrate:remote": "wrangler d1 migrations apply db --remote --env production",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
+ "licence:issue": "node scripts/issue-licence.mjs"
+ },
+ "dependencies": {
+ "@hono/oauth-providers": "^0.9.0",
+ "hono": "^4.12.8"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20260917.1",
+ "typescript": "^5.9.3",
+ "vitest": "^5.0.1",
+ "wrangler": "^4.4.0"
+ }
+}
diff --git a/backend/scripts/issue-licence.mjs b/backend/scripts/issue-licence.mjs
new file mode 100644
index 0000000..cb15f23
--- /dev/null
+++ b/backend/scripts/issue-licence.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/**
+ * Mints a licence code and inserts it into D1.
+ *
+ * This is the whole fulfilment pipeline until a checkout provider is wired up
+ * (docs/adr/0001-cloudflare-tiers.md): someone pays however they pay, you run
+ * this, you send them the code. When a provider is chosen, its webhook inserts
+ * the same row and this script stays useful for comps, refunds and testing.
+ *
+ * node scripts/issue-licence.mjs --env local # local dev database
+ * node scripts/issue-licence.mjs --env production --note "ko-fi #128"
+ * node scripts/issue-licence.mjs --print # code + SQL, no write
+ */
+import { randomBytes } from 'node:crypto';
+import { spawnSync } from 'node:child_process';
+
+// No I, O, 0 or 1: these are read off a screen and typed by hand, and those
+// four are where that goes wrong.
+const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+
+function group() {
+ const bytes = randomBytes(4);
+ return Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
+}
+
+function generateCode() {
+ return `BC-${group()}-${group()}-${group()}`;
+}
+
+function arg(name, fallback = null) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i === -1 ? fallback : (process.argv[i + 1] ?? fallback);
+}
+
+const env = arg('env', 'local');
+const note = arg('note', '');
+const printOnly = process.argv.includes('--print');
+const local = env === 'local';
+
+const code = generateCode();
+const sql =
+ 'INSERT INTO licence_keys (code, tier, issued_at, note) VALUES ' +
+ `('${code}', 'pro', '${new Date().toISOString()}', ` +
+ `${note ? `'${note.replace(/'/g, "''")}'` : 'NULL'});`;
+
+if (printOnly) {
+ console.log(code);
+ console.log(sql);
+ process.exit(0);
+}
+
+const database = env === 'preview' ? 'db-preview' : 'db';
+const result = spawnSync(
+ 'npx',
+ [
+ 'wrangler',
+ 'd1',
+ 'execute',
+ database,
+ '--env',
+ env,
+ local ? '--local' : '--remote',
+ '--command',
+ sql,
+ ],
+ { stdio: 'inherit' },
+);
+
+if (result.status !== 0) process.exit(result.status ?? 1);
+console.log(`\nLicence code: ${code}`);
diff --git a/backend/src/app.ts b/backend/src/app.ts
new file mode 100644
index 0000000..6087839
--- /dev/null
+++ b/backend/src/app.ts
@@ -0,0 +1,103 @@
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import { jwt } from 'hono/jwt';
+import type { AppVariables } from './appEnv';
+import { repositoriesFor } from './composition';
+import type { Repositories } from './repositories/repositories';
+import auth from './routes/auth';
+import collaborate from './routes/collaborate';
+import devAuth from './routes/devAuth';
+import invites from './routes/invites';
+import licences from './routes/licences';
+import parties from './routes/parties';
+import session from './routes/session';
+
+export type Bindings = {
+ db: D1Database;
+ GOOGLE_CLIENT_ID: string;
+ GOOGLE_CLIENT_SECRET: string;
+ JWT_SECRET: string;
+ FRONTEND_URL: string;
+ ENVIRONMENT: string;
+ /** "true" on a self-hosted deployment — see shared/tiers.ts. */
+ SELF_HOSTED?: string;
+ /** Guards the unauthenticated invite endpoints. Absent locally. */
+ INVITE_RATE_LIMITER?: {
+ limit(o: { key: string }): Promise<{ success: boolean }>;
+ };
+};
+
+export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
+
+/** `/api/*` paths served without a session. Trailing slashes are stripped first. */
+const PUBLIC_API_PATHS = new Set(['/api/session']);
+
+/** Test seam: substitute storage without standing up a D1 binding. */
+export interface AppOverrides {
+ repositories?: Repositories;
+}
+
+/**
+ * Every route the Worker serves.
+ *
+ * A function rather than a module-level `app` so tests can build a fresh one
+ * per case without a stale isolate's state leaking between them.
+ */
+export function createApp(overrides: AppOverrides = {}): App {
+ const app: App = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+ // In production the Pages Function proxy puts the frontend and this Worker on
+ // one origin, so CORS never comes up. It matters for `wrangler dev`, where
+ // the Astro dev server is a different port and the session cookie has to
+ // survive the hop.
+ app.use(
+ '*',
+ cors({
+ origin: (origin) => origin,
+ credentials: true,
+ }),
+ );
+
+ app.use('*', async (c, next) => {
+ c.set('repositories', overrides.repositories ?? repositoriesFor(c.env));
+ return next();
+ });
+
+ app.get('/', (c) =>
+ c.json({ service: 'bottlecount', environment: c.env.ENVIRONMENT }),
+ );
+
+ app.route('/auth', auth);
+ // Mounted beside the Google flow because it produces the identical session;
+ // the route itself refuses to run outside local/self-hosted builds.
+ app.route('/auth', devAuth);
+
+ // `/api/*` needs a session — except `/api/session` itself, which has to answer
+ // for logged-out browsers because that is the free tier, not an error. The
+ // exemption is named here rather than left to mount order: relying on the
+ // route being registered before this middleware would make the paywall depend
+ // on the order of two lines, and a later reshuffle would silently open or
+ // close it.
+ app.use('/api/*', async (c, next) => {
+ if (PUBLIC_API_PATHS.has(c.req.path.replace(/\/$/, ''))) return next();
+ const handler = jwt({
+ secret: c.env.JWT_SECRET,
+ alg: 'HS256',
+ cookie: 'session_token',
+ });
+ return handler(c, next);
+ });
+
+ // Does its own optional JWT check — see routes/session.ts.
+ app.route('/api/session', session);
+ app.route('/api/licences', licences);
+ app.route('/api/parties', parties);
+ app.route('/api/collaborate', collaborate);
+
+ // Mounted outside /api/* on purpose: guests have no account, and being able
+ // to RSVP without signing up is most of what an invite link is for. The
+ // handlers are written knowing the URL is the only credential.
+ app.route('/invite', invites);
+
+ return app;
+}
diff --git a/backend/src/appEnv.ts b/backend/src/appEnv.ts
new file mode 100644
index 0000000..d1af4a4
--- /dev/null
+++ b/backend/src/appEnv.ts
@@ -0,0 +1,12 @@
+import type { Repositories } from './repositories/repositories';
+
+/** Everything the `*`-middleware puts on the context for routes to read. */
+export interface AppVariables {
+ repositories: Repositories;
+ /**
+ * The authenticated user's id, set by a route group's own guard after it has
+ * resolved and checked the row — not by the JWT middleware, which only proves
+ * the cookie is signed.
+ */
+ userId?: string;
+}
diff --git a/backend/src/composition.ts b/backend/src/composition.ts
new file mode 100644
index 0000000..c4a61c4
--- /dev/null
+++ b/backend/src/composition.ts
@@ -0,0 +1,14 @@
+import { d1Repositories } from './repositories/d1';
+import type { Repositories } from './repositories/repositories';
+
+/**
+ * Picks the storage target for a request.
+ *
+ * D1 is the only one today. It stays a function rather than a module-level
+ * constant because a Worker's bindings arrive per request, not at import time —
+ * and because the seam is where a self-hoster who would rather run Postgres
+ * plugs in, the way FantasyWiki keeps a MongoDB target beside its D1 one.
+ */
+export function repositoriesFor(env: { db: D1Database }): Repositories {
+ return d1Repositories(env.db);
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 0000000..9aac462
--- /dev/null
+++ b/backend/src/index.ts
@@ -0,0 +1,3 @@
+import { createApp } from './app';
+
+export default createApp();
diff --git a/backend/src/lib/ticketCodes.ts b/backend/src/lib/ticketCodes.ts
new file mode 100644
index 0000000..977f1bc
--- /dev/null
+++ b/backend/src/lib/ticketCodes.ts
@@ -0,0 +1,30 @@
+import {
+ generateTicketCode,
+ TICKET_CODE_ATTEMPTS,
+} from '../../../shared/tickets';
+import { INVITE_ERRORS } from '../repositories/inviteRepository';
+import type { Result } from '../repositories/result';
+import { err } from '../repositories/result';
+
+/**
+ * Runs a write that needs a free ticket code, redrawing if the unique index
+ * rejects the one it was given.
+ *
+ * A helper rather than a loop at each call site, so a caller states only its
+ * INSERT and inherits the retry policy — and so the policy is testable on its
+ * own. Bounded on purpose: an unbounded regenerate loop turns a broken RNG or a
+ * mis-declared index into a hung request instead of an error.
+ */
+export async function withUniqueTicketCode(
+ write: (code: string) => Promise>,
+ attempts: number = TICKET_CODE_ATTEMPTS,
+): Promise> {
+ for (let i = 0; i < attempts; i++) {
+ const result = await write(generateTicketCode());
+ if (result.ok || result.error !== INVITE_ERRORS.CODE_TAKEN) {
+ // Success, or a real persistence failure that retrying would only repeat.
+ return result;
+ }
+ }
+ return err(INVITE_ERRORS.CODE_UNAVAILABLE);
+}
diff --git a/backend/src/lib/tokens.ts b/backend/src/lib/tokens.ts
new file mode 100644
index 0000000..cae540f
--- /dev/null
+++ b/backend/src/lib/tokens.ts
@@ -0,0 +1,40 @@
+/**
+ * Tokens and slugs for the invite link.
+ *
+ * Nothing behind an invite URL is authenticated — that is the whole point, a
+ * guest has no account — so the URL itself is the secret. These are sized to be
+ * unguessable rather than short.
+ */
+
+// Base32-ish, no I/O/0/1: these end up in URLs people read aloud and retype.
+const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
+
+function randomString(length: number): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
+ return Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
+}
+
+/**
+ * ~99 bits. Forward tokens identify a guest to anyone holding one, and a
+ * guessable one would let a stranger claim someone else's referral tree.
+ */
+export function newToken(): string {
+ return randomString(20);
+}
+
+/**
+ * A readable slug with a random tail. The readable half is courtesy — the tail
+ * is what stops someone enumerating parties, so it does not shrink when the
+ * name is long.
+ */
+export function newSlug(name: string): string {
+ const readable =
+ name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 32) || 'party';
+ return `${readable}-${randomString(10)}`;
+}
diff --git a/backend/src/repositories/d1/index.ts b/backend/src/repositories/d1/index.ts
new file mode 100644
index 0000000..929a493
--- /dev/null
+++ b/backend/src/repositories/d1/index.ts
@@ -0,0 +1,16 @@
+import type { Repositories } from '../repositories';
+import { InviteRepositoryD1 } from './inviteRepositoryD1';
+import { LicenceRepositoryD1 } from './licenceRepositoryD1';
+import { MemberRepositoryD1 } from './memberRepositoryD1';
+import { PartyRepositoryD1 } from './partyRepositoryD1';
+import { UserRepositoryD1 } from './userRepositoryD1';
+
+export function d1Repositories(db: D1Database): Repositories {
+ return {
+ users: new UserRepositoryD1(db),
+ licences: new LicenceRepositoryD1(db),
+ parties: new PartyRepositoryD1(db),
+ invites: new InviteRepositoryD1(db),
+ members: new MemberRepositoryD1(db),
+ };
+}
diff --git a/backend/src/repositories/d1/inviteRepositoryD1.ts b/backend/src/repositories/d1/inviteRepositoryD1.ts
new file mode 100644
index 0000000..e37d146
--- /dev/null
+++ b/backend/src/repositories/d1/inviteRepositoryD1.ts
@@ -0,0 +1,342 @@
+import type { InviteAnswer, InviteStatus } from '../../../../shared/invites';
+import { INVITE_STATUSES } from '../../../../shared/invites';
+import { newToken } from '../../lib/tokens';
+import { withUniqueTicketCode } from '../../lib/ticketCodes';
+import {
+ INVITE_ERRORS,
+ type Invite,
+ type InviteRepository,
+ type InviteSource,
+ type InviteWithReferrer,
+} from '../inviteRepository';
+import { err, ok, type Result } from '../result';
+
+interface InviteRow {
+ id: string;
+ party_id: string;
+ name: string | null;
+ status: string;
+ depth: number;
+ referrer_id: string | null;
+ forward_token: string;
+ ticket_code: string | null;
+ source: string;
+ checked_in: number;
+ checked_in_at: string | null;
+ opened_at: string;
+ answered_at: string | null;
+}
+
+interface InviteJoinRow extends InviteRow {
+ referrer_name: string | null;
+}
+
+function toStatus(value: string): InviteStatus {
+ // The column has a CHECK constraint but is still TEXT. Narrow rather than
+ // cast, and fall back to the state that claims the least.
+ return (INVITE_STATUSES as readonly string[]).includes(value)
+ ? (value as InviteStatus)
+ : 'opened';
+}
+
+function toInvite(row: InviteRow): Invite {
+ return {
+ id: row.id,
+ partyId: row.party_id,
+ name: row.name,
+ status: toStatus(row.status),
+ depth: row.depth,
+ referrerId: row.referrer_id,
+ forwardToken: row.forward_token,
+ // A row from before ticket codes existed reads as empty rather than being
+ // invented here — only a write may mint a code, or two readers would
+ // disagree about what is printed on the same ticket.
+ ticketCode: row.ticket_code ?? '',
+ source: row.source === 'manual' ? 'manual' : ('link' as InviteSource),
+ checkedIn: row.checked_in === 1,
+ checkedInAt: row.checked_in_at,
+ openedAt: row.opened_at,
+ answeredAt: row.answered_at,
+ };
+}
+
+export class InviteRepositoryD1 implements InviteRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async open({
+ partyId,
+ referrerToken,
+ rootToken,
+ existingInviteId,
+ }: {
+ partyId: string;
+ referrerToken: string | null;
+ rootToken: string;
+ existingInviteId: string | null;
+ }): Promise> {
+ // A browser that already has a row for this party is the same guest coming
+ // back — show them their answer rather than counting them twice. Scoped to
+ // the party so an id lifted from another party's link resolves to nothing.
+ if (existingInviteId) {
+ const existing = await this.db
+ .prepare('SELECT * FROM invites WHERE id = ? AND party_id = ?')
+ .bind(existingInviteId, partyId)
+ .first();
+ if (existing) return ok(toInvite(existing));
+ }
+
+ let depth = 0;
+ let referrerId: string | null = null;
+
+ // The host's own token is depth 0 and has no invite row behind it. Any
+ // other token has to resolve to an invite *of this party*; one that does
+ // not is treated as no referrer at all rather than rejected, because the
+ // common cause is a link from a party that has since been unpublished and
+ // the guest should still be able to RSVP.
+ if (referrerToken && referrerToken !== rootToken) {
+ const referrer = await this.db
+ .prepare(
+ 'SELECT id, depth FROM invites WHERE forward_token = ? AND party_id = ?',
+ )
+ .bind(referrerToken, partyId)
+ .first<{ id: string; depth: number }>();
+ if (referrer) {
+ referrerId = referrer.id;
+ depth = referrer.depth + 1;
+ }
+ }
+
+ // The code is minted here, on open, rather than on confirmation: a guest
+ // who answers expects their ticket immediately, and generating it later
+ // would mean a second write on the busiest path.
+ return withUniqueTicketCode(async (ticketCode) => {
+ const row = await this.db
+ .prepare(
+ `INSERT INTO invites (
+ id, party_id, name, status, depth, referrer_id,
+ forward_token, ticket_code, source, opened_at
+ ) VALUES (?, ?, NULL, 'opened', ?, ?, ?, ?, 'link', ?) RETURNING *`,
+ )
+ .bind(
+ crypto.randomUUID(),
+ partyId,
+ depth,
+ referrerId,
+ newToken(),
+ ticketCode,
+ new Date().toISOString(),
+ )
+ .first()
+ .catch(() => null);
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.CODE_TAKEN);
+ });
+ }
+
+ async findById(id: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM invites WHERE id = ?')
+ .bind(id)
+ .first();
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async answer({
+ inviteId,
+ partyId,
+ name,
+ answer,
+ maxCapacity,
+ }: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise> {
+ const now = new Date().toISOString();
+
+ // Declining is always allowed — a full party is still a party you can say
+ // no to, and refusing the decline would leave the row stuck at `opened`.
+ if (answer === 'declined' || maxCapacity === null) {
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET name = ?, status = ?, answered_at = ?
+ WHERE id = ? AND party_id = ? RETURNING *`,
+ )
+ .bind(name, answer, now, inviteId, partyId)
+ .first();
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ // Capacity check and write in one statement. Counting first and updating
+ // after would let two guests racing for the last place both read "one left"
+ // and both confirm.
+ //
+ // The subquery excludes this invite, so a guest who is already confirmed and
+ // merely corrects their name does not have to fit into a party they are
+ // already counted in.
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET name = ?, status = 'confirmed', answered_at = ?
+ WHERE id = ? AND party_id = ?
+ AND (
+ SELECT COUNT(*) FROM invites others
+ WHERE others.party_id = ?
+ AND others.status = 'confirmed'
+ AND others.id <> ?
+ ) < ?
+ RETURNING *`,
+ )
+ .bind(name, now, inviteId, partyId, partyId, inviteId, maxCapacity)
+ .first();
+
+ if (row) return ok(toInvite(row));
+
+ // Nothing was written: either the invite is gone, or the party is full.
+ const stillThere = await this.db
+ .prepare('SELECT id FROM invites WHERE id = ? AND party_id = ?')
+ .bind(inviteId, partyId)
+ .first<{ id: string }>();
+
+ return err(stillThere ? INVITE_ERRORS.PARTY_FULL : INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async setStatus({
+ inviteId,
+ partyId,
+ status,
+ }: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise> {
+ // Sending someone back to `opened` clears the answer timestamp too, so the
+ // funnel does not show a guest who supposedly answered at a time but holds
+ // no answer.
+ const row = await this.db
+ .prepare(
+ `UPDATE invites
+ SET status = ?, answered_at = CASE WHEN ? = 'opened' THEN NULL ELSE ? END
+ WHERE id = ? AND party_id = ? RETURNING *`,
+ )
+ .bind(status, status, new Date().toISOString(), inviteId, partyId)
+ .first();
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async addManual({
+ partyId,
+ name,
+ }: {
+ partyId: string;
+ name: string;
+ ticketCode: string;
+ }): Promise> {
+ const now = new Date().toISOString();
+
+ // `ticketCode` in the signature is ignored in favour of a drawn one: the
+ // caller should not have to know about collisions, and the retry has to own
+ // the draw for the redraw to mean anything.
+ return withUniqueTicketCode(async (code) => {
+ const row = await this.db
+ .prepare(
+ `INSERT INTO invites (
+ id, party_id, name, status, depth, referrer_id,
+ forward_token, ticket_code, source, opened_at, answered_at
+ ) VALUES (?, ?, ?, 'confirmed', 0, NULL, ?, ?, 'manual', ?, ?)
+ RETURNING *`,
+ )
+ .bind(crypto.randomUUID(), partyId, name, newToken(), code, now, now)
+ .first()
+ .catch(() => null);
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.CODE_TAKEN);
+ });
+ }
+
+ async checkIn({
+ inviteId,
+ partyId,
+ at,
+ }: {
+ inviteId: string;
+ partyId: string;
+ at: string;
+ }): Promise> {
+ // `AND checked_in = 0` is what makes the second scan lose. Two phones
+ // scanning the same ticket at once both reach here; only one row changes.
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET checked_in = 1, checked_in_at = ?
+ WHERE id = ? AND party_id = ? AND checked_in = 0
+ RETURNING *`,
+ )
+ .bind(at, inviteId, partyId)
+ .first();
+
+ if (row) return ok(toInvite(row));
+
+ // Nothing changed: either they are already in, or the invite is not here.
+ const existing = await this.db
+ .prepare('SELECT * FROM invites WHERE id = ? AND party_id = ?')
+ .bind(inviteId, partyId)
+ .first();
+
+ return err(
+ existing ? INVITE_ERRORS.ALREADY_CHECKED_IN : INVITE_ERRORS.NOT_FOUND,
+ );
+ }
+
+ async undoCheckIn({
+ inviteId,
+ partyId,
+ }: {
+ inviteId: string;
+ partyId: string;
+ }): Promise> {
+ const row = await this.db
+ .prepare(
+ `UPDATE invites SET checked_in = 0, checked_in_at = NULL
+ WHERE id = ? AND party_id = ? RETURNING *`,
+ )
+ .bind(inviteId, partyId)
+ .first();
+
+ return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND);
+ }
+
+ async listForParty(partyId: string): Promise> {
+ // Oldest first: the host's client matches these onto rows it already has by
+ // id, and a stable order keeps newly opened invites appending at the end
+ // rather than reshuffling the list under the reader.
+ const { results } = await this.db
+ .prepare(
+ `SELECT i.*, r.name AS referrer_name
+ FROM invites i
+ LEFT JOIN invites r ON r.id = i.referrer_id
+ WHERE i.party_id = ?
+ ORDER BY i.opened_at ASC, i.id ASC`,
+ )
+ .bind(partyId)
+ .all();
+
+ return ok(
+ results.map((row) => ({
+ ...toInvite(row),
+ referrerName: row.referrer_name,
+ })),
+ );
+ }
+
+ async countConfirmed(partyId: string): Promise> {
+ const row = await this.db
+ .prepare(
+ "SELECT COUNT(*) AS n FROM invites WHERE party_id = ? AND status = 'confirmed'",
+ )
+ .bind(partyId)
+ .first<{ n: number }>();
+ return ok(row?.n ?? 0);
+ }
+}
diff --git a/backend/src/repositories/d1/licenceRepositoryD1.ts b/backend/src/repositories/d1/licenceRepositoryD1.ts
new file mode 100644
index 0000000..5e0ac9a
--- /dev/null
+++ b/backend/src/repositories/d1/licenceRepositoryD1.ts
@@ -0,0 +1,65 @@
+import { isTier } from '../../../../shared/tiers';
+import { err, ok, type Result } from '../result';
+import {
+ LICENCE_ERRORS,
+ type LicenceKey,
+ type LicenceRepository,
+} from '../licenceRepository';
+
+interface LicenceRow {
+ code: string;
+ tier: string;
+ issued_at: string;
+ redeemed_at: string | null;
+ redeemed_by: string | null;
+ note: string | null;
+}
+
+function toLicence(row: LicenceRow): LicenceKey {
+ return {
+ code: row.code,
+ tier: isTier(row.tier) ? row.tier : 'free',
+ issuedAt: row.issued_at,
+ redeemedAt: row.redeemed_at,
+ redeemedBy: row.redeemed_by,
+ note: row.note,
+ };
+}
+
+/** Codes are handed out uppercased and hyphenated; users retype them however. */
+function normalise(code: string): string {
+ return code.trim().toUpperCase();
+}
+
+export class LicenceRepositoryD1 implements LicenceRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async redeem(code: string, userId: string): Promise> {
+ const normalised = normalise(code);
+
+ // One statement, so two requests racing the same code cannot both win: the
+ // loser's UPDATE matches no row because `redeemed_at` is no longer NULL.
+ const claimed = await this.db
+ .prepare(
+ `UPDATE licence_keys SET redeemed_at = ?, redeemed_by = ?
+ WHERE code = ? AND redeemed_at IS NULL RETURNING *`,
+ )
+ .bind(new Date().toISOString(), userId, normalised)
+ .first();
+
+ if (claimed) return ok(toLicence(claimed));
+
+ // Nothing was claimed: either the code does not exist, someone else holds
+ // it, or this same user already redeemed it.
+ const existing = await this.db
+ .prepare('SELECT * FROM licence_keys WHERE code = ?')
+ .bind(normalised)
+ .first();
+
+ if (!existing) return err(LICENCE_ERRORS.UNKNOWN);
+ // Idempotent for the holder — a double-tapped Redeem button is not an error
+ // the user can act on, and their tier is already what the code grants.
+ if (existing.redeemed_by === userId) return ok(toLicence(existing));
+ return err(LICENCE_ERRORS.ALREADY_REDEEMED);
+ }
+}
diff --git a/backend/src/repositories/d1/memberRepositoryD1.ts b/backend/src/repositories/d1/memberRepositoryD1.ts
new file mode 100644
index 0000000..ff8ef12
--- /dev/null
+++ b/backend/src/repositories/d1/memberRepositoryD1.ts
@@ -0,0 +1,177 @@
+import { isPartyRole, type PartyRole } from '../../../../shared/collab';
+import { newToken } from '../../lib/tokens';
+import {
+ MEMBER_ERRORS,
+ type CollaboratorInvite,
+ type MemberRepository,
+ type PartyMember,
+} from '../memberRepository';
+import { err, ok, type Result } from '../result';
+
+interface MemberRow {
+ party_id: string;
+ user_id: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+ role: string;
+ added_at: string;
+}
+
+interface InviteRow {
+ token: string;
+ party_id: string;
+ created_by: string;
+ created_at: string;
+ revoked_at: string | null;
+}
+
+function toMember(row: MemberRow): PartyMember {
+ return {
+ partyId: row.party_id,
+ userId: row.user_id,
+ email: row.email,
+ name: row.name,
+ picture: row.picture,
+ // Narrow rather than cast: an unreadable role must fall to the one that
+ // grants least, never to owner.
+ role: isPartyRole(row.role) ? row.role : 'editor',
+ addedAt: row.added_at,
+ };
+}
+
+function toInvite(row: InviteRow): CollaboratorInvite {
+ return {
+ token: row.token,
+ partyId: row.party_id,
+ createdBy: row.created_by,
+ createdAt: row.created_at,
+ revokedAt: row.revoked_at,
+ };
+}
+
+export class MemberRepositoryD1 implements MemberRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async roleFor(partyId: string, userId: string): Promise> {
+ const row = await this.db
+ .prepare(
+ 'SELECT role FROM party_members WHERE party_id = ? AND user_id = ?',
+ )
+ .bind(partyId, userId)
+ .first<{ role: string }>();
+
+ if (!row) return err(MEMBER_ERRORS.NOT_A_MEMBER);
+ return ok(isPartyRole(row.role) ? row.role : 'editor');
+ }
+
+ async listMembers(partyId: string): Promise> {
+ // Owner first, then by when they joined, so the list reads as the party
+ // grew rather than in whatever order SQLite feels like.
+ const { results } = await this.db
+ .prepare(
+ `SELECT m.party_id, m.user_id, m.role, m.added_at,
+ u.email, u.name, u.picture
+ FROM party_members m
+ JOIN users u ON u.id = m.user_id
+ WHERE m.party_id = ?
+ ORDER BY (m.role = 'owner') DESC, m.added_at ASC`,
+ )
+ .bind(partyId)
+ .all();
+
+ return ok(results.map(toMember));
+ }
+
+ async add({
+ partyId,
+ userId,
+ role,
+ }: {
+ partyId: string;
+ userId: string;
+ role: PartyRole;
+ }): Promise> {
+ // DO NOTHING rather than DO UPDATE: accepting an invite twice must not
+ // demote an owner who happened to open their own link.
+ await this.db
+ .prepare(
+ `INSERT INTO party_members (party_id, user_id, role, added_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (party_id, user_id) DO NOTHING`,
+ )
+ .bind(partyId, userId, role, new Date().toISOString())
+ .run();
+
+ const row = await this.db
+ .prepare(
+ `SELECT m.party_id, m.user_id, m.role, m.added_at,
+ u.email, u.name, u.picture
+ FROM party_members m
+ JOIN users u ON u.id = m.user_id
+ WHERE m.party_id = ? AND m.user_id = ?`,
+ )
+ .bind(partyId, userId)
+ .first();
+
+ return row ? ok(toMember(row)) : err(MEMBER_ERRORS.NOT_FOUND);
+ }
+
+ async remove(partyId: string, userId: string): Promise> {
+ // The owner guard is in the WHERE clause so there is no window between
+ // checking the role and deleting the row.
+ const result = await this.db
+ .prepare(
+ `DELETE FROM party_members
+ WHERE party_id = ? AND user_id = ? AND role <> 'owner'`,
+ )
+ .bind(partyId, userId)
+ .run();
+
+ if (result.meta.changes > 0) return ok(undefined);
+
+ const existing = await this.roleFor(partyId, userId);
+ if (!existing.ok) return err(MEMBER_ERRORS.NOT_FOUND);
+ return err(MEMBER_ERRORS.CANNOT_REMOVE_OWNER);
+ }
+
+ async createInvite({
+ partyId,
+ createdBy,
+ }: {
+ partyId: string;
+ createdBy: string;
+ }): Promise> {
+ const row = await this.db
+ .prepare(
+ `INSERT INTO collaborator_invites (token, party_id, created_by, created_at)
+ VALUES (?, ?, ?, ?) RETURNING *`,
+ )
+ .bind(newToken(), partyId, createdBy, new Date().toISOString())
+ .first();
+
+ return row ? ok(toInvite(row)) : err(MEMBER_ERRORS.INVITE_UNKNOWN);
+ }
+
+ async findInvite(token: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM collaborator_invites WHERE token = ?')
+ .bind(token)
+ .first();
+
+ if (!row) return err(MEMBER_ERRORS.INVITE_UNKNOWN);
+ if (row.revoked_at !== null) return err(MEMBER_ERRORS.INVITE_REVOKED);
+ return ok(toInvite(row));
+ }
+
+ async revokeInvitesFor(partyId: string): Promise> {
+ await this.db
+ .prepare(
+ `UPDATE collaborator_invites SET revoked_at = ?
+ WHERE party_id = ? AND revoked_at IS NULL`,
+ )
+ .bind(new Date().toISOString(), partyId)
+ .run();
+ return ok(undefined);
+ }
+}
diff --git a/backend/src/repositories/d1/partyRepositoryD1.ts b/backend/src/repositories/d1/partyRepositoryD1.ts
new file mode 100644
index 0000000..e86236c
--- /dev/null
+++ b/backend/src/repositories/d1/partyRepositoryD1.ts
@@ -0,0 +1,399 @@
+import type { PartyDocument } from '../../../../shared/collab';
+import { isPartyRole } from '../../../../shared/collab';
+import type { MergePatch } from '../../../../shared/patch';
+import type { PublishPartyRequest } from '../../../../shared/invites';
+import { newSlug, newToken } from '../../lib/tokens';
+import {
+ PARTY_ERRORS,
+ type PartyRepository,
+ type PartySummary,
+ type PublishedParty,
+} from '../partyRepository';
+import { err, ok, type Result } from '../result';
+
+interface PartyRow {
+ id: string;
+ owner_id: string;
+ local_id: number;
+ slug: string;
+ name: string;
+ date: string;
+ cover: number;
+ venue_place: string;
+ venue_city: string;
+ venue_time: string;
+ allow_forward: number;
+ max_capacity: number | null;
+ root_token: string;
+ published_at: string;
+ updated_at: string;
+ document: string | null;
+ version: number;
+ invites_open: number;
+ ticket_key: string | null;
+}
+
+/**
+ * The stored document, or null when it cannot be read.
+ *
+ * A row written before documents existed has NULL here. A row whose JSON has
+ * somehow gone bad is the same situation from the caller's side — there is no
+ * document to serve — and turning it into a throw would take down a request
+ * that could have degraded.
+ */
+function parseTicketKey(raw: string | null): JsonWebKey | null {
+ if (raw === null) return null;
+ try {
+ return JSON.parse(raw) as JsonWebKey;
+ } catch {
+ return null;
+ }
+}
+
+function parseDocument(raw: string | null): PartyDocument | null {
+ if (raw === null) return null;
+ try {
+ return JSON.parse(raw) as PartyDocument;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The card columns (`name`, `date`, `cover`, the venue, `allow_forward`,
+ * `max_capacity`) duplicate fields that also live inside the document. Reading
+ * them from the document when there is one is what makes that duplication safe:
+ * a patch only has to write the document, and the two can never drift, because
+ * only one of them is ever believed.
+ *
+ * The columns remain for rows written before documents existed, and because
+ * `listForUser` and the guest-facing card want them without parsing JSON.
+ */
+function toParty(row: PartyRow): PublishedParty {
+ const document = parseDocument(row.document);
+ if (document) {
+ return {
+ id: row.id,
+ ownerId: row.owner_id,
+ localId: row.local_id,
+ slug: row.slug,
+ name: document.name,
+ date: document.date,
+ cover: document.cover,
+ venue: document.venue,
+ allowForward: document.allowForward,
+ maxCapacity: document.settings.max_capacity,
+ rootToken: row.root_token,
+ publishedAt: row.published_at,
+ updatedAt: row.updated_at,
+ document,
+ version: row.version,
+ invitesOpen: row.invites_open === 1,
+ ticketKey: parseTicketKey(row.ticket_key),
+ };
+ }
+
+ return {
+ id: row.id,
+ ownerId: row.owner_id,
+ localId: row.local_id,
+ slug: row.slug,
+ name: row.name,
+ date: row.date,
+ cover: row.cover,
+ venue: {
+ place: row.venue_place,
+ city: row.venue_city,
+ time: row.venue_time,
+ },
+ allowForward: row.allow_forward === 1,
+ maxCapacity: row.max_capacity,
+ rootToken: row.root_token,
+ publishedAt: row.published_at,
+ updatedAt: row.updated_at,
+ document: null,
+ version: row.version,
+ invitesOpen: row.invites_open === 1,
+ ticketKey: parseTicketKey(row.ticket_key),
+ };
+}
+
+/**
+ * A fresh HMAC-SHA256 key, exported as a JWK.
+ *
+ * Generated on the server so no client decides it, and exported because
+ * IndexedDB — where the browser will keep its copy — cannot structured-clone a
+ * CryptoKey.
+ */
+async function newTicketKey(): Promise {
+ // `generateKey` is typed as possibly returning a key *pair*; HMAC never does,
+ // but the signature covers RSA and EC too.
+ const key = (await crypto.subtle.generateKey(
+ { name: 'HMAC', hash: 'SHA-256' },
+ true,
+ ['sign', 'verify'],
+ )) as CryptoKey;
+ return JSON.stringify(await crypto.subtle.exportKey('jwk', key));
+}
+
+export class PartyRepositoryD1 implements PartyRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise> {
+ const now = new Date().toISOString();
+ const doc = snapshot.document;
+
+ // ON CONFLICT rather than a read-then-write: republishing is what happens
+ // on every save, so it has to be one round trip and safe against two
+ // devices doing it at once.
+ //
+ // `slug` and `root_token` are excluded from the update set on purpose. They
+ // are already out in the world on links the host has sent; rotating them on
+ // an edit would silently break every invitation. `invites_open` is excluded
+ // for the same reason in reverse — storing the party must not reopen a link
+ // the owner has closed.
+ //
+ // The card columns are derived from the document rather than sent beside
+ // it, so the party's name cannot mean one thing to a guest and another to
+ // an organiser.
+ const row = await this.db
+ .prepare(
+ `INSERT INTO parties (
+ id, owner_id, local_id, slug, name, date, cover,
+ venue_place, venue_city, venue_time,
+ allow_forward, max_capacity, root_token,
+ document, version, invites_open, ticket_key, published_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?)
+ ON CONFLICT (owner_id, local_id) DO UPDATE SET
+ name = excluded.name,
+ date = excluded.date,
+ cover = excluded.cover,
+ venue_place = excluded.venue_place,
+ venue_city = excluded.venue_city,
+ venue_time = excluded.venue_time,
+ allow_forward = excluded.allow_forward,
+ max_capacity = excluded.max_capacity,
+ document = excluded.document,
+ version = parties.version + 1,
+ updated_at = excluded.updated_at
+ -- ticket_key is deliberately absent: rotating it on every save would
+ -- invalidate every ticket already in a guest's phone.
+ RETURNING *`,
+ )
+ .bind(
+ crypto.randomUUID(),
+ ownerId,
+ snapshot.localId,
+ newSlug(doc.name),
+ doc.name,
+ doc.date,
+ doc.cover,
+ doc.venue.place,
+ doc.venue.city,
+ doc.venue.time,
+ doc.allowForward ? 1 : 0,
+ doc.settings.max_capacity,
+ newToken(),
+ JSON.stringify(doc),
+ await newTicketKey(),
+ now,
+ now,
+ )
+ .first();
+
+ if (!row) return err(PARTY_ERRORS.NOT_FOUND);
+
+ // The owner's membership row. A separate statement because the party's id
+ // is only known after the upsert has resolved the conflict, and idempotent
+ // so republishing does not disturb it. A party whose owner is not a member
+ // would be invisible to the person who made it, so this runs on every
+ // publish rather than only on insert — that way a row lost to a failure
+ // here is repaired by the next save instead of stranding the party.
+ await this.db
+ .prepare(
+ `INSERT INTO party_members (party_id, user_id, role, added_at)
+ VALUES (?, ?, 'owner', ?)
+ ON CONFLICT (party_id, user_id) DO NOTHING`,
+ )
+ .bind(row.id, ownerId, now)
+ .run();
+
+ return ok(toParty(row));
+ }
+
+ async findBySlug(slug: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM parties WHERE slug = ?')
+ .bind(slug)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM parties WHERE owner_id = ? AND local_id = ?')
+ .bind(ownerId, localId)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async findById(id: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM parties WHERE id = ?')
+ .bind(id)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async listForUser(userId: string): Promise> {
+ const { results } = await this.db
+ .prepare(
+ `SELECT p.id, p.version, p.updated_at, m.role,
+ -- Same rule as toParty: the document wins where there is one,
+ -- so a patched party does not show its old name in the list.
+ COALESCE(json_extract(p.document, '$.name'), p.name) AS name,
+ COALESCE(json_extract(p.document, '$.date'), p.date) AS date,
+ COALESCE(json_extract(p.document, '$.cover'), p.cover) AS cover,
+ (SELECT COUNT(*) FROM party_members WHERE party_id = p.id) AS member_count
+ FROM parties p
+ JOIN party_members m ON m.party_id = p.id
+ WHERE m.user_id = ?
+ ORDER BY p.date DESC, p.updated_at DESC`,
+ )
+ .bind(userId)
+ .all<{
+ id: string;
+ name: string;
+ date: string;
+ cover: number;
+ version: number;
+ updated_at: string;
+ role: string;
+ member_count: number;
+ }>();
+
+ return ok(
+ results.map((row) => ({
+ id: row.id,
+ name: row.name,
+ date: row.date,
+ cover: row.cover,
+ role: isPartyRole(row.role) ? row.role : 'editor',
+ version: row.version,
+ updatedAt: row.updated_at,
+ memberCount: row.member_count,
+ })),
+ );
+ }
+
+ async patchDocument({
+ partyId,
+ patch,
+ }: {
+ partyId: string;
+ patch: MergePatch;
+ }): Promise<
+ Result<{ document: PartyDocument; version: number; updatedAt: string }>
+ > {
+ // `json_patch` is SQLite's RFC 7386 merge — the same rules
+ // `shared/patch.ts` implements for the client, so both sides agree on what
+ // a patch means without either reimplementing the other.
+ //
+ // Read, merge and write are one statement on purpose. Two co-organisers
+ // saving at the same moment would otherwise both read the same document and
+ // write back over each other, which is exactly the loss patching exists to
+ // prevent.
+ //
+ // It touches only the document. The card columns are derived from it on
+ // read (see `toParty`), so there is nothing here to keep in step.
+ const row = await this.db
+ .prepare(
+ `UPDATE parties
+ SET document = json_patch(document, ?),
+ version = version + 1,
+ updated_at = ?
+ WHERE id = ? AND document IS NOT NULL
+ RETURNING *`,
+ )
+ .bind(JSON.stringify(patch), new Date().toISOString(), partyId)
+ .first();
+
+ if (!row) {
+ // Either the party is gone or it predates document storage; the caller
+ // needs to tell those apart to choose between 404 and "store it first".
+ const existing = await this.findById(partyId);
+ return err(
+ existing.ok ? PARTY_ERRORS.NO_DOCUMENT : PARTY_ERRORS.NOT_FOUND,
+ );
+ }
+
+ const document = parseDocument(row.document);
+ if (!document) return err(PARTY_ERRORS.NO_DOCUMENT);
+
+ return ok({ document, version: row.version, updatedAt: row.updated_at });
+ }
+
+ async putDocument({
+ partyId,
+ document,
+ }: {
+ partyId: string;
+ document: PartyDocument;
+ }): Promise> {
+ const row = await this.db
+ .prepare(
+ `UPDATE parties SET document = ?, version = version + 1, updated_at = ?
+ WHERE id = ? RETURNING version, updated_at`,
+ )
+ .bind(JSON.stringify(document), new Date().toISOString(), partyId)
+ .first<{ version: number; updated_at: string }>();
+
+ return row
+ ? ok({ version: row.version, updatedAt: row.updated_at })
+ : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async setInvitesOpen(
+ id: string,
+ open: boolean,
+ ): Promise> {
+ const row = await this.db
+ .prepare(
+ 'UPDATE parties SET invites_open = ?, updated_at = ? WHERE id = ? RETURNING *',
+ )
+ .bind(open ? 1 : 0, new Date().toISOString(), id)
+ .first();
+ return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async deleteById(id: string): Promise> {
+ const result = await this.db
+ .prepare('DELETE FROM parties WHERE id = ?')
+ .bind(id)
+ .run();
+ return result.meta.changes > 0
+ ? ok(undefined)
+ : err(PARTY_ERRORS.NOT_FOUND);
+ }
+
+ async unpublish(ownerId: string, id: string): Promise> {
+ // The owner check is in the WHERE clause, not a prior SELECT: a separate
+ // read would let another request change ownership in between, and it also
+ // means a party someone else owns is indistinguishable from one that does
+ // not exist.
+ const result = await this.db
+ .prepare('DELETE FROM parties WHERE id = ? AND owner_id = ?')
+ .bind(id, ownerId)
+ .run();
+
+ return result.meta.changes > 0
+ ? ok(undefined)
+ : err(PARTY_ERRORS.NOT_FOUND);
+ }
+}
diff --git a/backend/src/repositories/d1/userRepositoryD1.ts b/backend/src/repositories/d1/userRepositoryD1.ts
new file mode 100644
index 0000000..d717e47
--- /dev/null
+++ b/backend/src/repositories/d1/userRepositoryD1.ts
@@ -0,0 +1,136 @@
+import { isTier, type Tier } from '../../../../shared/tiers';
+import { err, ok, type Result } from '../result';
+import {
+ USER_ERRORS,
+ type ProviderIdentity,
+ type User,
+ type UserRepository,
+} from '../userRepository';
+
+interface UserRow {
+ id: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+ tier: string;
+ created_at: string;
+ updated_at: string;
+}
+
+function toUser(row: UserRow): User {
+ return {
+ id: row.id,
+ email: row.email,
+ name: row.name,
+ picture: row.picture,
+ // A row can only hold 'free' or 'pro' (CHECK constraint), but the column is
+ // still TEXT, so narrow rather than cast: an unreadable value must downgrade
+ // to the safe tier, never be trusted into `pro`.
+ tier: isTier(row.tier) ? row.tier : 'free',
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export class UserRepositoryD1 implements UserRepository {
+ constructor(private readonly db: D1Database) {}
+
+ async findById(id: string): Promise> {
+ const row = await this.db
+ .prepare('SELECT * FROM users WHERE id = ?')
+ .bind(id)
+ .first();
+ return row ? ok(toUser(row)) : err(USER_ERRORS.NOT_FOUND);
+ }
+
+ async upsertByIdentity(
+ identity: ProviderIdentity,
+ ): Promise> {
+ const existing = await this.db
+ .prepare(
+ `SELECT u.* FROM users u
+ JOIN identities i ON i.user_id = u.id
+ WHERE i.provider = ? AND i.provider_user_id = ?`,
+ )
+ .bind(identity.provider, identity.providerUserId)
+ .first();
+
+ if (existing) {
+ // Name and picture change on the provider's side; refreshing them here is
+ // what keeps a renamed account from showing its old name forever.
+ const updated = await this.db
+ .prepare(
+ `UPDATE users SET name = ?, picture = ?, updated_at = ?
+ WHERE id = ? RETURNING *`,
+ )
+ .bind(
+ identity.name ?? existing.name,
+ identity.picture ?? existing.picture,
+ new Date().toISOString(),
+ existing.id,
+ )
+ .first();
+ return ok({ user: toUser(updated ?? existing), isNew: false });
+ }
+
+ // Same person, second provider: attach the identity to the user the email
+ // already names instead of minting a duplicate they cannot merge later.
+ const byEmail = await this.db
+ .prepare('SELECT * FROM users WHERE email = ?')
+ .bind(identity.email)
+ .first();
+
+ const now = new Date().toISOString();
+
+ if (byEmail) {
+ await this.db
+ .prepare(
+ `INSERT INTO identities (provider, provider_user_id, user_id, created_at)
+ VALUES (?, ?, ?, ?)`,
+ )
+ .bind(identity.provider, identity.providerUserId, byEmail.id, now)
+ .run();
+ return ok({ user: toUser(byEmail), isNew: false });
+ }
+
+ const id = crypto.randomUUID();
+ // D1 batches run in an implicit transaction, so a user is never left
+ // without the identity that is the only way to reach it.
+ const [inserted] = await this.db.batch([
+ this.db
+ .prepare(
+ `INSERT INTO users (id, email, name, picture, tier, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'free', ?, ?) RETURNING *`,
+ )
+ .bind(
+ id,
+ identity.email,
+ identity.name ?? null,
+ identity.picture ?? null,
+ now,
+ now,
+ ),
+ this.db
+ .prepare(
+ `INSERT INTO identities (provider, provider_user_id, user_id, created_at)
+ VALUES (?, ?, ?, ?)`,
+ )
+ .bind(identity.provider, identity.providerUserId, id, now),
+ ]);
+
+ const row = inserted?.results[0];
+ return row
+ ? ok({ user: toUser(row), isNew: true })
+ : err(USER_ERRORS.EMAIL_TAKEN);
+ }
+
+ async setTier(id: string, tier: Tier): Promise> {
+ const row = await this.db
+ .prepare(
+ 'UPDATE users SET tier = ?, updated_at = ? WHERE id = ? RETURNING *',
+ )
+ .bind(tier, new Date().toISOString(), id)
+ .first();
+ return row ? ok(toUser(row)) : err(USER_ERRORS.NOT_FOUND);
+ }
+}
diff --git a/backend/src/repositories/inviteRepository.ts b/backend/src/repositories/inviteRepository.ts
new file mode 100644
index 0000000..9fb8755
--- /dev/null
+++ b/backend/src/repositories/inviteRepository.ts
@@ -0,0 +1,121 @@
+import type { InviteAnswer, InviteStatus } from '../../../shared/invites';
+import type { Result } from './result';
+
+export interface Invite {
+ id: string;
+ partyId: string;
+ name: string | null;
+ status: InviteStatus;
+ depth: number;
+ referrerId: string | null;
+ forwardToken: string;
+ /** The five characters on this guest's ticket. Unique within the party. */
+ ticketCode: string;
+ /** 'link' if they RSVPed themselves, 'manual' if an organiser typed them in. */
+ source: InviteSource;
+ checkedIn: boolean;
+ checkedInAt: string | null;
+ openedAt: string;
+ answeredAt: string | null;
+}
+
+/** Where an invite came from. Only 'link' counts towards "reached". */
+export type InviteSource = 'link' | 'manual';
+
+/** An invite plus the referrer's display name, which the host's funnel shows. */
+export interface InviteWithReferrer extends Invite {
+ referrerName: string | null;
+}
+
+export const INVITE_ERRORS = {
+ NOT_FOUND: 'invite_not_found',
+ /** The cap is reached; the caller may still decline, just not confirm. */
+ PARTY_FULL: 'party_full',
+ /** The drawn ticket code was taken. Caller redraws — see lib/ticketCodes.ts. */
+ CODE_TAKEN: 'ticket_code_taken',
+ /** Five redraws all collided, which is a fault rather than bad luck. */
+ CODE_UNAVAILABLE: 'ticket_code_unavailable',
+ /** Someone already walked in on this ticket. */
+ ALREADY_CHECKED_IN: 'already_checked_in',
+} as const;
+
+export interface InviteRepository {
+ /**
+ * Records that someone opened the link, at `referrerToken`'s depth + 1 (or 0
+ * for the host's own token). Returns the existing row when the caller already
+ * has one, so a reload or a second visit is the same guest, not a second one.
+ */
+ open(args: {
+ partyId: string;
+ referrerToken: string | null;
+ rootToken: string;
+ existingInviteId: string | null;
+ }): Promise>;
+
+ findById(id: string): Promise>;
+
+ /**
+ * Records an answer. Confirming is refused once the party is full, and the
+ * check has to be part of the write — two guests confirming the last place at
+ * once must not both succeed.
+ */
+ answer(args: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise>;
+
+ /**
+ * The host overriding a guest's state — "I spoke to her, she's coming".
+ *
+ * Unlike {@link answer} this ignores capacity: the host is the authority on
+ * their own door, and a cap they set themselves should not stop them letting
+ * one more person in.
+ */
+ setStatus(args: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise>;
+
+ /** The host's funnel, oldest first so client-side ids stay stable. */
+ listForParty(partyId: string): Promise>;
+
+ countConfirmed(partyId: string): Promise>;
+
+ /**
+ * A guest an organiser typed in, rather than one who RSVPed.
+ *
+ * On a shared party this has to be a server row like any other: it is what
+ * lets the co-organiser see them and the second phone on the door check their
+ * ticket. They arrive already confirmed — the organiser would not be typing
+ * them in otherwise — and at depth 0, since they came through nobody.
+ */
+ addManual(args: {
+ partyId: string;
+ name: string;
+ ticketCode: string;
+ }): Promise>;
+
+ /**
+ * Marks a guest as through the door.
+ *
+ * The server arbitrates rather than each phone deciding for itself: that is
+ * the whole difference between one scanner and several. A second scan returns
+ * ALREADY_CHECKED_IN along with the invite, so the door can say *when* they
+ * came in rather than only that they did.
+ */
+ checkIn(args: {
+ inviteId: string;
+ partyId: string;
+ at: string;
+ }): Promise>;
+
+ /** Undoes a check-in, for the guest who was waved through by mistake. */
+ undoCheckIn(args: {
+ inviteId: string;
+ partyId: string;
+ }): Promise>;
+}
diff --git a/backend/src/repositories/licenceRepository.ts b/backend/src/repositories/licenceRepository.ts
new file mode 100644
index 0000000..715436b
--- /dev/null
+++ b/backend/src/repositories/licenceRepository.ts
@@ -0,0 +1,28 @@
+import type { Tier } from '../../../shared/tiers';
+import type { Result } from './result';
+
+export interface LicenceKey {
+ code: string;
+ tier: Tier;
+ issuedAt: string;
+ redeemedAt: string | null;
+ redeemedBy: string | null;
+ note: string | null;
+}
+
+export const LICENCE_ERRORS = {
+ /** No such code. Deliberately indistinguishable from a used one to callers. */
+ UNKNOWN: 'licence_unknown',
+ ALREADY_REDEEMED: 'licence_already_redeemed',
+} as const;
+
+export interface LicenceRepository {
+ /**
+ * Claims the code for `userId` and returns the tier it grants.
+ *
+ * Must be atomic: two requests racing the same code may not both succeed.
+ * Redeeming a code the same user already redeemed succeeds idempotently, so a
+ * double-tapped button does not read as an error.
+ */
+ redeem(code: string, userId: string): Promise>;
+}
diff --git a/backend/src/repositories/memberRepository.ts b/backend/src/repositories/memberRepository.ts
new file mode 100644
index 0000000..fa3c168
--- /dev/null
+++ b/backend/src/repositories/memberRepository.ts
@@ -0,0 +1,59 @@
+import type { PartyRole } from '../../../shared/collab';
+import type { Result } from './result';
+
+export interface PartyMember {
+ partyId: string;
+ userId: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+ role: PartyRole;
+ addedAt: string;
+}
+
+export interface CollaboratorInvite {
+ token: string;
+ partyId: string;
+ createdBy: string;
+ createdAt: string;
+ revokedAt: string | null;
+}
+
+export const MEMBER_ERRORS = {
+ NOT_A_MEMBER: 'not_a_member',
+ NOT_FOUND: 'member_not_found',
+ /** The owner's own membership is not removable — see `remove`. */
+ CANNOT_REMOVE_OWNER: 'cannot_remove_owner',
+ INVITE_UNKNOWN: 'collaborator_invite_unknown',
+ INVITE_REVOKED: 'collaborator_invite_revoked',
+} as const;
+
+export interface MemberRepository {
+ /** The caller's role on a party, or `NOT_A_MEMBER`. The authorisation check. */
+ roleFor(partyId: string, userId: string): Promise>;
+
+ listMembers(partyId: string): Promise>;
+
+ /** Adding someone who is already on the party keeps the role they have. */
+ add(args: {
+ partyId: string;
+ userId: string;
+ role: PartyRole;
+ }): Promise>;
+
+ /**
+ * Removes a member. Refuses the owner: a party with no owner has nobody who
+ * can delete it or manage its people, and the row is what several queries
+ * key on.
+ */
+ remove(partyId: string, userId: string): Promise>;
+
+ createInvite(args: {
+ partyId: string;
+ createdBy: string;
+ }): Promise>;
+
+ findInvite(token: string): Promise>;
+
+ revokeInvitesFor(partyId: string): Promise>;
+}
diff --git a/backend/src/repositories/partyRepository.ts b/backend/src/repositories/partyRepository.ts
new file mode 100644
index 0000000..39636d0
--- /dev/null
+++ b/backend/src/repositories/partyRepository.ts
@@ -0,0 +1,105 @@
+import type { PartyDocument, PartyRole } from '../../../shared/collab';
+import type { MergePatch } from '../../../shared/patch';
+import type { PublishPartyRequest } from '../../../shared/invites';
+import type { Result } from './result';
+
+export interface PublishedParty {
+ id: string;
+ ownerId: string;
+ localId: number;
+ slug: string;
+ name: string;
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ allowForward: boolean;
+ maxCapacity: number | null;
+ rootToken: string;
+ publishedAt: string;
+ updatedAt: string;
+ /** The planning half. Null on a party published before it was stored. */
+ document: PartyDocument | null;
+ version: number;
+ /**
+ * Whether guests may RSVP. Storing a party so a co-organiser can open it is
+ * not the same as opening it to the world, so this is set by the invite link
+ * and nothing else.
+ */
+ invitesOpen: boolean;
+ /**
+ * The party's ticket-signing key, as a JWK. Generated server-side when the
+ * party is first stored; every member verifies with the same one, which is
+ * what lets a second phone check a ticket the first phone issued.
+ */
+ ticketKey: JsonWebKey | null;
+}
+
+/** A party as it appears in somebody's list, with their role on it. */
+export interface PartySummary {
+ id: string;
+ name: string;
+ date: string;
+ cover: number;
+ role: PartyRole;
+ version: number;
+ updatedAt: string;
+ memberCount: number;
+}
+
+export const PARTY_ERRORS = {
+ NOT_FOUND: 'party_not_found',
+ NOT_OWNER: 'party_not_owner',
+ /** The party predates document storage and has nothing to patch yet. */
+ NO_DOCUMENT: 'party_has_no_document',
+} as const;
+
+export interface PartyRepository {
+ /**
+ * Publishes or republishes. Keyed on (owner, localId), so a host editing a
+ * party and sharing again updates the same row — and keeps the same slug, or
+ * every link they already sent would break.
+ */
+ publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise>;
+ findBySlug(slug: string): Promise>;
+
+ findById(id: string): Promise>;
+
+ /** Every party this user can open, owned or shared with them. */
+ listForUser(userId: string): Promise>;
+
+ /**
+ * Applies a merge patch to the stored document and bumps the version.
+ *
+ * The read, the merge and the write are one statement, because two
+ * co-organisers saving at the same moment must not both read the same
+ * document and write back over each other — that is precisely the loss
+ * patching exists to prevent.
+ */
+ patchDocument(args: {
+ partyId: string;
+ patch: MergePatch;
+ }): Promise<
+ Result<{ document: PartyDocument; version: number; updatedAt: string }>
+ >;
+
+ /** Writes the whole document, for a party being published the first time. */
+ putDocument(args: {
+ partyId: string;
+ document: PartyDocument;
+ }): Promise>;
+ findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise>;
+ /** Opens or closes the guest-facing invite link without touching the party. */
+ setInvitesOpen(id: string, open: boolean): Promise>;
+
+ /** Removes the party outright; members and invites cascade with it. */
+ deleteById(id: string): Promise>;
+
+ /** Unpublishing deletes the row; its invites cascade with it. */
+ unpublish(ownerId: string, id: string): Promise>;
+}
diff --git a/backend/src/repositories/repositories.ts b/backend/src/repositories/repositories.ts
new file mode 100644
index 0000000..2625e5c
--- /dev/null
+++ b/backend/src/repositories/repositories.ts
@@ -0,0 +1,14 @@
+import type { InviteRepository } from './inviteRepository';
+import type { LicenceRepository } from './licenceRepository';
+import type { MemberRepository } from './memberRepository';
+import type { PartyRepository } from './partyRepository';
+import type { UserRepository } from './userRepository';
+
+/** Everything a route can reach storage through. */
+export interface Repositories {
+ users: UserRepository;
+ licences: LicenceRepository;
+ parties: PartyRepository;
+ invites: InviteRepository;
+ members: MemberRepository;
+}
diff --git a/backend/src/repositories/result.ts b/backend/src/repositories/result.ts
new file mode 100644
index 0000000..1b78b06
--- /dev/null
+++ b/backend/src/repositories/result.ts
@@ -0,0 +1,18 @@
+/**
+ * A result that carries its failure instead of throwing it.
+ *
+ * Repositories return these because the difference between "no such user" and
+ * "D1 is unreachable" decides an HTTP status, and an exception flattens both
+ * into a 500 unless every caller remembers to inspect it.
+ */
+export type Result =
+ | { ok: true; value: T }
+ | { ok: false; error: E };
+
+export function ok(value: T): Result {
+ return { ok: true, value };
+}
+
+export function err(error: E): Result {
+ return { ok: false, error };
+}
diff --git a/backend/src/repositories/userRepository.ts b/backend/src/repositories/userRepository.ts
new file mode 100644
index 0000000..34c3f2f
--- /dev/null
+++ b/backend/src/repositories/userRepository.ts
@@ -0,0 +1,39 @@
+import type { Tier } from '../../../shared/tiers';
+import type { Result } from './result';
+
+export interface User {
+ id: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+ tier: Tier;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Identity-provider account details handed over by a completed sign-in. */
+export interface ProviderIdentity {
+ provider: 'google' | 'dev';
+ providerUserId: string;
+ email: string;
+ name?: string | null;
+ picture?: string | null;
+}
+
+export const USER_ERRORS = {
+ NOT_FOUND: 'user_not_found',
+ EMAIL_TAKEN: 'email_taken',
+} as const;
+
+export interface UserRepository {
+ findById(id: string): Promise>;
+ /**
+ * Resolves the provider account to a user, creating one the first time.
+ * `isNew` is what tells the frontend to run its onboarding, so it reports the
+ * user's creation, not the identity's.
+ */
+ upsertByIdentity(
+ identity: ProviderIdentity,
+ ): Promise>;
+ setTier(id: string, tier: Tier): Promise>;
+}
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
new file mode 100644
index 0000000..cde71b3
--- /dev/null
+++ b/backend/src/routes/auth.ts
@@ -0,0 +1,82 @@
+import { Hono } from 'hono';
+import { googleAuth } from '@hono/oauth-providers/google';
+import { deleteCookie } from 'hono/cookie';
+import type { AppVariables } from '../appEnv';
+import { resolveFrontendUrl } from './frontendUrl';
+import { issueSession } from './issueSession';
+
+type Bindings = {
+ GOOGLE_CLIENT_ID: string;
+ GOOGLE_CLIENT_SECRET: string;
+ JWT_SECRET: string;
+ FRONTEND_URL: string;
+};
+
+const auth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+auth.use('/google', async (c, next) => {
+ if (!c.env.GOOGLE_CLIENT_ID) {
+ return c.json({ error: 'Missing GOOGLE_CLIENT_ID' }, 500);
+ }
+ if (!c.env.GOOGLE_CLIENT_SECRET) {
+ return c.json({ error: 'Missing GOOGLE_CLIENT_SECRET' }, 500);
+ }
+ const handler = googleAuth({
+ client_id: c.env.GOOGLE_CLIENT_ID,
+ client_secret: c.env.GOOGLE_CLIENT_SECRET,
+ scope: ['openid', 'email', 'profile'],
+ // The redirect target is the *frontend* origin: the Pages Function at
+ // functions/auth/google.ts proxies it straight back here, which is what
+ // keeps the session cookie first-party.
+ redirect_uri: `${resolveFrontendUrl(c.env)}/auth/google`,
+ });
+ return handler(c, next);
+});
+
+auth.get('/google', async (c) => {
+ const frontendUrl = resolveFrontendUrl(c.env);
+ const oauthToken = c.get('token');
+ const user = c.get('user-google');
+
+ if (!oauthToken || !user?.id || !user.email) {
+ return c.redirect(`${frontendUrl}/app?error=auth_failed`);
+ }
+
+ if (!c.env.JWT_SECRET) {
+ return c.json({ error: 'Missing JWT_SECRET' }, 500);
+ }
+
+ const result = await c.var.repositories.users.upsertByIdentity({
+ provider: 'google',
+ providerUserId: user.id,
+ email: user.email,
+ name: user.name ?? null,
+ picture: user.picture ?? null,
+ });
+
+ if (!result.ok) {
+ console.error('Google sign-in failed to resolve a user:', result.error);
+ return c.redirect(`${frontendUrl}/app?error=account_failed`);
+ }
+
+ const { user: account, isNew } = result.value;
+ await issueSession(c, {
+ sub: account.id,
+ email: account.email,
+ name: account.name,
+ picture: account.picture,
+ });
+
+ return c.redirect(`${frontendUrl}/auth/callback${isNew ? '?new=1' : ''}`);
+});
+
+/**
+ * Signing out is a route rather than a client-side cookie delete because the
+ * cookie is httpOnly — the page that set it cannot clear it.
+ */
+auth.post('/logout', (c) => {
+ deleteCookie(c, 'session_token', { path: '/' });
+ return c.json({ ok: true });
+});
+
+export default auth;
diff --git a/backend/src/routes/collaborate.ts b/backend/src/routes/collaborate.ts
new file mode 100644
index 0000000..4687585
--- /dev/null
+++ b/backend/src/routes/collaborate.ts
@@ -0,0 +1,99 @@
+import { Hono } from 'hono';
+import type { JwtVariables } from 'hono/jwt';
+import type { CollaboratorPreviewDTO } from '../../../shared/collab';
+import { featuresFor, resolveTier } from '../../../shared/tiers';
+import type { AppVariables } from '../appEnv';
+import { MEMBER_ERRORS } from '../repositories/memberRepository';
+
+type Bindings = {
+ SELF_HOSTED?: string;
+};
+
+const collaborate = new Hono<{
+ Bindings: Bindings;
+ Variables: AppVariables & JwtVariables;
+}>();
+
+/**
+ * Accepting a co-organiser invitation.
+ *
+ * Unlike a guest invite, this one needs an account: a co-organiser edits the
+ * party, so there has to be someone to attribute the edit to and someone the
+ * owner can later remove.
+ *
+ * It does **not** require the invitee to be on the paid tier. The party is
+ * somebody else's, already paid for, and charging both people to run one party
+ * would make the feature useless — you cannot co-organise alone. What a free
+ * co-organiser does not get is parties of their own.
+ */
+collaborate.use('*', async (c, next) => {
+ const sub = c.get('jwtPayload')?.sub;
+ if (typeof sub !== 'string') return c.json({ error: 'unauthenticated' }, 401);
+ c.set('userId', sub);
+ return next();
+});
+
+/** `GET /api/collaborate/:token` — what am I being asked to join? */
+collaborate.get('/:token', async (c) => {
+ const userId = c.get('userId') as string;
+ const invite = await c.var.repositories.members.findInvite(
+ c.req.param('token'),
+ );
+ if (!invite.ok) {
+ // A revoked link and an unknown one answer alike: which it is tells a
+ // stranger whether they guessed a real token.
+ return c.json({ error: 'collaborator_invite_unknown' }, 404);
+ }
+
+ const party = await c.var.repositories.parties.findById(invite.value.partyId);
+ if (!party.ok) return c.json({ error: 'collaborator_invite_unknown' }, 404);
+
+ const inviter = await c.var.repositories.users.findById(
+ invite.value.createdBy,
+ );
+ const existing = await c.var.repositories.members.roleFor(
+ party.value.id,
+ userId,
+ );
+
+ const preview: CollaboratorPreviewDTO = {
+ partyName: party.value.name,
+ date: party.value.date,
+ cover: party.value.cover,
+ invitedBy: inviter.ok
+ ? (inviter.value.name ?? inviter.value.email)
+ : 'the host',
+ alreadyMember: existing.ok,
+ };
+ return c.json(preview);
+});
+
+/** `POST /api/collaborate/:token` — join the party as an editor. */
+collaborate.post('/:token', async (c) => {
+ const userId = c.get('userId') as string;
+ const invite = await c.var.repositories.members.findInvite(
+ c.req.param('token'),
+ );
+ if (!invite.ok) {
+ return c.json({ error: 'collaborator_invite_unknown' }, 404);
+ }
+
+ const party = await c.var.repositories.parties.findById(invite.value.partyId);
+ if (!party.ok) return c.json({ error: 'collaborator_invite_unknown' }, 404);
+
+ // Adding is idempotent and never demotes, so an owner opening their own link
+ // stays the owner and a second visit is not an error.
+ const added = await c.var.repositories.members.add({
+ partyId: party.value.id,
+ userId,
+ role: 'editor',
+ });
+ if (!added.ok) {
+ const status = added.error === MEMBER_ERRORS.NOT_FOUND ? 404 : 500;
+ return c.json({ error: added.error }, status);
+ }
+
+ return c.json({ partyId: party.value.id, role: added.value.role });
+});
+
+export default collaborate;
diff --git a/backend/src/routes/devAuth.ts b/backend/src/routes/devAuth.ts
new file mode 100644
index 0000000..90d20ec
--- /dev/null
+++ b/backend/src/routes/devAuth.ts
@@ -0,0 +1,59 @@
+import { Hono } from 'hono';
+import type { AppVariables } from '../appEnv';
+import { issueSession } from './issueSession';
+
+type Bindings = {
+ JWT_SECRET: string;
+ FRONTEND_URL: string;
+ ENVIRONMENT: string;
+ SELF_HOSTED?: string;
+};
+
+const devAuth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+/**
+ * Sign in without Google.
+ *
+ * Two audiences: local development, and self-hosters. Registering a Google
+ * OAuth client is a real chunk of setup to demand of someone whose whole
+ * reason for self-hosting may be that they wanted nothing to do with Google,
+ * and a self-hosted Worker is single-tenant by definition — whoever can reach
+ * it is already the owner.
+ *
+ * It is refused everywhere else. The check is on a binding, not on a request
+ * header, so no caller can talk their way into it; on the hosted deployment
+ * `SELF_HOSTED` is unset and `ENVIRONMENT` is "production", and this returns
+ * 404 as though the route did not exist.
+ */
+devAuth.post('/dev', async (c) => {
+ const enabled = c.env.SELF_HOSTED === 'true' || c.env.ENVIRONMENT === 'local';
+ if (!enabled) return c.notFound();
+
+ const body = await c.req
+ .json<{ email?: string; name?: string }>()
+ .catch(() => ({}) as { email?: string; name?: string });
+ const email = body.email?.trim();
+ if (!email) return c.json({ error: 'email is required' }, 400);
+
+ const result = await c.var.repositories.users.upsertByIdentity({
+ provider: 'dev',
+ providerUserId: email,
+ email,
+ name: body.name ?? email.split('@')[0] ?? null,
+ picture: null,
+ });
+
+ if (!result.ok) return c.json({ error: result.error }, 500);
+
+ const { user, isNew } = result.value;
+ await issueSession(c, {
+ sub: user.id,
+ email: user.email,
+ name: user.name,
+ picture: user.picture,
+ });
+
+ return c.json({ ok: true, isNew });
+});
+
+export default devAuth;
diff --git a/backend/src/routes/frontendUrl.ts b/backend/src/routes/frontendUrl.ts
new file mode 100644
index 0000000..9c09c7c
--- /dev/null
+++ b/backend/src/routes/frontendUrl.ts
@@ -0,0 +1,13 @@
+/**
+ * Takes only the field it reads, so anything holding a `FRONTEND_URL` can ask —
+ * the dev sign-in route has no OAuth client to speak of.
+ */
+export function resolveFrontendUrl(env: { FRONTEND_URL?: string }): string {
+ let url = env.FRONTEND_URL ?? 'localhost:4321';
+
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ const isLocal = url.startsWith('localhost') || url.startsWith('127.');
+ url = (isLocal ? 'http://' : 'https://') + url;
+ }
+ return url.replace(/\/$/, '');
+}
diff --git a/backend/src/routes/helpers.ts b/backend/src/routes/helpers.ts
new file mode 100644
index 0000000..ee47c2f
--- /dev/null
+++ b/backend/src/routes/helpers.ts
@@ -0,0 +1,9 @@
+import { USER_ERRORS } from '../repositories/userRepository';
+
+/**
+ * A session whose user genuinely doesn't exist is a 404; anything else (a D1
+ * outage, say) is ours and must not be dressed up as a missing user.
+ */
+export function userErrorStatus(error: string): 404 | 500 {
+ return error === USER_ERRORS.NOT_FOUND ? 404 : 500;
+}
diff --git a/backend/src/routes/invites.ts b/backend/src/routes/invites.ts
new file mode 100644
index 0000000..a07cea4
--- /dev/null
+++ b/backend/src/routes/invites.ts
@@ -0,0 +1,185 @@
+import { Hono } from 'hono';
+import type {
+ InviteOpenDTO,
+ InviteOpenRequest,
+ InvitePartyDTO,
+} from '../../../shared/invites';
+import { isInviteAnswer } from '../../../shared/invites';
+import type { AppVariables } from '../appEnv';
+import { INVITE_ERRORS } from '../repositories/inviteRepository';
+import type { Invite } from '../repositories/inviteRepository';
+import type { PublishedParty } from '../repositories/partyRepository';
+
+type RateLimiter = { limit(o: { key: string }): Promise<{ success: boolean }> };
+
+type Bindings = {
+ // Optional: the local environment leaves it unbound so a fresh clone runs
+ // without a Cloudflare account. Absent means unlimited, which is correct for
+ // a machine only you can reach.
+ INVITE_RATE_LIMITER?: RateLimiter;
+};
+
+const invites = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+/**
+ * Public, unauthenticated, and mounted outside the `/api/*` guard.
+ *
+ * A guest has no account by design — being able to RSVP without signing up is
+ * most of the value of an invite link. So the URL is the only credential, and
+ * these handlers must never return anything the link holder should not see:
+ * no other guests' names, no owner identity, no budget.
+ */
+
+function toPartyDTO(party: PublishedParty, full: boolean): InvitePartyDTO {
+ return {
+ slug: party.slug,
+ name: party.name,
+ date: party.date,
+ cover: party.cover,
+ venue: party.venue,
+ allowForward: party.allowForward,
+ full,
+ };
+}
+
+/**
+ * A guest's own forward link exists only once they have confirmed and only if
+ * the host allows forwarding. Handing it out at `opened` would let someone who
+ * never replied seed a referral tree.
+ */
+function forwardTokenFor(invite: Invite, party: PublishedParty): string | null {
+ if (!party.allowForward) return null;
+ return invite.status === 'confirmed' ? invite.forwardToken : null;
+}
+
+async function isFull(
+ repositories: AppVariables['repositories'],
+ party: PublishedParty,
+): Promise {
+ if (party.maxCapacity === null) return false;
+ const counted = await repositories.invites.countConfirmed(party.id);
+ return counted.ok && counted.value >= party.maxCapacity;
+}
+
+/** Guards the two write paths. Keyed on IP, since there is no account to key on. */
+async function rateLimited(
+ limiter: RateLimiter | undefined,
+ c: { req: { header(name: string): string | undefined } },
+): Promise {
+ if (!limiter) return false;
+ const key = c.req.header('cf-connecting-ip') ?? 'unknown';
+ const { success } = await limiter.limit({ key });
+ return !success;
+}
+
+/**
+ * `POST /invite/:slug/open` — someone opened the link.
+ *
+ * A POST rather than a GET because it writes: this is the row that makes
+ * "reached" a real number. The client sends back the `inviteId` it was given
+ * last time, so a reload, a second device-less visit or a guest returning to
+ * change their mind is the same person rather than a new one.
+ */
+invites.post('/:slug/open', async (c) => {
+ if (await rateLimited(c.env.INVITE_RATE_LIMITER, c)) {
+ return c.json({ error: 'rate_limited' }, 429);
+ }
+
+ const found = await c.var.repositories.parties.findBySlug(
+ c.req.param('slug'),
+ );
+ // A party that does not exist, one whose link the owner closed, and one
+ // stored only so a co-organiser could open it all answer alike: there is
+ // nothing useful to tell a link holder apart from "this is not a party", and
+ // distinguishing them would leak that a slug is real.
+ if (!found.ok || !found.value.invitesOpen) {
+ return c.json({ error: 'party_not_found' }, 404);
+ }
+ const party = found.value;
+
+ const body = await c.req
+ .json()
+ .catch(() => ({}) as InviteOpenRequest);
+
+ const opened = await c.var.repositories.invites.open({
+ partyId: party.id,
+ referrerToken: body.referrer ?? null,
+ rootToken: party.rootToken,
+ existingInviteId: body.inviteId ?? null,
+ });
+ if (!opened.ok) return c.json({ error: opened.error }, 500);
+
+ const invite = opened.value;
+ const dto: InviteOpenDTO = {
+ party: toPartyDTO(party, await isFull(c.var.repositories, party)),
+ inviteId: invite.id,
+ forwardToken: forwardTokenFor(invite, party),
+ status: invite.status,
+ name: invite.name,
+ depth: invite.depth,
+ };
+ return c.json(dto);
+});
+
+/**
+ * `POST /invite/:slug/answer` — yes or no.
+ *
+ * Answering again overwrites: someone who said maybe-then-no, or who mistyped
+ * their name, should not need a second row, and a second row would inflate the
+ * funnel's "reached" count with people who were only ever one guest.
+ */
+invites.post('/:slug/answer', async (c) => {
+ if (await rateLimited(c.env.INVITE_RATE_LIMITER, c)) {
+ return c.json({ error: 'rate_limited' }, 429);
+ }
+
+ const found = await c.var.repositories.parties.findBySlug(
+ c.req.param('slug'),
+ );
+ if (!found.ok || !found.value.invitesOpen) {
+ return c.json({ error: 'party_not_found' }, 404);
+ }
+ const party = found.value;
+
+ const body = await c.req.json().catch(() => null);
+ if (typeof body !== 'object' || body === null) {
+ return c.json({ error: 'invalid_answer' }, 400);
+ }
+ const { inviteId, name, answer } = body as Record;
+
+ if (typeof inviteId !== 'string' || !isInviteAnswer(answer)) {
+ return c.json({ error: 'invalid_answer' }, 400);
+ }
+ const trimmed = typeof name === 'string' ? name.trim().slice(0, 60) : '';
+ if (trimmed === '') return c.json({ error: 'name_required' }, 400);
+
+ const answered = await c.var.repositories.invites.answer({
+ inviteId,
+ partyId: party.id,
+ name: trimmed,
+ answer,
+ maxCapacity: party.maxCapacity,
+ });
+
+ if (!answered.ok) {
+ if (answered.error === INVITE_ERRORS.PARTY_FULL) {
+ return c.json({ error: answered.error }, 409);
+ }
+ // A stale inviteId (the host unpublished and republished, say) is a 404, so
+ // the client knows to open again rather than retrying an answer forever.
+ return c.json({ error: answered.error }, 404);
+ }
+
+ const invite = answered.value;
+ const dto: InviteOpenDTO = {
+ party: toPartyDTO(party, await isFull(c.var.repositories, party)),
+ inviteId: invite.id,
+ forwardToken: forwardTokenFor(invite, party),
+ status: invite.status,
+ name: invite.name,
+ depth: invite.depth,
+ };
+ return c.json(dto);
+});
+
+export default invites;
diff --git a/backend/src/routes/issueSession.ts b/backend/src/routes/issueSession.ts
new file mode 100644
index 0000000..f3621ee
--- /dev/null
+++ b/backend/src/routes/issueSession.ts
@@ -0,0 +1,51 @@
+import type { Context, Env } from 'hono';
+import { setCookie } from 'hono/cookie';
+import { sign } from 'hono/jwt';
+import type { JWTPayload } from 'hono/utils/jwt/types';
+import { resolveFrontendUrl } from './frontendUrl';
+
+const SESSION_DAYS = 7;
+
+/** Who the session says the caller is. `sub` is our user id, not Google's. */
+export interface SessionClaims {
+ sub: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+}
+
+type SessionEnv = Env & {
+ Bindings: { JWT_SECRET: string; FRONTEND_URL: string };
+};
+
+/**
+ * Signs a session and sets it as the `session_token` cookie.
+ *
+ * Both sign-in routes go through here, so a feature downstream never has to
+ * know which door a user came through — and the two cannot drift apart on
+ * `secure` or on the expiry, which is the kind of difference nothing fails on
+ * until it locks someone out.
+ *
+ * The Pages Function proxy serves the frontend and this Worker on one origin,
+ * so the cookie is first-party and SameSite=Lax suffices. `secure` mirrors the
+ * frontend's scheme so the cookie also works against http://localhost.
+ */
+export async function issueSession(
+ c: Context,
+ claims: SessionClaims,
+): Promise {
+ const payload: JWTPayload = {
+ ...claims,
+ exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * SESSION_DAYS,
+ };
+
+ const token = await sign(payload, c.env.JWT_SECRET, 'HS256');
+
+ setCookie(c, 'session_token', token, {
+ httpOnly: true,
+ secure: resolveFrontendUrl(c.env).startsWith('https://'),
+ sameSite: 'Lax',
+ path: '/',
+ maxAge: 60 * 60 * 24 * SESSION_DAYS,
+ });
+}
diff --git a/backend/src/routes/licences.ts b/backend/src/routes/licences.ts
new file mode 100644
index 0000000..730f9bc
--- /dev/null
+++ b/backend/src/routes/licences.ts
@@ -0,0 +1,58 @@
+import { Hono } from 'hono';
+import type { JwtVariables } from 'hono/jwt';
+import { featuresFor, resolveTier } from '../../../shared/tiers';
+import type { AppVariables } from '../appEnv';
+import { LICENCE_ERRORS } from '../repositories/licenceRepository';
+import { userErrorStatus } from './helpers';
+
+type Bindings = {
+ SELF_HOSTED?: string;
+};
+
+const licences = new Hono<{
+ Bindings: Bindings;
+ Variables: AppVariables & JwtVariables;
+}>();
+
+/**
+ * `POST /api/licences/redeem` — turn a purchased code into `pro`.
+ *
+ * This is the whole upgrade path for now. No checkout provider is wired yet
+ * (ADR 0001), so codes are minted by hand with `npm run licence:issue`; when
+ * one is chosen, its webhook inserts rows into the same table and nothing here
+ * changes.
+ */
+licences.post('/redeem', async (c) => {
+ const sub = c.get('jwtPayload')?.sub;
+ if (typeof sub !== 'string') return c.json({ error: 'unauthenticated' }, 401);
+
+ const body = await c.req
+ .json<{ code?: string }>()
+ .catch(() => ({}) as { code?: string });
+ const code = body.code?.trim();
+ if (!code) return c.json({ error: 'code is required' }, 400);
+
+ const redeemed = await c.var.repositories.licences.redeem(code, sub);
+ if (!redeemed.ok) {
+ // An unknown code and a spent one answer alike: telling them apart lets
+ // someone probe the keyspace for codes that merely belong to somebody else.
+ const status = redeemed.error === LICENCE_ERRORS.UNKNOWN ? 404 : 409;
+ return c.json({ error: redeemed.error }, status);
+ }
+
+ const updated = await c.var.repositories.users.setTier(
+ sub,
+ redeemed.value.tier,
+ );
+ if (!updated.ok) {
+ return c.json({ error: updated.error }, userErrorStatus(updated.error));
+ }
+
+ const tier = resolveTier({
+ storedTier: updated.value.tier,
+ selfHosted: c.env.SELF_HOSTED === 'true',
+ });
+ return c.json({ tier, features: featuresFor(tier) });
+});
+
+export default licences;
diff --git a/backend/src/routes/parties.ts b/backend/src/routes/parties.ts
new file mode 100644
index 0000000..934d804
--- /dev/null
+++ b/backend/src/routes/parties.ts
@@ -0,0 +1,473 @@
+import { Hono } from 'hono';
+import type { Context } from 'hono';
+import type { JwtVariables } from 'hono/jwt';
+import type {
+ CollaboratorInviteDTO,
+ PartyDocument,
+ PartyMemberDTO,
+ PartyRole,
+ PartySummaryDTO,
+ PatchPartyResponse,
+ SharedPartyDTO,
+} from '../../../shared/collab';
+import type { HostInviteDTO, PublishedPartyDTO } from '../../../shared/invites';
+import { INVITE_STATUSES } from '../../../shared/invites';
+import type { InviteStatus } from '../../../shared/invites';
+import { featuresFor, resolveTier } from '../../../shared/tiers';
+import type { AppVariables } from '../appEnv';
+import { INVITE_ERRORS } from '../repositories/inviteRepository';
+import type {
+ Invite,
+ InviteWithReferrer,
+} from '../repositories/inviteRepository';
+import { MEMBER_ERRORS } from '../repositories/memberRepository';
+import type { PartyMember } from '../repositories/memberRepository';
+import { PARTY_ERRORS } from '../repositories/partyRepository';
+import type { PublishedParty } from '../repositories/partyRepository';
+import { parseDocument, parsePatch } from './partyInput';
+
+type Bindings = {
+ SELF_HOSTED?: string;
+};
+
+type Env = {
+ Bindings: Bindings;
+ Variables: AppVariables &
+ JwtVariables & { role: PartyRole; party: PublishedParty };
+};
+
+const parties = new Hono();
+
+function toPublishedDTO(party: PublishedParty): PublishedPartyDTO {
+ return {
+ id: party.id,
+ slug: party.slug,
+ rootToken: party.rootToken,
+ publishedAt: party.publishedAt,
+ allowForward: party.allowForward,
+ };
+}
+
+function toInviteDTO(
+ invite: InviteWithReferrer | (Invite & { referrerName: string | null }),
+): HostInviteDTO {
+ return {
+ id: invite.id,
+ name: invite.name,
+ status: invite.status,
+ depth: invite.depth,
+ referrer: invite.referrerName,
+ forwardToken: invite.forwardToken,
+ ticketCode: invite.ticketCode,
+ source: invite.source,
+ checkedIn: invite.checkedIn,
+ checkedInAt: invite.checkedInAt,
+ openedAt: invite.openedAt,
+ answeredAt: invite.answeredAt,
+ };
+}
+
+function toMemberDTO(member: PartyMember): PartyMemberDTO {
+ return {
+ userId: member.userId,
+ email: member.email,
+ name: member.name,
+ picture: member.picture,
+ role: member.role,
+ addedAt: member.addedAt,
+ };
+}
+
+/** Everything here needs a session. What it does not all need is a tier. */
+parties.use('*', async (c, next) => {
+ const sub = c.get('jwtPayload')?.sub;
+ if (typeof sub !== 'string') return c.json({ error: 'unauthenticated' }, 401);
+ c.set('userId', sub);
+ return next();
+});
+
+/**
+ * Whether the caller may keep parties of their own on the server.
+ *
+ * This gates creating a cloud party, and nothing else. Opening, editing and
+ * running a party you were invited to is deliberately outside it: that party
+ * belongs to someone who has already paid, and charging both people to run one
+ * party would make co-organising useless, since you cannot co-organise alone.
+ * Membership is the authorisation everywhere else.
+ *
+ * The tier comes from the user's row rather than the JWT: a session lives seven
+ * days, and a claim baked into one would keep granting `pro` for a week after a
+ * refund.
+ */
+async function mayOwnCloudParties(c: Context): Promise {
+ const found = await c.var.repositories.users.findById(
+ c.get('userId') as string,
+ );
+ if (!found.ok) return false;
+
+ const tier = resolveTier({
+ storedTier: found.value.tier,
+ selfHosted: c.env.SELF_HOSTED === 'true',
+ });
+ return featuresFor(tier).cloudSync;
+}
+
+/**
+ * Resolves `:partyId` and the caller's role on it.
+ *
+ * Membership *is* the authorisation: a party the caller is not a member of
+ * answers 404, not 403, so a party id cannot be probed for existence. That is
+ * also why every party route is keyed on the server's id rather than the
+ * owner's local one — a co-organiser has their own local numbering, and it
+ * means nothing here.
+ */
+async function loadParty(c: Context): Promise {
+ const userId = c.get('userId') as string;
+ const partyId = c.req.param('partyId');
+ if (!partyId) return c.json({ error: 'party_not_found' }, 404);
+
+ const role = await c.var.repositories.members.roleFor(partyId, userId);
+ if (!role.ok) return c.json({ error: 'party_not_found' }, 404);
+
+ const party = await c.var.repositories.parties.findById(partyId);
+ if (!party.ok) return c.json({ error: 'party_not_found' }, 404);
+
+ c.set('role', role.value);
+ c.set('party', party.value);
+ return null;
+}
+
+parties.use('/:partyId/*', async (c, next) => {
+ const refused = await loadParty(c);
+ return refused ?? next();
+});
+parties.use('/:partyId', async (c, next) => {
+ const refused = await loadParty(c);
+ return refused ?? next();
+});
+
+/** Actions that take the party away from everyone else stay with the owner. */
+function ownerOnly(c: Context): boolean {
+ return c.get('role') === 'owner';
+}
+
+async function sharedPartyDTO(
+ c: Context,
+ party: PublishedParty,
+ document: PartyDocument,
+ role: PartyRole,
+): Promise {
+ const members = await c.var.repositories.members.listMembers(party.id);
+ return {
+ id: party.id,
+ document,
+ version: party.version,
+ role,
+ members: members.ok ? members.value.map(toMemberDTO) : [],
+ updatedAt: party.updatedAt,
+ // Only present while the link is open, so a collaborator cannot hand out a
+ // link the owner has closed.
+ publication: party.invitesOpen
+ ? { slug: party.slug, rootToken: party.rootToken }
+ : null,
+ ticketKey: party.ticketKey,
+ };
+}
+
+// ── The party itself ────────────────────────────────────────────────────────
+
+/** `GET /api/parties` — everything the caller can open, owned or shared. */
+parties.get('/', async (c) => {
+ const userId = c.get('userId') as string;
+ const listed = await c.var.repositories.parties.listForUser(userId);
+ if (!listed.ok) return c.json({ error: listed.error }, 500);
+
+ const summaries: PartySummaryDTO[] = listed.value.map((row) => ({
+ id: row.id,
+ name: row.name,
+ date: row.date,
+ cover: row.cover,
+ role: row.role,
+ version: row.version,
+ updatedAt: row.updatedAt,
+ memberCount: row.memberCount,
+ }));
+ return c.json({ parties: summaries });
+});
+
+/**
+ * `POST /api/parties` — put a party on the server, or save it again.
+ *
+ * Idempotent on (owner, localId). Deliberately does *not* open the invite link:
+ * storing a party so a co-organiser can open it is not the same act as opening
+ * it to the world.
+ */
+parties.post('/', async (c) => {
+ const userId = c.get('userId') as string;
+ if (!(await mayOwnCloudParties(c))) {
+ return c.json({ error: 'upgrade_required', feature: 'cloudSync' }, 403);
+ }
+
+ const body = await c.req.json().catch(() => null);
+ const parsed = parseDocument(body);
+ if (!parsed) return c.json({ error: 'invalid_party' }, 400);
+
+ const published = await c.var.repositories.parties.publish(userId, parsed);
+ if (!published.ok) return c.json({ error: published.error }, 500);
+
+ const party = published.value;
+ return c.json(await sharedPartyDTO(c, party, parsed.document, 'owner'));
+});
+
+/** `GET /api/parties/:partyId` — the document a co-organiser opens. */
+parties.get('/:partyId', async (c) => {
+ const party = c.get('party');
+ if (!party.document) return c.json({ error: PARTY_ERRORS.NO_DOCUMENT }, 409);
+ return c.json(await sharedPartyDTO(c, party, party.document, c.get('role')));
+});
+
+/**
+ * `PATCH /api/parties/:partyId` — one organiser's edit.
+ *
+ * A patch rather than the whole party, so two people editing different parts of
+ * it do not overwrite each other (`shared/patch.ts`). `baseVersion` is reported
+ * on, not enforced: rejecting a stale write would lose an edit that merges
+ * perfectly well, so instead a client that had fallen behind gets the merged
+ * document back and catches up.
+ */
+parties.patch('/:partyId', async (c) => {
+ const party = c.get('party');
+ const body = await c.req.json().catch(() => null);
+ const parsed = parsePatch(body);
+ if (!parsed) return c.json({ error: 'invalid_patch' }, 400);
+
+ const applied = await c.var.repositories.parties.patchDocument({
+ partyId: party.id,
+ patch: parsed.patch,
+ });
+ if (!applied.ok) {
+ const status = applied.error === PARTY_ERRORS.NOT_FOUND ? 404 : 409;
+ return c.json({ error: applied.error }, status);
+ }
+
+ const wasCurrent = parsed.baseVersion === party.version;
+ const response: PatchPartyResponse = {
+ version: applied.value.version,
+ document: wasCurrent ? null : applied.value.document,
+ updatedAt: applied.value.updatedAt,
+ };
+ return c.json(response);
+});
+
+/** `DELETE /api/parties/:partyId` — owner only; members and invites cascade. */
+parties.delete('/:partyId', async (c) => {
+ if (!ownerOnly(c)) return c.json({ error: 'owner_only' }, 403);
+
+ const removed = await c.var.repositories.parties.deleteById(
+ c.get('party').id,
+ );
+ // Already gone is the state the caller wanted.
+ return removed.ok ? c.json({ ok: true }) : c.json({ ok: true });
+});
+
+// ── The guest-facing invite link ────────────────────────────────────────────
+
+/** `POST /api/parties/:partyId/invite-link` — open the party to RSVPs. */
+parties.post('/:partyId/invite-link', async (c) => {
+ const opened = await c.var.repositories.parties.setInvitesOpen(
+ c.get('party').id,
+ true,
+ );
+ if (!opened.ok) return c.json({ error: opened.error }, 500);
+ return c.json(toPublishedDTO(opened.value));
+});
+
+/**
+ * `DELETE /api/parties/:partyId/invite-link` — close it.
+ *
+ * Owner only: a co-organiser turning off the link would invalidate invitations
+ * the owner has already sent, which is not an edit, it is a decision.
+ */
+parties.delete('/:partyId/invite-link', async (c) => {
+ if (!ownerOnly(c)) return c.json({ error: 'owner_only' }, 403);
+
+ const closed = await c.var.repositories.parties.setInvitesOpen(
+ c.get('party').id,
+ false,
+ );
+ if (!closed.ok) return c.json({ error: closed.error }, 500);
+ return c.json({ ok: true });
+});
+
+// ── The funnel ──────────────────────────────────────────────────────────────
+
+/** `GET /api/parties/:partyId/invites` — the RSVP funnel. Any member. */
+parties.get('/:partyId/invites', async (c) => {
+ const party = c.get('party');
+ const listed = await c.var.repositories.invites.listForParty(party.id);
+ if (!listed.ok) return c.json({ error: listed.error }, 500);
+
+ return c.json({
+ party: toPublishedDTO(party),
+ invites: listed.value.map(toInviteDTO),
+ });
+});
+
+/**
+ * `POST /api/parties/:partyId/invites` — a guest an organiser types in.
+ *
+ * On a shared party this has to be a server row, not a local one. It is what
+ * lets the co-organiser see them at all, and what gives them a ticket code the
+ * second phone on the door can check.
+ */
+parties.post('/:partyId/invites', async (c) => {
+ const body = await c.req
+ .json<{ name?: string }>()
+ .catch(() => ({}) as { name?: string });
+ const name = body.name?.trim().slice(0, 60);
+ if (!name) return c.json({ error: 'name_required' }, 400);
+
+ const added = await c.var.repositories.invites.addManual({
+ partyId: c.get('party').id,
+ name,
+ ticketCode: '',
+ });
+ if (!added.ok) return c.json({ error: added.error }, 500);
+
+ return c.json(toInviteDTO({ ...added.value, referrerName: null }));
+});
+
+/**
+ * `POST /api/parties/:partyId/invites/:inviteId/check-in` — they walked in.
+ *
+ * The server decides, not the scanning phone. A second scan answers 409 with
+ * the time of the first, so whichever phone is holding the queue up can say
+ * "already scanned at 23:14" rather than silently admitting them twice.
+ */
+parties.post('/:partyId/invites/:inviteId/check-in', async (c) => {
+ const checked = await c.var.repositories.invites.checkIn({
+ inviteId: c.req.param('inviteId'),
+ partyId: c.get('party').id,
+ at: new Date().toISOString(),
+ });
+
+ if (!checked.ok) {
+ if (checked.error === INVITE_ERRORS.ALREADY_CHECKED_IN) {
+ const existing = await c.var.repositories.invites.findById(
+ c.req.param('inviteId'),
+ );
+ return c.json(
+ {
+ error: checked.error,
+ checkedInAt: existing.ok ? existing.value.checkedInAt : null,
+ },
+ 409,
+ );
+ }
+ return c.json({ error: checked.error }, 404);
+ }
+
+ return c.json(toInviteDTO({ ...checked.value, referrerName: null }));
+});
+
+/** `DELETE …/check-in` — for the guest waved through by mistake. */
+parties.delete('/:partyId/invites/:inviteId/check-in', async (c) => {
+ const undone = await c.var.repositories.invites.undoCheckIn({
+ inviteId: c.req.param('inviteId'),
+ partyId: c.get('party').id,
+ });
+ if (!undone.ok) return c.json({ error: undone.error }, 404);
+ return c.json(toInviteDTO({ ...undone.value, referrerName: null }));
+});
+
+/** `PATCH /api/parties/:partyId/invites/:inviteId` — override an answer. */
+parties.patch('/:partyId/invites/:inviteId', async (c) => {
+ const body = await c.req
+ .json<{ status?: string }>()
+ .catch(() => ({}) as { status?: string });
+ const status = body.status;
+ if (!status || !(INVITE_STATUSES as readonly string[]).includes(status)) {
+ return c.json({ error: 'invalid_status' }, 400);
+ }
+
+ const updated = await c.var.repositories.invites.setStatus({
+ inviteId: c.req.param('inviteId'),
+ partyId: c.get('party').id,
+ status: status as InviteStatus,
+ });
+ if (!updated.ok) return c.json({ error: updated.error }, 404);
+
+ return c.json({ ok: true, status: updated.value.status });
+});
+
+// ── Co-organisers ───────────────────────────────────────────────────────────
+
+/** `GET /api/parties/:partyId/members` — who is on this party. */
+parties.get('/:partyId/members', async (c) => {
+ const members = await c.var.repositories.members.listMembers(
+ c.get('party').id,
+ );
+ if (!members.ok) return c.json({ error: members.error }, 500);
+ return c.json({ members: members.value.map(toMemberDTO) });
+});
+
+/**
+ * `POST /api/parties/:partyId/members/invite` — mint a link to co-organise.
+ *
+ * Owner only. An editor who could invite could add someone the owner has just
+ * removed, which would make removal meaningless.
+ */
+parties.post('/:partyId/members/invite', async (c) => {
+ if (!ownerOnly(c)) return c.json({ error: 'owner_only' }, 403);
+
+ const userId = c.get('userId') as string;
+ const invite = await c.var.repositories.members.createInvite({
+ partyId: c.get('party').id,
+ createdBy: userId,
+ });
+ if (!invite.ok) return c.json({ error: invite.error }, 500);
+
+ const dto: CollaboratorInviteDTO = {
+ token: invite.value.token,
+ createdAt: invite.value.createdAt,
+ };
+ return c.json(dto);
+});
+
+/** `DELETE /api/parties/:partyId/members/invites` — revoke outstanding links. */
+parties.delete('/:partyId/members/invites', async (c) => {
+ if (!ownerOnly(c)) return c.json({ error: 'owner_only' }, 403);
+
+ const revoked = await c.var.repositories.members.revokeInvitesFor(
+ c.get('party').id,
+ );
+ if (!revoked.ok) return c.json({ error: revoked.error }, 500);
+ return c.json({ ok: true });
+});
+
+/**
+ * `DELETE /api/parties/:partyId/members/:userId` — remove a co-organiser, or
+ * leave a party you were added to.
+ */
+parties.delete('/:partyId/members/:userId', async (c) => {
+ const userId = c.get('userId') as string;
+ const target = c.req.param('userId');
+
+ // Anyone may remove themselves; only the owner may remove anybody else.
+ if (target !== userId && !ownerOnly(c)) {
+ return c.json({ error: 'owner_only' }, 403);
+ }
+
+ const removed = await c.var.repositories.members.remove(
+ c.get('party').id,
+ target,
+ );
+ if (!removed.ok) {
+ const status =
+ removed.error === MEMBER_ERRORS.CANNOT_REMOVE_OWNER ? 409 : 404;
+ return c.json({ error: removed.error }, status);
+ }
+ return c.json({ ok: true });
+});
+
+export default parties;
diff --git a/backend/src/routes/partyInput.ts b/backend/src/routes/partyInput.ts
new file mode 100644
index 0000000..eea7e90
--- /dev/null
+++ b/backend/src/routes/partyInput.ts
@@ -0,0 +1,127 @@
+import type { PartyDocument } from '../../../shared/collab';
+import type { MergePatch } from '../../../shared/patch';
+import type { PublishPartyRequest } from '../../../shared/invites';
+
+/**
+ * Validation for everything a client sends about a party.
+ *
+ * The party document is mostly free-form by design — the menu is whatever
+ * categories and cocktails the host invented — so this checks the shape the
+ * server itself relies on and bounds the strings that end up on a page anyone
+ * with an invite link can open. A host is not a threat; a stolen session is.
+ */
+
+const MAX_DOCUMENT_BYTES = 256 * 1024;
+
+function str(value: unknown, max: number, fallback = ''): string {
+ return typeof value === 'string' ? value.slice(0, max) : fallback;
+}
+
+function num(value: unknown, fallback: number): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
+}
+
+function record(value: unknown): Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+/** Normalises a document, or null if it is not one. */
+export function parseDocument(body: unknown): PublishPartyRequest | null {
+ if (typeof body !== 'object' || body === null) return null;
+ const b = body as { localId?: unknown; document?: unknown };
+
+ if (typeof b.localId !== 'number' || !Number.isInteger(b.localId))
+ return null;
+ if (typeof b.document !== 'object' || b.document === null) return null;
+
+ const d = b.document as Record;
+ const name = str(d['name'], 80).trim();
+ if (name === '') return null;
+
+ const venue = record(d['venue']);
+ const settings = record(d['settings']);
+
+ const document: PartyDocument = {
+ name,
+ date: str(d['date'], 10),
+ cover: Math.max(0, Math.min(5, Math.trunc(num(d['cover'], 0)))),
+ venue: {
+ place: str(venue['place'], 120),
+ city: str(venue['city'], 80),
+ time: str(venue['time'], 10),
+ },
+ settings: {
+ guests: num(settings['guests'], 0),
+ ticket_price: num(settings['ticket_price'], 0),
+ venue_cost: num(settings['venue_cost'], 0),
+ equipment_cost: num(settings['equipment_cost'], 0),
+ alcohol_ml_per_person: num(settings['alcohol_ml_per_person'], 0),
+ buffer: num(settings['buffer'], 1),
+ max_capacity:
+ typeof settings['max_capacity'] === 'number'
+ ? Math.max(1, Math.round(settings['max_capacity']))
+ : null,
+ },
+ menu: record(d['menu']),
+ locks: record(d['locks']) as Record,
+ checked: record(d['checked']) as Record,
+ allowForward: d['allowForward'] !== false,
+ includeSnacks: d['includeSnacks'] !== false,
+ };
+
+ // A menu is user-authored and unbounded in principle. Cap the stored size so
+ // one party cannot become a denial-of-service against the row it lives in.
+ if (JSON.stringify(document).length > MAX_DOCUMENT_BYTES) return null;
+
+ return { localId: b.localId, document };
+}
+
+/**
+ * Checks a patch without interpreting it.
+ *
+ * Its keys are menu categories and cocktail names, so there is no schema to
+ * validate against — what matters is that it is a plain object of bounded size,
+ * and that it cannot reach fields the document does not own.
+ */
+export function parsePatch(
+ body: unknown,
+): { baseVersion: number; patch: MergePatch } | null {
+ if (typeof body !== 'object' || body === null) return null;
+ const b = body as { baseVersion?: unknown; patch?: unknown };
+
+ if (typeof b.baseVersion !== 'number' || !Number.isFinite(b.baseVersion)) {
+ return null;
+ }
+ if (
+ typeof b.patch !== 'object' ||
+ b.patch === null ||
+ Array.isArray(b.patch)
+ ) {
+ return null;
+ }
+
+ const patch = b.patch as MergePatch;
+ if (Object.keys(patch).length === 0) return null;
+ if (JSON.stringify(patch).length > MAX_DOCUMENT_BYTES) return null;
+
+ // `__proto__` in a merge patch is how a JSON payload reaches Object.prototype
+ // once something spreads the result. SQLite's json_patch does not care, but
+ // the client applies the same patch to a live object and would.
+ if (hasForbiddenKey(patch)) return null;
+
+ return { baseVersion: b.baseVersion, patch };
+}
+
+const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
+
+function hasForbiddenKey(value: unknown): boolean {
+ if (typeof value !== 'object' || value === null) return false;
+ if (Array.isArray(value)) return value.some(hasForbiddenKey);
+ for (const [key, nested] of Object.entries(value)) {
+ if (FORBIDDEN.has(key)) return true;
+ if (hasForbiddenKey(nested)) return true;
+ }
+ return false;
+}
diff --git a/backend/src/routes/session.ts b/backend/src/routes/session.ts
new file mode 100644
index 0000000..741aefb
--- /dev/null
+++ b/backend/src/routes/session.ts
@@ -0,0 +1,76 @@
+import { Hono } from 'hono';
+import { getCookie } from 'hono/cookie';
+import { verify } from 'hono/jwt';
+import type { SessionDTO } from '../../../shared/session';
+import { featuresFor, resolveTier } from '../../../shared/tiers';
+import type { AppVariables } from '../appEnv';
+
+type Bindings = {
+ JWT_SECRET: string;
+ SELF_HOSTED?: string;
+};
+
+const session = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
+
+/** Anonymous free tier — what an unsigned, expired or unreadable cookie means. */
+function anonymous(selfHosted: boolean): SessionDTO {
+ const tier = resolveTier({ storedTier: null, selfHosted });
+ return {
+ authenticated: false,
+ user: null,
+ tier,
+ features: featuresFor(tier),
+ selfHosted,
+ backendAvailable: true,
+ };
+}
+
+/**
+ * `GET /api/session` — public on purpose.
+ *
+ * Every other `/api/*` route sits behind the JWT guard, but this one answers
+ * for logged-out browsers as well, because "logged out" is a supported tier
+ * rather than an error. An unreadable cookie degrades to anonymous instead of
+ * 401 for the same reason: a user whose session expired mid-plan should quietly
+ * drop to free, not be shown a failure over a product they are still using.
+ */
+session.get('/', async (c) => {
+ const selfHosted = c.env.SELF_HOSTED === 'true';
+ const token = getCookie(c, 'session_token');
+ if (!token) return c.json(anonymous(selfHosted));
+
+ let sub: string;
+ try {
+ const payload = await verify(token, c.env.JWT_SECRET, 'HS256');
+ if (typeof payload.sub !== 'string') return c.json(anonymous(selfHosted));
+ sub = payload.sub;
+ } catch {
+ return c.json(anonymous(selfHosted));
+ }
+
+ // The tier comes from the row, never from the cookie: a JWT lives 7 days, and
+ // a claim baked into one would keep granting `pro` for a week after a refund
+ // — or withhold it until re-login after a purchase.
+ const found = await c.var.repositories.users.findById(sub);
+ if (!found.ok) return c.json(anonymous(selfHosted));
+
+ const user = found.value;
+ const tier = resolveTier({ storedTier: user.tier, selfHosted });
+
+ const dto: SessionDTO = {
+ authenticated: true,
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ picture: user.picture,
+ },
+ tier,
+ features: featuresFor(tier),
+ selfHosted,
+ backendAvailable: true,
+ };
+ return c.json(dto);
+});
+
+export default session;
diff --git a/backend/src/tests/routes/collaborate.spec.ts b/backend/src/tests/routes/collaborate.spec.ts
new file mode 100644
index 0000000..f39aab9
--- /dev/null
+++ b/backend/src/tests/routes/collaborate.spec.ts
@@ -0,0 +1,351 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ CollaboratorInviteDTO,
+ CollaboratorPreviewDTO,
+ PartySummaryDTO,
+ SharedPartyDTO,
+} from '../../../../shared/collab';
+import type { Repositories } from '../../repositories/repositories';
+import { resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeUsers } from '../support/fakeRepositories';
+import { aDocument, publishParty, repositoriesWith } from '../support/party';
+import { request, sessionCookie } from '../support/harness';
+
+/** An owner and someone they might invite. */
+function twoUsers() {
+ return fakeUsers([
+ aUser({
+ id: 'user-1',
+ email: 'owner@example.com',
+ name: 'Marco',
+ tier: 'pro',
+ }),
+ aUser({
+ id: 'user-2',
+ email: 'friend@example.com',
+ name: 'Giulia',
+ tier: 'free',
+ }),
+ ]);
+}
+
+let repositories: Repositories;
+let party: SharedPartyDTO;
+
+beforeEach(async () => {
+ resetFakeIds();
+ repositories = repositoriesWith(twoUsers());
+ party = await publishParty(repositories);
+});
+
+async function inviteToken(as = 'user-1'): Promise {
+ const res = await request(`/api/parties/${party.id}/members/invite`, {
+ repositories,
+ cookie: await sessionCookie(as),
+ method: 'POST',
+ });
+ expect(res.status).toBe(200);
+ return ((await res.json()) as CollaboratorInviteDTO).token;
+}
+
+async function join(token: string, as = 'user-2'): Promise {
+ return request(`/api/collaborate/${token}`, {
+ repositories,
+ cookie: await sessionCookie(as),
+ method: 'POST',
+ });
+}
+
+describe('storing a party', () => {
+ it('makes its creator the owner', async () => {
+ expect(party.role).toBe('owner');
+ expect(party.members).toHaveLength(1);
+ expect(party.members[0]).toMatchObject({ userId: 'user-1', role: 'owner' });
+ });
+
+ it('does not open the party to guests', async () => {
+ // Syncing a party so a co-organiser can open it is not the same act as
+ // opening it to the world.
+ expect(party.publication).toBeNull();
+ });
+
+ it('lists the party for its owner', async () => {
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+
+ const body = (await res.json()) as { parties: PartySummaryDTO[] };
+ expect(body.parties).toHaveLength(1);
+ expect(body.parties[0]).toMatchObject({ role: 'owner', memberCount: 1 });
+ });
+});
+
+describe('inviting a co-organiser', () => {
+ it('lets someone join and see the party', async () => {
+ const token = await inviteToken();
+ const joined = await join(token);
+ expect(joined.status).toBe(200);
+
+ const res = await request(`/api/parties/${party.id}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ expect(res.status).toBe(200);
+ const shared = (await res.json()) as SharedPartyDTO;
+ expect(shared.role).toBe('editor');
+ expect(shared.document.name).toBe('Rooftop');
+ expect(shared.members).toHaveLength(2);
+ });
+
+ it('does not require the joiner to be on the paid tier', async () => {
+ // The party is somebody else's and already paid for. Charging both people
+ // to run one party would make the feature useless — you cannot
+ // co-organise alone.
+ expect(repositories.users).toBeDefined();
+ const token = await inviteToken();
+
+ expect((await join(token, 'user-2')).status).toBe(200);
+ });
+
+ it('shows what is being joined before accepting', async () => {
+ const token = await inviteToken();
+ const res = await request(`/api/collaborate/${token}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ const preview = (await res.json()) as CollaboratorPreviewDTO;
+ expect(preview).toMatchObject({
+ partyName: 'Rooftop',
+ invitedBy: 'Marco',
+ alreadyMember: false,
+ });
+ });
+
+ it('reports when the joiner is already on the party', async () => {
+ const token = await inviteToken();
+ await join(token);
+
+ const res = await request(`/api/collaborate/${token}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ expect(((await res.json()) as CollaboratorPreviewDTO).alreadyMember).toBe(
+ true,
+ );
+ });
+
+ it('is idempotent — joining twice is not an error', async () => {
+ const token = await inviteToken();
+ expect((await join(token)).status).toBe(200);
+ expect((await join(token)).status).toBe(200);
+
+ const members = await repositories.members.listMembers(party.id);
+ expect(members.ok && members.value).toHaveLength(2);
+ });
+
+ it('does not demote the owner who opens their own link', async () => {
+ const token = await inviteToken();
+ await join(token, 'user-1');
+
+ const role = await repositories.members.roleFor(party.id, 'user-1');
+ expect(role.ok && role.value).toBe('owner');
+ });
+
+ it('refuses an anonymous joiner — an editor has to be somebody', async () => {
+ const token = await inviteToken();
+ const res = await request(`/api/collaborate/${token}`, {
+ repositories,
+ method: 'POST',
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it('404s an unknown token', async () => {
+ expect((await join('not-a-token')).status).toBe(404);
+ });
+
+ it('404s a revoked link, indistinguishably from an unknown one', async () => {
+ // Telling them apart would confirm to a stranger that they guessed a real
+ // token.
+ const token = await inviteToken();
+ const revoked = await request(`/api/parties/${party.id}/members/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'DELETE',
+ });
+ expect(revoked.status).toBe(200);
+
+ expect((await join(token)).status).toBe(404);
+ });
+
+ it('refuses to mint a link for an editor', async () => {
+ // An editor who could invite could add back someone the owner just
+ // removed, which would make removal meaningless.
+ await join(await inviteToken());
+
+ const res = await request(`/api/parties/${party.id}/members/invite`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'POST',
+ });
+
+ expect(res.status).toBe(403);
+ });
+});
+
+describe('what an editor may do', () => {
+ beforeEach(async () => {
+ await join(await inviteToken());
+ });
+
+ it('edits the party', async () => {
+ const res = await request(`/api/parties/${party.id}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'PATCH',
+ body: { baseVersion: party.version, patch: { name: 'Rooftop II' } },
+ });
+
+ expect(res.status).toBe(200);
+ const stored = await repositories.parties.findById(party.id);
+ expect(stored.ok && stored.value.document?.name).toBe('Rooftop II');
+ });
+
+ it('reads the funnel', async () => {
+ const res = await request(`/api/parties/${party.id}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('opens the guest invite link', async () => {
+ // Sharing the party with guests is running the party, which is the job.
+ const res = await request(`/api/parties/${party.id}/invite-link`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'POST',
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('may not close a link the owner opened', async () => {
+ // That invalidates invitations the owner has already sent — a decision,
+ // not an edit.
+ const res = await request(`/api/parties/${party.id}/invite-link`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'DELETE',
+ });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('may not delete the party', async () => {
+ const res = await request(`/api/parties/${party.id}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'DELETE',
+ });
+
+ expect(res.status).toBe(403);
+ expect((await repositories.parties.findById(party.id)).ok).toBe(true);
+ });
+
+ it('may not remove the owner', async () => {
+ const res = await request(`/api/parties/${party.id}/members/user-1`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'DELETE',
+ });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('may leave of their own accord', async () => {
+ const res = await request(`/api/parties/${party.id}/members/user-2`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'DELETE',
+ });
+
+ expect(res.status).toBe(200);
+ expect((await repositories.members.roleFor(party.id, 'user-2')).ok).toBe(
+ false,
+ );
+ });
+
+ it('sees the party in their own list', async () => {
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ const body = (await res.json()) as { parties: PartySummaryDTO[] };
+ expect(body.parties).toHaveLength(1);
+ expect(body.parties[0]).toMatchObject({ role: 'editor', memberCount: 2 });
+ });
+});
+
+describe('removing a co-organiser', () => {
+ it('takes their access away', async () => {
+ await join(await inviteToken());
+
+ const removed = await request(`/api/parties/${party.id}/members/user-2`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'DELETE',
+ });
+ expect(removed.status).toBe(200);
+
+ const res = await request(`/api/parties/${party.id}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+ expect(res.status).toBe(404);
+ });
+
+ it('refuses to remove the owner, who would leave the party ownerless', async () => {
+ const res = await request(`/api/parties/${party.id}/members/user-1`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'DELETE',
+ });
+
+ expect(res.status).toBe(409);
+ });
+});
+
+describe('a party someone is not on', () => {
+ it('is a 404, not a 403 — an id must not be probeable', async () => {
+ for (const path of [
+ `/api/parties/${party.id}`,
+ `/api/parties/${party.id}/invites`,
+ `/api/parties/${party.id}/members`,
+ ]) {
+ const res = await request(path, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+ expect(res.status, path).toBe(404);
+ }
+ });
+
+ it('cannot be edited', async () => {
+ const res = await request(`/api/parties/${party.id}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'PATCH',
+ body: { baseVersion: 1, patch: { name: 'Hijacked' } },
+ });
+
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/backend/src/tests/routes/devAuth.spec.ts b/backend/src/tests/routes/devAuth.spec.ts
new file mode 100644
index 0000000..5ebbd14
--- /dev/null
+++ b/backend/src/tests/routes/devAuth.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { fakeRepositories } from '../support/fakeRepositories';
+import { request } from '../support/harness';
+
+describe('POST /auth/dev', () => {
+ it('is absent on the hosted deployment', async () => {
+ // The check reads a binding, not a header, so no caller can talk their way
+ // into it. Losing this is a free account on the paid deployment.
+ const res = await request('/auth/dev', {
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'production', SELF_HOSTED: 'false' },
+ method: 'POST',
+ body: { email: 'someone@example.com' },
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('signs a user in on a self-hosted deployment', async () => {
+ const res = await request('/auth/dev', {
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'production', SELF_HOSTED: 'true' },
+ method: 'POST',
+ body: { email: 'owner@example.com', name: 'Owner' },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get('set-cookie')).toContain('session_token=');
+ });
+
+ it('signs a user in locally', async () => {
+ const res = await request('/auth/dev', {
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'local', SELF_HOSTED: 'false' },
+ method: 'POST',
+ body: { email: 'dev@example.com' },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('requires an email', async () => {
+ const res = await request('/auth/dev', {
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'local' },
+ method: 'POST',
+ body: {},
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('marks the session cookie httpOnly', async () => {
+ const res = await request('/auth/dev', {
+ repositories: fakeRepositories(),
+ env: { ENVIRONMENT: 'local' },
+ method: 'POST',
+ body: { email: 'dev@example.com' },
+ });
+
+ expect(res.headers.get('set-cookie')?.toLowerCase()).toContain('httponly');
+ });
+});
diff --git a/backend/src/tests/routes/door.spec.ts b/backend/src/tests/routes/door.spec.ts
new file mode 100644
index 0000000..300f035
--- /dev/null
+++ b/backend/src/tests/routes/door.spec.ts
@@ -0,0 +1,307 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type { SharedPartyDTO } from '../../../../shared/collab';
+import type { HostInviteDTO, InviteOpenDTO } from '../../../../shared/invites';
+import { isTicketCode } from '../../../../shared/tickets';
+import type { Repositories } from '../../repositories/repositories';
+import { resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeUsers } from '../support/fakeRepositories';
+import {
+ aDocument,
+ publishAndOpenInvites,
+ repositoriesWith,
+} from '../support/party';
+import { request, sessionCookie } from '../support/harness';
+
+/** Anna owns the party; Bruno co-organises and works the other door. */
+function twoOrganisers() {
+ return fakeUsers([
+ aUser({
+ id: 'user-1',
+ email: 'anna@example.com',
+ name: 'Anna',
+ tier: 'pro',
+ }),
+ aUser({
+ id: 'user-2',
+ email: 'bruno@example.com',
+ name: 'Bruno',
+ tier: 'free',
+ }),
+ ]);
+}
+
+let repositories: Repositories;
+let partyId: string;
+let slug: string;
+let rootToken: string;
+
+beforeEach(async () => {
+ resetFakeIds();
+ repositories = repositoriesWith(twoOrganisers());
+ const published = await publishAndOpenInvites(
+ repositories,
+ 'user-1',
+ aDocument(),
+ );
+ partyId = published.party.id;
+ slug = published.slug;
+ rootToken = published.rootToken;
+
+ // Bruno joins as a co-organiser.
+ const invite = await request(`/api/parties/${partyId}/members/invite`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ });
+ const { token } = (await invite.json()) as { token: string };
+ await request(`/api/collaborate/${token}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ method: 'POST',
+ });
+});
+
+async function aConfirmedGuest(name: string): Promise {
+ const openRes = await request(`/invite/${slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: { referrer: rootToken },
+ });
+ const opened = (await openRes.json()) as InviteOpenDTO;
+ await request(`/invite/${slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: opened.inviteId, name, answer: 'confirmed' },
+ });
+
+ const funnel = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ const body = (await funnel.json()) as { invites: HostInviteDTO[] };
+ return body.invites.find((i) => i.name === name)!;
+}
+
+function scan(inviteId: string, as: string): Promise {
+ return sessionCookie(as).then((cookie) =>
+ request(`/api/parties/${partyId}/invites/${inviteId}/check-in`, {
+ repositories,
+ cookie,
+ method: 'POST',
+ }),
+ );
+}
+
+describe('the ticket key', () => {
+ it('is the same for every organiser', async () => {
+ // The whole reason a second phone can check a ticket the first phone
+ // issued. A per-device key meant Bruno's scanner rejected every guest.
+ const forAnna = await request(`/api/parties/${partyId}`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ const forBruno = await request(`/api/parties/${partyId}`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ const anna = (await forAnna.json()) as SharedPartyDTO;
+ const bruno = (await forBruno.json()) as SharedPartyDTO;
+
+ expect(anna.ticketKey).not.toBeNull();
+ expect(bruno.ticketKey).toEqual(anna.ticketKey);
+ });
+
+ it('survives a save, or every ticket already issued would break', async () => {
+ const before = await request(`/api/parties/${partyId}`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ const keyBefore = ((await before.json()) as SharedPartyDTO).ticketKey;
+
+ await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 7, document: aDocument({ name: 'Renamed' }) },
+ });
+
+ const after = await request(`/api/parties/${partyId}`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ expect(((await after.json()) as SharedPartyDTO).ticketKey).toEqual(
+ keyBefore,
+ );
+ });
+
+ it('is never handed to a guest', async () => {
+ const res = await request(`/invite/${slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ expect(JSON.stringify(await res.json())).not.toContain('ticketKey');
+ });
+});
+
+describe('ticket codes', () => {
+ it('are issued to everyone who opens the link', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ expect(isTicketCode(guest.ticketCode)).toBe(true);
+ });
+
+ it('are unique within a party', async () => {
+ const codes = new Set();
+ for (const name of ['A', 'B', 'C', 'D', 'E']) {
+ codes.add((await aConfirmedGuest(name)).ticketCode);
+ }
+ expect(codes.size).toBe(5);
+ });
+});
+
+describe('a guest an organiser types in', () => {
+ it('becomes a real invite the co-organiser can see', async () => {
+ // Previously these lived only in the typing browser, so the other organiser
+ // never saw them and their ticket could not be checked at the other door.
+ const added = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { name: 'Walk-in Wanda' },
+ });
+ expect(added.status).toBe(200);
+
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+ const body = (await res.json()) as { invites: HostInviteDTO[] };
+ const wanda = body.invites.find((i) => i.name === 'Walk-in Wanda');
+
+ expect(wanda).toMatchObject({
+ status: 'confirmed',
+ source: 'manual',
+ depth: 0,
+ });
+ expect(isTicketCode(wanda!.ticketCode)).toBe(true);
+ });
+
+ it('is marked manual, so it does not inflate "reached"', async () => {
+ // They never opened a link. Counting them as reached would make the
+ // conversion rate a lie.
+ await aConfirmedGuest('Giulia');
+ await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { name: 'Wanda' },
+ });
+
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ const { invites } = (await res.json()) as { invites: HostInviteDTO[] };
+
+ expect(invites.filter((i) => i.source === 'link')).toHaveLength(1);
+ expect(invites.filter((i) => i.source === 'manual')).toHaveLength(1);
+ });
+
+ it('needs a name', async () => {
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { name: ' ' },
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe('two phones on the door', () => {
+ it('lets the first scan through', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ const res = await scan(guest.id, 'user-1');
+
+ expect(res.status).toBe(200);
+ expect((await res.json()) as HostInviteDTO).toMatchObject({
+ checkedIn: true,
+ });
+ });
+
+ it('refuses the second, on the other phone', async () => {
+ // This is the whole feature. Before, Bruno's phone had its own tally and
+ // would have admitted her again.
+ const guest = await aConfirmedGuest('Giulia');
+ expect((await scan(guest.id, 'user-1')).status).toBe(200);
+
+ const second = await scan(guest.id, 'user-2');
+
+ expect(second.status).toBe(409);
+ const body = (await second.json()) as {
+ error: string;
+ checkedInAt: string;
+ };
+ expect(body.error).toBe('already_checked_in');
+ // The time matters: the door says "already scanned at 23:14", not just no.
+ expect(body.checkedInAt).toBeTruthy();
+ });
+
+ it('shows the check-in to the other organiser', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ await scan(guest.id, 'user-1');
+
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+ const { invites } = (await res.json()) as { invites: HostInviteDTO[] };
+
+ expect(invites.find((i) => i.id === guest.id)).toMatchObject({
+ checkedIn: true,
+ });
+ });
+
+ it('can be undone for someone waved through by mistake', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ await scan(guest.id, 'user-1');
+
+ const undone = await request(
+ `/api/parties/${partyId}/invites/${guest.id}/check-in`,
+ { repositories, cookie: await sessionCookie('user-2'), method: 'DELETE' },
+ );
+
+ expect(undone.status).toBe(200);
+ expect((await undone.json()) as HostInviteDTO).toMatchObject({
+ checkedIn: false,
+ checkedInAt: null,
+ });
+ // …and they can then be scanned again.
+ expect((await scan(guest.id, 'user-1')).status).toBe(200);
+ });
+
+ it('refuses a ticket from another party', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ const other = await publishAndOpenInvites(
+ repositories,
+ 'user-1',
+ aDocument({ name: 'Other' }),
+ 9,
+ );
+
+ const res = await request(
+ `/api/parties/${other.party.id}/invites/${guest.id}/check-in`,
+ { repositories, cookie: await sessionCookie('user-1'), method: 'POST' },
+ );
+
+ expect(res.status).toBe(404);
+ });
+
+ it('refuses a scanner who is not on the party', async () => {
+ const guest = await aConfirmedGuest('Giulia');
+ const res = await scan(guest.id, 'stranger');
+
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/backend/src/tests/routes/funnel.spec.ts b/backend/src/tests/routes/funnel.spec.ts
new file mode 100644
index 0000000..03c9259
--- /dev/null
+++ b/backend/src/tests/routes/funnel.spec.ts
@@ -0,0 +1,294 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ HostInviteDTO,
+ InviteOpenDTO,
+ PublishedPartyDTO,
+} from '../../../../shared/invites';
+import type { Repositories } from '../../repositories/repositories';
+import { resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeUsers } from '../support/fakeRepositories';
+import {
+ aDocument,
+ publishAndOpenInvites,
+ repositoriesWith,
+} from '../support/party';
+import { request, sessionCookie } from '../support/harness';
+
+let repositories: Repositories;
+let party: PublishedPartyDTO;
+let partyId: string;
+
+beforeEach(async () => {
+ resetFakeIds();
+ repositories = repositoriesWith();
+ const published = await publishAndOpenInvites(
+ repositories,
+ 'user-1',
+ aDocument({ venue: { place: '', city: '', time: '21:00' } }),
+ );
+ partyId = published.party.id;
+ party = {
+ id: published.party.id,
+ slug: published.slug,
+ rootToken: published.rootToken,
+ publishedAt: published.party.updatedAt,
+ allowForward: true,
+ };
+});
+
+/** Opens the link (optionally as a forward) and confirms, returning the guest. */
+async function guestConfirms(
+ name: string,
+ referrer?: string | null,
+): Promise {
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: { referrer: referrer ?? null },
+ });
+ const opened = (await openRes.json()) as InviteOpenDTO;
+
+ const answerRes = await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: opened.inviteId, name, answer: 'confirmed' },
+ });
+ return (await answerRes.json()) as InviteOpenDTO;
+}
+
+async function funnel(): Promise {
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { invites: HostInviteDTO[] };
+ return body.invites;
+}
+
+describe('depth', () => {
+ it("puts a guest from the host's own link at depth 0", async () => {
+ // Depth 0 is the "Direct invites" tier in the spread card.
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ expect(guest.depth).toBe(0);
+ });
+
+ it("treats no referrer at all as the host's link", async () => {
+ const guest = await guestConfirms('Giulia');
+ expect(guest.depth).toBe(0);
+ });
+
+ it('puts a friend of a guest at depth 1', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ const marco = await guestConfirms('Marco', giulia.forwardToken);
+
+ expect(marco.depth).toBe(1);
+ });
+
+ it('keeps counting down the chain', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ const marco = await guestConfirms('Marco', giulia.forwardToken);
+ const sara = await guestConfirms('Sara', marco.forwardToken);
+
+ expect(sara.depth).toBe(2);
+ });
+
+ it('falls back to depth 0 for a token that means nothing', async () => {
+ // Usually a link from a party that has since been unpublished. The guest
+ // should still be able to RSVP rather than hit an error.
+ const guest = await guestConfirms('Giulia', 'not-a-real-token');
+ expect(guest.depth).toBe(0);
+ });
+
+ it('records who referred whom, by name', async () => {
+ const giulia = await guestConfirms('Giulia', party.rootToken);
+ await guestConfirms('Marco', giulia.forwardToken);
+
+ const rows = await funnel();
+ const marco = rows.find((i) => i.name === 'Marco');
+ expect(marco?.referrer).toBe('Giulia');
+ expect(rows.find((i) => i.name === 'Giulia')?.referrer).toBeNull();
+ });
+});
+
+describe("the host's funnel", () => {
+ it('reports every state, including people who never answered', async () => {
+ await guestConfirms('Giulia', party.rootToken);
+
+ // Someone who opened and said no.
+ const declining = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const declined = (await declining.json()) as InviteOpenDTO;
+ await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: declined.inviteId, name: 'Marco', answer: 'declined' },
+ });
+
+ // Someone who opened and walked away.
+ await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+
+ const rows = await funnel();
+ expect(rows).toHaveLength(3);
+ expect(rows.map((i) => i.status).sort()).toEqual([
+ 'confirmed',
+ 'declined',
+ 'opened',
+ ]);
+ // The one who never answered has no name to show — that is the point of
+ // "reached" being a separate number from "confirmed".
+ expect(rows.find((i) => i.status === 'opened')?.name).toBeNull();
+ });
+
+ it('returns invites oldest first', async () => {
+ await guestConfirms('First', party.rootToken);
+ await guestConfirms('Second', party.rootToken);
+
+ const rows = await funnel();
+ expect(rows.map((i) => i.name)).toEqual(['First', 'Second']);
+ });
+
+ it('404s a party that does not exist', async () => {
+ const res = await request('/api/parties/party-nope/invites', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+ expect(res.status).toBe(404);
+ });
+
+ it("does not show one host another host's guests", async () => {
+ // The lookup is by (owner, localId), so there is no id to substitute.
+ await guestConfirms('Giulia', party.rootToken);
+ repositories.users = fakeUsers([
+ aUser({ tier: 'pro' }),
+ aUser({ id: 'user-2', email: 'other@example.com', tier: 'pro' }),
+ ]);
+
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-2'),
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('stays readable to a member whose tier has changed', async () => {
+ // The paywall is on *creating* a cloud party, not on running one. A member
+ // of a party that already exists keeps access to it — anything else would
+ // strand a co-organiser, who is never required to pay at all.
+ repositories.users = fakeUsers([aUser({ tier: 'free' })]);
+
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('is refused to someone who is not a member, whatever their tier', async () => {
+ const res = await request(`/api/parties/${partyId}/invites`, {
+ repositories,
+ cookie: await sessionCookie('stranger'),
+ });
+
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('the host overriding an answer', () => {
+ async function override(
+ inviteId: string,
+ status: string,
+ cookie = 'user-1',
+ ): Promise {
+ return request(`/api/parties/${partyId}/invites/${inviteId}`, {
+ repositories,
+ cookie: await sessionCookie(cookie),
+ method: 'PATCH',
+ body: { status },
+ });
+ }
+
+ it('marks someone who never answered as coming', async () => {
+ // Hosts hear from guests off-platform. Without this the funnel would poll
+ // the host's change straight back out again.
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const opened = (await openRes.json()) as InviteOpenDTO;
+
+ const res = await override(opened.inviteId, 'confirmed');
+
+ expect(res.status).toBe(200);
+ expect((await funnel())[0]?.status).toBe('confirmed');
+ });
+
+ it('can send a guest back to unanswered, clearing the answer time', async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ await override(guest.inviteId, 'opened');
+
+ const row = (await funnel())[0];
+ expect(row?.status).toBe('opened');
+ expect(row?.answeredAt).toBeNull();
+ });
+
+ it('ignores capacity — the host is the authority on their own door', async () => {
+ await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ document: aDocument({
+ settings: { ...aDocument().settings, max_capacity: 1 },
+ }),
+ },
+ });
+ await guestConfirms('Giulia', party.rootToken);
+
+ const openRes = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ const second = (await openRes.json()) as InviteOpenDTO;
+
+ // The guest cannot get in…
+ const guestTry = await request(`/invite/${party.slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body: { inviteId: second.inviteId, name: 'Marco', answer: 'confirmed' },
+ });
+ expect(guestTry.status).toBe(409);
+
+ // …but the host can let them.
+ expect((await override(second.inviteId, 'confirmed')).status).toBe(200);
+ });
+
+ it('rejects a status that is not a real one', async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ expect((await override(guest.inviteId, 'maybe')).status).toBe(400);
+ });
+
+ it("404s an invite belonging to someone else's party", async () => {
+ const guest = await guestConfirms('Giulia', party.rootToken);
+ repositories.users = fakeUsers([
+ aUser({ tier: 'pro' }),
+ aUser({ id: 'user-2', email: 'other@example.com', tier: 'pro' }),
+ ]);
+
+ expect((await override(guest.inviteId, 'declined', 'user-2')).status).toBe(
+ 404,
+ );
+ });
+});
diff --git a/backend/src/tests/routes/invites.spec.ts b/backend/src/tests/routes/invites.spec.ts
new file mode 100644
index 0000000..eb96cc0
--- /dev/null
+++ b/backend/src/tests/routes/invites.spec.ts
@@ -0,0 +1,410 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ InviteOpenDTO,
+ PublishedPartyDTO,
+} from '../../../../shared/invites';
+import type { SharedPartyDTO } from '../../../../shared/collab';
+import type { Repositories } from '../../repositories/repositories';
+import { resetFakeIds } from '../support/fakeInvites';
+import { aUser, fakeUsers } from '../support/fakeRepositories';
+import {
+ aDocument,
+ publishAndOpenInvites,
+ repositoriesWith,
+} from '../support/party';
+import { request, sessionCookie } from '../support/harness';
+
+/** A pro host with one published party whose invite link is open. */
+async function aPublishedParty(
+ overrides: { maxCapacity?: number | null; allowForward?: boolean } = {},
+): Promise<{ repositories: Repositories; party: PublishedPartyDTO }> {
+ const repositories = repositoriesWith();
+ const document = aDocument({
+ allowForward: overrides.allowForward ?? true,
+ settings: {
+ ...aDocument().settings,
+ max_capacity: overrides.maxCapacity ?? null,
+ },
+ });
+ const { party, slug, rootToken } = await publishAndOpenInvites(
+ repositories,
+ 'user-1',
+ document,
+ );
+ return {
+ repositories,
+ party: {
+ id: party.id,
+ slug,
+ rootToken,
+ publishedAt: party.updatedAt,
+ allowForward: document.allowForward,
+ },
+ };
+}
+
+async function open(
+ repositories: Repositories,
+ slug: string,
+ body: Record = {},
+): Promise {
+ const res = await request(`/invite/${slug}/open`, {
+ repositories,
+ method: 'POST',
+ body,
+ });
+ expect(res.status).toBe(200);
+ return (await res.json()) as InviteOpenDTO;
+}
+
+async function answer(
+ repositories: Repositories,
+ slug: string,
+ body: Record,
+): Promise {
+ return request(`/invite/${slug}/answer`, {
+ repositories,
+ method: 'POST',
+ body,
+ });
+}
+
+beforeEach(() => resetFakeIds());
+
+describe('publishing a party', () => {
+ it('refuses a free host', async () => {
+ // The paywall is enforced here, not only by hiding the share sheet.
+ const repositories = repositoriesWith(fakeUsers([aUser({ tier: 'free' })]));
+
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 1, document: aDocument() },
+ });
+
+ expect(res.status).toBe(403);
+ expect(await res.json()).toMatchObject({ feature: 'cloudSync' });
+ });
+
+ it('allows a free host on a self-hosted deployment', async () => {
+ const repositories = repositoriesWith(fakeUsers([aUser({ tier: 'free' })]));
+
+ const res = await request('/api/parties', {
+ repositories,
+ env: { SELF_HOSTED: 'true' },
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 1, document: aDocument() },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('refuses an anonymous caller', async () => {
+ const res = await request('/api/parties', {
+ repositories: repositoriesWith(fakeUsers()),
+ method: 'POST',
+ body: { localId: 1, document: aDocument() },
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it('keeps the slug when the host republishes', async () => {
+ // Every share-sheet open republishes. A new slug would break every link
+ // already sent.
+ const { repositories, party } = await aPublishedParty();
+
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {
+ localId: 7,
+ document: aDocument({
+ name: 'Rooftop (moved)',
+ date: '2026-10-09',
+ venue: { place: 'The Roof', city: 'Milan', time: '22:00' },
+ }),
+ },
+ });
+ expect(res.status).toBe(200);
+
+ const republished = (await res.json()) as SharedPartyDTO;
+ expect(republished.publication?.slug).toBe(party.slug);
+ expect(republished.publication?.rootToken).toBe(party.rootToken);
+
+ // …and the guest-facing card reflects the edit.
+ const opened = await open(repositories, party.slug);
+ expect(opened.party.name).toBe('Rooftop (moved)');
+ expect(opened.party.venue.time).toBe('22:00');
+ });
+
+ it('rejects a document with no name', async () => {
+ const { repositories } = await aPublishedParty();
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { localId: 8, document: aDocument({ name: ' ' }) },
+ });
+
+ expect(res.status).toBe(400);
+ });
+});
+
+describe('opening an invite link', () => {
+ it('records the visit before any answer', async () => {
+ // This is what makes "reached" a real number rather than a guess.
+ const { repositories, party } = await aPublishedParty();
+
+ const opened = await open(repositories, party.slug);
+
+ expect(opened.status).toBe('opened');
+ expect(opened.name).toBeNull();
+ expect(opened.depth).toBe(0);
+ expect(opened.party.name).toBe('Rooftop');
+ });
+
+ it('does not hand out a forward token before confirming', async () => {
+ // Otherwise someone who never replied could seed a referral tree.
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ expect(opened.forwardToken).toBeNull();
+ });
+
+ it('treats a returning browser as the same guest', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const first = await open(repositories, party.slug);
+ const second = await open(repositories, party.slug, {
+ inviteId: first.inviteId,
+ });
+
+ expect(second.inviteId).toBe(first.inviteId);
+ const listed = await repositories.invites.listForParty('party-1');
+ expect(listed.ok && listed.value).toHaveLength(1);
+ });
+
+ it('ignores an inviteId belonging to another party', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const first = await open(repositories, party.slug);
+
+ // Publish a second party and try to carry the first party's row into it.
+ const second = await publishAndOpenInvites(
+ repositories,
+ 'user-1',
+ aDocument({ name: 'Other', date: '2026-11-01' }),
+ 9,
+ );
+
+ const opened = await open(repositories, second.slug, {
+ inviteId: first.inviteId,
+ });
+
+ expect(opened.inviteId).not.toBe(first.inviteId);
+ });
+
+ it('404s an unknown slug', async () => {
+ const { repositories } = await aPublishedParty();
+ const res = await request('/invite/not-a-party/open', {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('404s once the host closes the link', async () => {
+ const { repositories, party } = await aPublishedParty();
+
+ const removed = await request(`/api/parties/${party.id}/invite-link`, {
+ repositories,
+ cookie: await sessionCookie('user-1'),
+ method: 'DELETE',
+ });
+ expect(removed.status).toBe(200);
+
+ const res = await request(`/invite/${party.slug}/open`, {
+ repositories,
+ method: 'POST',
+ body: {},
+ });
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('answering', () => {
+ it('confirms and hands back a forward link', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(200);
+ const dto = (await res.json()) as InviteOpenDTO;
+ expect(dto.status).toBe('confirmed');
+ expect(dto.name).toBe('Giulia');
+ expect(dto.forwardToken).toBeTruthy();
+ });
+
+ it('withholds the forward link when the host disallows forwarding', async () => {
+ const { repositories, party } = await aPublishedParty({
+ allowForward: false,
+ });
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const dto = (await res.json()) as InviteOpenDTO;
+ expect(dto.forwardToken).toBeNull();
+ });
+
+ it('lets a guest change their mind without becoming a second guest', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'declined',
+ });
+
+ expect(res.status).toBe(200);
+ const listed = await repositories.invites.listForParty('party-1');
+ expect(listed.ok && listed.value).toHaveLength(1);
+ expect(listed.ok && listed.value[0]?.status).toBe('declined');
+ });
+
+ it('requires a name', async () => {
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: ' ',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects an answer that is not one of the two', async () => {
+ // "opened" is a state, not something a guest can claim.
+ const { repositories, party } = await aPublishedParty();
+ const opened = await open(repositories, party.slug);
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'opened',
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('404s a stale invite id so the client knows to open again', async () => {
+ const { repositories, party } = await aPublishedParty();
+
+ const res = await answer(repositories, party.slug, {
+ inviteId: 'invite-does-not-exist',
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('capacity', () => {
+ it('refuses a confirmation once the cap is reached', async () => {
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ const res = await answer(repositories, party.slug, {
+ inviteId: second.inviteId,
+ name: 'Marco',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(409);
+ expect(await res.json()).toMatchObject({ error: 'party_full' });
+ });
+
+ it('still lets someone decline a full party', async () => {
+ // Refusing the decline would strand the row at `opened` and overstate the
+ // "maybe" column with people who have already said no.
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ const res = await answer(repositories, party.slug, {
+ inviteId: second.inviteId,
+ name: 'Marco',
+ answer: 'declined',
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('lets an already-confirmed guest correct their name at the cap', async () => {
+ // They are already counted; re-confirming must not have to fit them in again.
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+ const opened = await open(repositories, party.slug);
+
+ await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giula',
+ answer: 'confirmed',
+ });
+ const res = await answer(repositories, party.slug, {
+ inviteId: opened.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as InviteOpenDTO).name).toBe('Giulia');
+ });
+
+ it('tells a late arrival the party is full before they answer', async () => {
+ const { repositories, party } = await aPublishedParty({ maxCapacity: 1 });
+ const first = await open(repositories, party.slug);
+ await answer(repositories, party.slug, {
+ inviteId: first.inviteId,
+ name: 'Giulia',
+ answer: 'confirmed',
+ });
+
+ const second = await open(repositories, party.slug);
+ expect(second.party.full).toBe(true);
+ });
+});
diff --git a/backend/src/tests/routes/licences.spec.ts b/backend/src/tests/routes/licences.spec.ts
new file mode 100644
index 0000000..96235dd
--- /dev/null
+++ b/backend/src/tests/routes/licences.spec.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from 'vitest';
+import {
+ aLicence,
+ aUser,
+ fakeLicences,
+ fakeRepositories,
+ fakeUsers,
+} from '../support/fakeRepositories';
+import { request, sessionCookie } from '../support/harness';
+
+describe('POST /api/licences/redeem', () => {
+ it('rejects an unauthenticated caller', async () => {
+ // Unlike /api/session, this one really does need a session — it changes a
+ // user's tier, so there has to be a user.
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(),
+ method: 'POST',
+ body: { code: 'BC-TEST-0001' },
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it('upgrades the caller and reports the new feature set', async () => {
+ const users = fakeUsers([aUser({ tier: 'free' })]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(users, fakeLicences([aLicence()])),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: 'BC-TEST-0001' },
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ tier: 'pro' });
+ expect(users.rows.get('user-1')?.tier).toBe('pro');
+ });
+
+ it('accepts a code typed in lower case', async () => {
+ const users = fakeUsers([aUser()]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(users, fakeLicences([aLicence()])),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: ' bc-test-0001 ' },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('answers an unknown code with 404', async () => {
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: 'BC-NOPE-0000' },
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it("answers someone else's code with 409, not the tier", async () => {
+ const licences = fakeLicences([
+ aLicence({
+ redeemedAt: '2026-01-02T00:00:00.000Z',
+ redeemedBy: 'user-2',
+ }),
+ ]);
+ const users = fakeUsers([aUser()]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(users, licences),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: 'BC-TEST-0001' },
+ });
+
+ expect(res.status).toBe(409);
+ expect(users.rows.get('user-1')?.tier).toBe('free');
+ });
+
+ it('is idempotent for the user who already redeemed it', async () => {
+ // A double-tapped Redeem button is not something the user can act on, and
+ // their tier is already what the code grants.
+ const licences = fakeLicences([
+ aLicence({
+ redeemedAt: '2026-01-02T00:00:00.000Z',
+ redeemedBy: 'user-1',
+ }),
+ ]);
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(
+ fakeUsers([aUser({ tier: 'pro' })]),
+ licences,
+ ),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: { code: 'BC-TEST-0001' },
+ });
+
+ expect(res.status).toBe(200);
+ });
+
+ it('rejects a request with no code', async () => {
+ const res = await request('/api/licences/redeem', {
+ repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()),
+ cookie: await sessionCookie('user-1'),
+ method: 'POST',
+ body: {},
+ });
+
+ expect(res.status).toBe(400);
+ });
+});
+
+describe('the /api/* guard', () => {
+ it('protects everything except the session endpoint', async () => {
+ const repositories = fakeRepositories();
+
+ const guarded = await request('/api/licences/redeem', {
+ repositories,
+ method: 'POST',
+ body: { code: 'x' },
+ });
+ const open = await request('/api/session', { repositories });
+
+ expect(guarded.status).toBe(401);
+ expect(open.status).toBe(200);
+ });
+
+ it('does not let a trailing slash slip past the guard', async () => {
+ // The exemption is matched against a path with its trailing slash stripped,
+ // so a guarded route cannot be reached by adding one. (`/api/session/`
+ // itself 404s — Hono does not alias it onto the mounted `/` — which is
+ // harmless: it is the guard, not the router, that this protects.)
+ const res = await request('/api/licences/redeem/', {
+ repositories: fakeRepositories(),
+ method: 'POST',
+ body: { code: 'x' },
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/backend/src/tests/routes/session.spec.ts b/backend/src/tests/routes/session.spec.ts
new file mode 100644
index 0000000..9d8c400
--- /dev/null
+++ b/backend/src/tests/routes/session.spec.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import type { SessionDTO } from '../../../../shared/session';
+import {
+ aUser,
+ fakeLicences,
+ fakeRepositories,
+ fakeUsers,
+} from '../support/fakeRepositories';
+import { request, sessionCookie } from '../support/harness';
+
+async function getSession(
+ options: Parameters[1],
+): Promise {
+ const res = await request('/api/session', options);
+ expect(res.status).toBe(200);
+ return (await res.json()) as SessionDTO;
+}
+
+describe('GET /api/session', () => {
+ it('answers anonymous callers instead of rejecting them', async () => {
+ // The free tier *is* a logged-out browser. A 401 here would make the
+ // browser-only product depend on being signed out successfully.
+ const dto = await getSession({ repositories: fakeRepositories() });
+
+ expect(dto.authenticated).toBe(false);
+ expect(dto.tier).toBe('free');
+ expect(dto.features.inviteLink).toBe(false);
+ });
+
+ it('reports the stored tier for a signed-in user', async () => {
+ const users = fakeUsers([aUser({ tier: 'pro' })]);
+ const dto = await getSession({
+ repositories: fakeRepositories(users, fakeLicences()),
+ cookie: await sessionCookie('user-1'),
+ });
+
+ expect(dto.authenticated).toBe(true);
+ expect(dto.tier).toBe('pro');
+ expect(dto.features.coOrganizers).toBe(true);
+ expect(dto.user?.email).toBe('host@example.com');
+ });
+
+ it('degrades an unreadable cookie to anonymous rather than failing', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ cookie: 'session_token=not-a-jwt',
+ });
+
+ expect(dto.authenticated).toBe(false);
+ expect(dto.tier).toBe('free');
+ });
+
+ it('degrades a session whose user no longer exists', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ cookie: await sessionCookie('deleted-user'),
+ });
+
+ expect(dto.authenticated).toBe(false);
+ });
+
+ it('grants pro to a signed-in user on a self-hosted deployment', async () => {
+ const users = fakeUsers([aUser({ tier: 'free' })]);
+ const dto = await getSession({
+ repositories: fakeRepositories(users, fakeLicences()),
+ env: { SELF_HOSTED: 'true' },
+ cookie: await sessionCookie('user-1'),
+ });
+
+ expect(dto.tier).toBe('pro');
+ expect(dto.selfHosted).toBe(true);
+ });
+
+ it('still refuses anonymous callers pro when self-hosted', async () => {
+ const dto = await getSession({
+ repositories: fakeRepositories(),
+ env: { SELF_HOSTED: 'true' },
+ });
+
+ expect(dto.tier).toBe('free');
+ expect(dto.selfHosted).toBe(true);
+ });
+});
diff --git a/backend/src/tests/support/fakeInvites.ts b/backend/src/tests/support/fakeInvites.ts
new file mode 100644
index 0000000..e551d4d
--- /dev/null
+++ b/backend/src/tests/support/fakeInvites.ts
@@ -0,0 +1,422 @@
+import type { PartyDocument, PartyRole } from '../../../../shared/collab';
+import { generateTicketCode } from '../../../../shared/tickets';
+import { apply as applyPatch } from '../../../../shared/patch';
+import type { MergePatch } from '../../../../shared/patch';
+import type {
+ InviteAnswer,
+ InviteStatus,
+ PublishPartyRequest,
+} from '../../../../shared/invites';
+import type {
+ Invite,
+ InviteRepository,
+ InviteWithReferrer,
+} from '../../repositories/inviteRepository';
+import { INVITE_ERRORS } from '../../repositories/inviteRepository';
+import {
+ PARTY_ERRORS,
+ type PartyRepository,
+ type PartySummary,
+ type PublishedParty,
+} from '../../repositories/partyRepository';
+import { err, ok, type Result } from '../../repositories/result';
+
+/**
+ * In-memory parties and invites.
+ *
+ * These reproduce the *rules* — depth from the referrer, one row per returning
+ * browser, capacity refused on confirm but never on decline — because those are
+ * what the route tests are about. They do not reproduce the atomicity the real
+ * statements get from doing the check and the write together; a race is a
+ * property of the SQL, and only the Workers pool can test it.
+ */
+
+let counter = 0;
+const nextId = (prefix: string) => `${prefix}-${++counter}`;
+
+export function resetFakeIds(): void {
+ counter = 0;
+}
+
+export function fakeParties(seed: PublishedParty[] = []): PartyRepository & {
+ rows: Map;
+ /** Membership, so `listForUser` can answer. Set by `fakeMembers`. */
+ memberships: Map>;
+} {
+ const rows = new Map(seed.map((p) => [p.id, p]));
+ const memberships = new Map>();
+
+ return {
+ rows,
+ memberships,
+
+ async publish(
+ ownerId: string,
+ snapshot: PublishPartyRequest,
+ ): Promise> {
+ const now = new Date().toISOString();
+ const doc = snapshot.document;
+
+ for (const row of rows.values()) {
+ if (row.ownerId === ownerId && row.localId === snapshot.localId) {
+ // Republishing keeps the slug, the root token and whether the invite
+ // link is open — links already sent have to keep working, and storing
+ // the party must not reopen one the owner closed.
+ const updated: PublishedParty = {
+ ...row,
+ name: doc.name,
+ date: doc.date,
+ cover: doc.cover,
+ venue: doc.venue,
+ allowForward: doc.allowForward,
+ maxCapacity: doc.settings.max_capacity,
+ document: doc,
+ version: row.version + 1,
+ updatedAt: now,
+ };
+ rows.set(row.id, updated);
+ return ok(updated);
+ }
+ }
+
+ const party: PublishedParty = {
+ id: nextId('party'),
+ ownerId,
+ localId: snapshot.localId,
+ slug: `${doc.name.toLowerCase().replace(/\W+/g, '-')}-${nextId('s')}`,
+ name: doc.name,
+ date: doc.date,
+ cover: doc.cover,
+ venue: doc.venue,
+ allowForward: doc.allowForward,
+ maxCapacity: doc.settings.max_capacity,
+ rootToken: nextId('root'),
+ publishedAt: now,
+ updatedAt: now,
+ document: doc,
+ version: 1,
+ invitesOpen: false,
+ // A stand-in, not a real key: nothing in a route test signs anything.
+ // What the tests care about is that every member gets the same one.
+ ticketKey: { kty: 'oct', k: `key-${nextId('k')}` },
+ };
+ rows.set(party.id, party);
+
+ const members = memberships.get(party.id) ?? new Map();
+ members.set(ownerId, 'owner');
+ memberships.set(party.id, members);
+
+ return ok(party);
+ },
+
+ async findBySlug(slug: string): Promise> {
+ for (const row of rows.values()) {
+ if (row.slug === slug) return ok(row);
+ }
+ return err(PARTY_ERRORS.NOT_FOUND);
+ },
+
+ async findById(id: string): Promise> {
+ const row = rows.get(id);
+ return row ? ok(row) : err(PARTY_ERRORS.NOT_FOUND);
+ },
+
+ async findByOwnerAndLocalId(
+ ownerId: string,
+ localId: number,
+ ): Promise> {
+ for (const row of rows.values()) {
+ if (row.ownerId === ownerId && row.localId === localId) return ok(row);
+ }
+ return err(PARTY_ERRORS.NOT_FOUND);
+ },
+
+ async listForUser(userId: string): Promise> {
+ const out: PartySummary[] = [];
+ for (const row of rows.values()) {
+ const role = memberships.get(row.id)?.get(userId);
+ if (!role) continue;
+ out.push({
+ id: row.id,
+ name: row.name,
+ date: row.date,
+ cover: row.cover,
+ role,
+ version: row.version,
+ updatedAt: row.updatedAt,
+ memberCount: memberships.get(row.id)?.size ?? 1,
+ });
+ }
+ return ok(out);
+ },
+
+ async patchDocument({
+ partyId,
+ patch,
+ }: {
+ partyId: string;
+ patch: MergePatch;
+ }): Promise<
+ Result<{ document: PartyDocument; version: number; updatedAt: string }>
+ > {
+ const row = rows.get(partyId);
+ if (!row) return err(PARTY_ERRORS.NOT_FOUND);
+ if (!row.document) return err(PARTY_ERRORS.NO_DOCUMENT);
+
+ // The same RFC 7386 rules SQLite's json_patch applies, via the shared
+ // implementation — so a test exercises the semantics the real statement
+ // has, even though it cannot exercise its atomicity.
+ const document = applyPatch(
+ row.document as unknown as Record,
+ patch,
+ ) as unknown as PartyDocument;
+ const updatedAt = new Date().toISOString();
+ const updated: PublishedParty = {
+ ...row,
+ document,
+ version: row.version + 1,
+ updatedAt,
+ name: document.name,
+ date: document.date,
+ cover: document.cover,
+ venue: document.venue,
+ allowForward: document.allowForward,
+ maxCapacity: document.settings.max_capacity,
+ };
+ rows.set(partyId, updated);
+ return ok({ document, version: updated.version, updatedAt });
+ },
+
+ async putDocument({
+ partyId,
+ document,
+ }: {
+ partyId: string;
+ document: PartyDocument;
+ }): Promise> {
+ const row = rows.get(partyId);
+ if (!row) return err(PARTY_ERRORS.NOT_FOUND);
+ const updatedAt = new Date().toISOString();
+ rows.set(partyId, {
+ ...row,
+ document,
+ version: row.version + 1,
+ updatedAt,
+ });
+ return ok({ version: row.version + 1, updatedAt });
+ },
+
+ async setInvitesOpen(
+ id: string,
+ open: boolean,
+ ): Promise> {
+ const row = rows.get(id);
+ if (!row) return err(PARTY_ERRORS.NOT_FOUND);
+ const updated = { ...row, invitesOpen: open };
+ rows.set(id, updated);
+ return ok(updated);
+ },
+
+ async deleteById(id: string): Promise> {
+ return rows.delete(id) ? ok(undefined) : err(PARTY_ERRORS.NOT_FOUND);
+ },
+
+ async unpublish(ownerId: string, id: string): Promise> {
+ const row = rows.get(id);
+ if (!row || row.ownerId !== ownerId) return err(PARTY_ERRORS.NOT_FOUND);
+ rows.delete(id);
+ return ok(undefined);
+ },
+ };
+}
+
+export function fakeInvites(seed: Invite[] = []): InviteRepository & {
+ rows: Map;
+} {
+ const rows = new Map(seed.map((i) => [i.id, i]));
+
+ const confirmedCount = (partyId: string, excluding?: string) =>
+ [...rows.values()].filter(
+ (i) =>
+ i.partyId === partyId && i.status === 'confirmed' && i.id !== excluding,
+ ).length;
+
+ return {
+ rows,
+ async open({
+ partyId,
+ referrerToken,
+ rootToken,
+ existingInviteId,
+ }): Promise> {
+ if (existingInviteId) {
+ const existing = rows.get(existingInviteId);
+ if (existing && existing.partyId === partyId) return ok(existing);
+ }
+
+ let depth = 0;
+ let referrerId: string | null = null;
+ if (referrerToken && referrerToken !== rootToken) {
+ for (const row of rows.values()) {
+ if (row.forwardToken === referrerToken && row.partyId === partyId) {
+ referrerId = row.id;
+ depth = row.depth + 1;
+ break;
+ }
+ }
+ }
+
+ const invite: Invite = {
+ id: nextId('invite'),
+ partyId,
+ name: null,
+ status: 'opened',
+ depth,
+ referrerId,
+ forwardToken: nextId('fwd'),
+ ticketCode: generateTicketCode(),
+ source: 'link',
+ checkedIn: false,
+ checkedInAt: null,
+ openedAt: new Date().toISOString(),
+ answeredAt: null,
+ };
+ rows.set(invite.id, invite);
+ return ok(invite);
+ },
+ async findById(id: string): Promise> {
+ const row = rows.get(id);
+ return row ? ok(row) : err(INVITE_ERRORS.NOT_FOUND);
+ },
+ async answer({
+ inviteId,
+ partyId,
+ name,
+ answer,
+ maxCapacity,
+ }: {
+ inviteId: string;
+ partyId: string;
+ name: string;
+ answer: InviteAnswer;
+ maxCapacity: number | null;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+
+ if (
+ answer === 'confirmed' &&
+ maxCapacity !== null &&
+ confirmedCount(partyId, inviteId) >= maxCapacity
+ ) {
+ return err(INVITE_ERRORS.PARTY_FULL);
+ }
+
+ const updated: Invite = {
+ ...row,
+ name,
+ status: answer,
+ answeredAt: new Date().toISOString(),
+ };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+ async setStatus({
+ inviteId,
+ partyId,
+ status,
+ }: {
+ inviteId: string;
+ partyId: string;
+ status: InviteStatus;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+ const updated: Invite = {
+ ...row,
+ status,
+ answeredAt: status === 'opened' ? null : new Date().toISOString(),
+ };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+
+ async addManual({
+ partyId,
+ name,
+ }: {
+ partyId: string;
+ name: string;
+ ticketCode: string;
+ }): Promise> {
+ const now = new Date().toISOString();
+ const invite: Invite = {
+ id: nextId('invite'),
+ partyId,
+ name,
+ status: 'confirmed',
+ depth: 0,
+ referrerId: null,
+ forwardToken: nextId('fwd'),
+ ticketCode: generateTicketCode(),
+ source: 'manual',
+ checkedIn: false,
+ checkedInAt: null,
+ openedAt: now,
+ answeredAt: now,
+ };
+ rows.set(invite.id, invite);
+ return ok(invite);
+ },
+
+ async checkIn({
+ inviteId,
+ partyId,
+ at,
+ }: {
+ inviteId: string;
+ partyId: string;
+ at: string;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+ if (row.checkedIn) return err(INVITE_ERRORS.ALREADY_CHECKED_IN);
+ const updated: Invite = { ...row, checkedIn: true, checkedInAt: at };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+
+ async undoCheckIn({
+ inviteId,
+ partyId,
+ }: {
+ inviteId: string;
+ partyId: string;
+ }): Promise> {
+ const row = rows.get(inviteId);
+ if (!row || row.partyId !== partyId) return err(INVITE_ERRORS.NOT_FOUND);
+ const updated: Invite = { ...row, checkedIn: false, checkedInAt: null };
+ rows.set(inviteId, updated);
+ return ok(updated);
+ },
+
+ async listForParty(partyId: string): Promise> {
+ const list = [...rows.values()]
+ .filter((i) => i.partyId === partyId)
+ .sort(
+ (a, b) =>
+ a.openedAt.localeCompare(b.openedAt) || a.id.localeCompare(b.id),
+ )
+ .map((invite) => ({
+ ...invite,
+ referrerName: invite.referrerId
+ ? (rows.get(invite.referrerId)?.name ?? null)
+ : null,
+ }));
+ return ok(list);
+ },
+ async countConfirmed(partyId: string): Promise> {
+ return ok(confirmedCount(partyId));
+ },
+ };
+}
diff --git a/backend/src/tests/support/fakeMembers.ts b/backend/src/tests/support/fakeMembers.ts
new file mode 100644
index 0000000..5226033
--- /dev/null
+++ b/backend/src/tests/support/fakeMembers.ts
@@ -0,0 +1,124 @@
+import type { PartyRole } from '../../../../shared/collab';
+import {
+ MEMBER_ERRORS,
+ type CollaboratorInvite,
+ type MemberRepository,
+ type PartyMember,
+} from '../../repositories/memberRepository';
+import { err, ok, type Result } from '../../repositories/result';
+import type { User } from '../../repositories/userRepository';
+
+/**
+ * In-memory membership.
+ *
+ * It shares its map with the fake party repository, because `listForUser` has
+ * to answer from the same membership that `roleFor` enforces — two copies would
+ * let a test pass with a party visible in the list that its own guard refuses.
+ */
+export function fakeMembers(
+ memberships: Map>,
+ users: () => Map,
+): MemberRepository & { invites: Map } {
+ const invites = new Map();
+ let counter = 0;
+
+ function toMember(
+ partyId: string,
+ userId: string,
+ role: PartyRole,
+ ): PartyMember {
+ const user = users().get(userId);
+ return {
+ partyId,
+ userId,
+ email: user?.email ?? `${userId}@example.com`,
+ name: user?.name ?? null,
+ picture: user?.picture ?? null,
+ role,
+ addedAt: '2026-01-01T00:00:00.000Z',
+ };
+ }
+
+ return {
+ invites,
+
+ async roleFor(partyId: string, userId: string): Promise> {
+ const role = memberships.get(partyId)?.get(userId);
+ return role ? ok(role) : err(MEMBER_ERRORS.NOT_A_MEMBER);
+ },
+
+ async listMembers(partyId: string): Promise> {
+ const rows = memberships.get(partyId);
+ if (!rows) return ok([]);
+ const members = [...rows.entries()].map(([userId, role]) =>
+ toMember(partyId, userId, role),
+ );
+ members.sort(
+ (a, b) => Number(b.role === 'owner') - Number(a.role === 'owner'),
+ );
+ return ok(members);
+ },
+
+ async add({
+ partyId,
+ userId,
+ role,
+ }: {
+ partyId: string;
+ userId: string;
+ role: PartyRole;
+ }): Promise> {
+ const rows = memberships.get(partyId) ?? new Map();
+ // Never demote: an owner opening their own invite link stays the owner.
+ if (!rows.has(userId)) rows.set(userId, role);
+ memberships.set(partyId, rows);
+ return ok(toMember(partyId, userId, rows.get(userId)!));
+ },
+
+ async remove(partyId: string, userId: string): Promise> {
+ const rows = memberships.get(partyId);
+ const role = rows?.get(userId);
+ if (!rows || !role) return err(MEMBER_ERRORS.NOT_FOUND);
+ if (role === 'owner') return err(MEMBER_ERRORS.CANNOT_REMOVE_OWNER);
+ rows.delete(userId);
+ return ok(undefined);
+ },
+
+ async createInvite({
+ partyId,
+ createdBy,
+ }: {
+ partyId: string;
+ createdBy: string;
+ }): Promise> {
+ const invite: CollaboratorInvite = {
+ token: `collab-${++counter}`,
+ partyId,
+ createdBy,
+ createdAt: new Date().toISOString(),
+ revokedAt: null,
+ };
+ invites.set(invite.token, invite);
+ return ok(invite);
+ },
+
+ async findInvite(token: string): Promise> {
+ const invite = invites.get(token);
+ if (!invite) return err(MEMBER_ERRORS.INVITE_UNKNOWN);
+ if (invite.revokedAt !== null) return err(MEMBER_ERRORS.INVITE_REVOKED);
+ return ok(invite);
+ },
+
+ async revokeInvitesFor(partyId: string): Promise> {
+ for (const [token, invite] of invites) {
+ if (invite.partyId === partyId && invite.revokedAt === null) {
+ invites.set(token, {
+ ...invite,
+ revokedAt: new Date().toISOString(),
+ });
+ }
+ }
+ return ok(undefined);
+ },
+ };
+}
diff --git a/backend/src/tests/support/fakeRepositories.ts b/backend/src/tests/support/fakeRepositories.ts
new file mode 100644
index 0000000..3c9fa54
--- /dev/null
+++ b/backend/src/tests/support/fakeRepositories.ts
@@ -0,0 +1,124 @@
+import type { Tier } from '../../../../shared/tiers';
+import { fakeInvites, fakeParties } from './fakeInvites';
+import { fakeMembers } from './fakeMembers';
+import type {
+ LicenceKey,
+ LicenceRepository,
+} from '../../repositories/licenceRepository';
+import { LICENCE_ERRORS } from '../../repositories/licenceRepository';
+import type { Repositories } from '../../repositories/repositories';
+import { err, ok, type Result } from '../../repositories/result';
+import type {
+ ProviderIdentity,
+ User,
+ UserRepository,
+} from '../../repositories/userRepository';
+import { USER_ERRORS } from '../../repositories/userRepository';
+
+/**
+ * In-memory stand-ins, so a route test says what it is about — a tier, a
+ * cookie, a guard — instead of setting up a database to say it.
+ *
+ * They implement the same interfaces the D1 classes do, which is the point of
+ * those interfaces existing: what these cannot catch is a mistake in the SQL,
+ * and nothing else.
+ */
+export function fakeUsers(seed: User[] = []): UserRepository & {
+ rows: Map;
+} {
+ const rows = new Map(seed.map((u) => [u.id, u]));
+
+ return {
+ rows,
+ async findById(id: string): Promise> {
+ const found = rows.get(id);
+ return found ? ok(found) : err(USER_ERRORS.NOT_FOUND);
+ },
+ async upsertByIdentity(
+ identity: ProviderIdentity,
+ ): Promise> {
+ for (const user of rows.values()) {
+ if (user.email === identity.email) return ok({ user, isNew: false });
+ }
+ const now = new Date().toISOString();
+ const user: User = {
+ id: `user-${rows.size + 1}`,
+ email: identity.email,
+ name: identity.name ?? null,
+ picture: identity.picture ?? null,
+ tier: 'free',
+ createdAt: now,
+ updatedAt: now,
+ };
+ rows.set(user.id, user);
+ return ok({ user, isNew: true });
+ },
+ async setTier(id: string, tier: Tier): Promise> {
+ const found = rows.get(id);
+ if (!found) return err(USER_ERRORS.NOT_FOUND);
+ const updated = { ...found, tier, updatedAt: new Date().toISOString() };
+ rows.set(id, updated);
+ return ok(updated);
+ },
+ };
+}
+
+export function fakeLicences(seed: LicenceKey[] = []): LicenceRepository {
+ const rows = new Map(seed.map((l) => [l.code, l]));
+
+ return {
+ async redeem(code: string, userId: string): Promise> {
+ const found = rows.get(code.trim().toUpperCase());
+ if (!found) return err(LICENCE_ERRORS.UNKNOWN);
+ if (found.redeemedBy === userId) return ok(found);
+ if (found.redeemedAt !== null)
+ return err(LICENCE_ERRORS.ALREADY_REDEEMED);
+ const claimed: LicenceKey = {
+ ...found,
+ redeemedAt: new Date().toISOString(),
+ redeemedBy: userId,
+ };
+ rows.set(claimed.code, claimed);
+ return ok(claimed);
+ },
+ };
+}
+
+export function fakeRepositories(
+ users = fakeUsers(),
+ licences = fakeLicences(),
+ parties = fakeParties(),
+ invites = fakeInvites(),
+): Repositories {
+ // Membership shares the party repository's map: `listForUser` must answer
+ // from the same membership `roleFor` enforces, or a test could pass with a
+ // party listed that its own guard refuses to open.
+ const members = fakeMembers(parties.memberships, () => users.rows);
+ return { users, licences, parties, invites, members };
+}
+
+export function aUser(overrides: Partial = {}): User {
+ const now = '2026-01-01T00:00:00.000Z';
+ return {
+ id: 'user-1',
+ email: 'host@example.com',
+ name: 'Host',
+ picture: null,
+ tier: 'free',
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
+
+export function aLicence(overrides: Partial = {}): LicenceKey {
+ return {
+ code: 'BC-TEST-0001',
+ tier: 'pro',
+ issuedAt: '2026-01-01T00:00:00.000Z',
+ redeemedAt: null,
+ redeemedBy: null,
+ note: null,
+ ...overrides,
+ };
+}
diff --git a/backend/src/tests/support/harness.ts b/backend/src/tests/support/harness.ts
new file mode 100644
index 0000000..4e5152d
--- /dev/null
+++ b/backend/src/tests/support/harness.ts
@@ -0,0 +1,65 @@
+import { sign } from 'hono/jwt';
+import { createApp, type Bindings } from '../../app';
+import type { Repositories } from '../../repositories/repositories';
+
+export const JWT_SECRET = 'test-secret';
+
+/**
+ * Bindings for a case. `db` is present only to satisfy the type — every test
+ * passes fake repositories, so nothing ever reaches through it.
+ */
+export function testEnv(overrides: Partial = {}): Bindings {
+ return {
+ db: null as unknown as D1Database,
+ GOOGLE_CLIENT_ID: 'client-id',
+ GOOGLE_CLIENT_SECRET: 'client-secret',
+ JWT_SECRET,
+ FRONTEND_URL: 'http://localhost:4321',
+ ENVIRONMENT: 'test',
+ ...overrides,
+ };
+}
+
+/** A `session_token` cookie header for `userId`. */
+export async function sessionCookie(userId: string): Promise {
+ const token = await sign(
+ {
+ sub: userId,
+ email: 'host@example.com',
+ name: 'Host',
+ picture: null,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ },
+ JWT_SECRET,
+ 'HS256',
+ );
+ return `session_token=${token}`;
+}
+
+export interface RequestOptions {
+ repositories: Repositories;
+ env?: Partial;
+ cookie?: string;
+ method?: string;
+ body?: unknown;
+}
+
+export async function request(
+ path: string,
+ { repositories, env, cookie, method = 'GET', body }: RequestOptions,
+): Promise {
+ const app = createApp({ repositories });
+ const headers: Record = {};
+ if (cookie) headers['cookie'] = cookie;
+ if (body !== undefined) headers['content-type'] = 'application/json';
+
+ return app.request(
+ `http://localhost${path}`,
+ {
+ method,
+ headers,
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ },
+ testEnv(env),
+ );
+}
diff --git a/backend/src/tests/support/party.ts b/backend/src/tests/support/party.ts
new file mode 100644
index 0000000..4c3f213
--- /dev/null
+++ b/backend/src/tests/support/party.ts
@@ -0,0 +1,86 @@
+import { expect } from 'vitest';
+import type { PartyDocument, SharedPartyDTO } from '../../../../shared/collab';
+import type { Repositories } from '../../repositories/repositories';
+import { fakeInvites, fakeParties } from './fakeInvites';
+import { aUser, fakeLicences, fakeUsers } from './fakeRepositories';
+import { fakeMembers } from './fakeMembers';
+import { request, sessionCookie } from './harness';
+
+/** A document with everything filled in, so tests only state what they vary. */
+export function aDocument(
+ overrides: Partial = {},
+): PartyDocument {
+ return {
+ name: 'Rooftop',
+ date: '2026-10-02',
+ cover: 1,
+ venue: { place: 'The Roof', city: 'Milan', time: '21:00' },
+ settings: {
+ guests: 40,
+ ticket_price: 15,
+ venue_cost: 0,
+ equipment_cost: 0,
+ alcohol_ml_per_person: 50,
+ buffer: 1.1,
+ max_capacity: null,
+ },
+ menu: { Vodka: { macro_pct: 1, spirits: {} } },
+ locks: {},
+ checked: {},
+ allowForward: true,
+ includeSnacks: true,
+ ...overrides,
+ };
+}
+
+/**
+ * Repositories wired the way the app wires them — one membership map shared
+ * between parties and members, so what a test can list is what a guard allows.
+ */
+export function repositoriesWith(
+ users = fakeUsers([aUser({ tier: 'pro' })]),
+): Repositories {
+ const parties = fakeParties();
+ return {
+ users,
+ licences: fakeLicences(),
+ parties,
+ invites: fakeInvites(),
+ members: fakeMembers(parties.memberships, () => users.rows),
+ };
+}
+
+/** Publishes a party as `userId` and returns what the server said. */
+export async function publishParty(
+ repositories: Repositories,
+ userId = 'user-1',
+ document = aDocument(),
+ localId = 7,
+): Promise {
+ const res = await request('/api/parties', {
+ repositories,
+ cookie: await sessionCookie(userId),
+ method: 'POST',
+ body: { localId, document },
+ });
+ expect(res.status).toBe(200);
+ return (await res.json()) as SharedPartyDTO;
+}
+
+/** Publishes and opens the guest-facing link, which storing alone does not. */
+export async function publishAndOpenInvites(
+ repositories: Repositories,
+ userId = 'user-1',
+ document = aDocument(),
+ localId = 7,
+): Promise<{ party: SharedPartyDTO; slug: string; rootToken: string }> {
+ const party = await publishParty(repositories, userId, document, localId);
+ const res = await request(`/api/parties/${party.id}/invite-link`, {
+ repositories,
+ cookie: await sessionCookie(userId),
+ method: 'POST',
+ });
+ expect(res.status).toBe(200);
+ const link = (await res.json()) as { slug: string; rootToken: string };
+ return { party, slug: link.slug, rootToken: link.rootToken };
+}
diff --git a/backend/src/tests/tiers.spec.ts b/backend/src/tests/tiers.spec.ts
new file mode 100644
index 0000000..688b266
--- /dev/null
+++ b/backend/src/tests/tiers.spec.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import {
+ FEATURES,
+ featuresFor,
+ isTier,
+ resolveTier,
+} from '../../../shared/tiers';
+
+describe('tier resolution', () => {
+ it('treats a caller with no account as free', () => {
+ expect(resolveTier({ storedTier: null, selfHosted: false })).toBe('free');
+ });
+
+ it('does not promote an anonymous caller on a self-hosted deployment', () => {
+ // Co-organisers and invite links need to know who is who even when the
+ // server is yours, so self-hosting grants pro on sign-in, not on arrival.
+ expect(resolveTier({ storedTier: null, selfHosted: true })).toBe('free');
+ });
+
+ it('honours the stored tier on the hosted deployment', () => {
+ expect(resolveTier({ storedTier: 'free', selfHosted: false })).toBe('free');
+ expect(resolveTier({ storedTier: 'pro', selfHosted: false })).toBe('pro');
+ });
+
+ it('promotes any signed-in user of a self-hosted deployment to pro', () => {
+ expect(resolveTier({ storedTier: 'free', selfHosted: true })).toBe('pro');
+ });
+});
+
+describe('feature sets', () => {
+ it('locks every paid feature on free', () => {
+ const free = featuresFor('free');
+ for (const feature of FEATURES) expect(free[feature]).toBe(false);
+ });
+
+ it('unlocks every feature on pro', () => {
+ const pro = featuresFor('pro');
+ for (const feature of FEATURES) expect(pro[feature]).toBe(true);
+ });
+
+ it('covers every declared feature in both tiers', () => {
+ // Adding a feature to FEATURES without adding it to both tables would
+ // otherwise leave it `undefined`, which reads as locked for pro users too.
+ for (const tier of ['free', 'pro'] as const) {
+ const set = featuresFor(tier);
+ for (const feature of FEATURES) {
+ expect(typeof set[feature]).toBe('boolean');
+ }
+ }
+ });
+});
+
+describe('isTier', () => {
+ it('accepts the known tiers and nothing else', () => {
+ expect(isTier('free')).toBe(true);
+ expect(isTier('pro')).toBe(true);
+ expect(isTier('enterprise')).toBe(false);
+ expect(isTier(undefined)).toBe(false);
+ });
+});
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
new file mode 100644
index 0000000..dbed01f
--- /dev/null
+++ b/backend/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*.ts", "../shared/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts
new file mode 100644
index 0000000..f4a2c4f
--- /dev/null
+++ b/backend/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vitest/config';
+
+/**
+ * Plain Vitest, not `@cloudflare/vitest-pool-workers`.
+ *
+ * The pool would run these against a real Workers runtime and a real local D1,
+ * which is the right way to test the SQL in `repositories/d1/`. It is not used
+ * here yet because its current release peers on Vitest 4 and this project is on
+ * 5; the tests below therefore exercise routing, the session guard and the
+ * entitlement rules through fake repositories, and the D1 statements are
+ * covered only by `wrangler d1 migrations apply --local` in development.
+ *
+ * Swapping the pool back in is a config change and a `support/` swap, not a
+ * rewrite: nothing in the tests reaches for a binding directly.
+ */
+export default defineConfig({
+ test: {
+ include: ['src/tests/**/*.spec.ts'],
+ environment: 'node',
+ },
+});
diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc
new file mode 100644
index 0000000..a03fa78
--- /dev/null
+++ b/backend/wrangler.jsonc
@@ -0,0 +1,166 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "bottlecount-backend",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-03-17",
+
+ // Every environment repeats the whole block on purpose: Wrangler does not
+ // inherit bindings into named environments, so an overlay would silently
+ // deploy a Worker with no database.
+ "env": {
+ // `wrangler dev --env local`. D1 runs against a local SQLite file, so the
+ // `database_id` is only a placeholder until you create a real one.
+ "local": {
+ "d1_databases": [
+ {
+ "binding": "db",
+ "database_name": "db",
+ "database_id": "00000000-0000-0000-0000-000000000000",
+ },
+ ],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
+ "vars": {
+ "FRONTEND_URL": "http://localhost:4321",
+ "ENVIRONMENT": "local",
+ // Unlocks POST /auth/dev so a fresh clone can sign in without
+ // registering a Google OAuth client.
+ "SELF_HOSTED": "true",
+ },
+ },
+
+ "preview": {
+ "name": "bottlecount-backend-preview",
+ "d1_databases": [
+ {
+ "binding": "db",
+ "database_name": "db-preview",
+ // Fill in after `wrangler d1 create db-preview`.
+ "database_id": "",
+ },
+ ],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
+ "vars": {
+ "FRONTEND_URL": "https://preview.bottlecount.pages.dev",
+ "ENVIRONMENT": "preview",
+ "SELF_HOSTED": "false",
+ "GOOGLE_CLIENT_ID": "",
+ },
+ },
+
+ "production": {
+ "name": "bottlecount-backend",
+ "d1_databases": [
+ {
+ "binding": "db",
+ "database_name": "db",
+ // Fill in after `wrangler d1 create db`.
+ "database_id": "",
+ },
+ ],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
+ "vars": {
+ "FRONTEND_URL": "https://bottlecount.pages.dev",
+ "ENVIRONMENT": "production",
+ // The hosted deployment is the one people pay for, so this is the one
+ // place it must stay "false" — see shared/tiers.ts.
+ "SELF_HOSTED": "false",
+ // Not a secret (GOOGLE_CLIENT_SECRET and JWT_SECRET are, and are set
+ // with `wrangler secret put`).
+ "GOOGLE_CLIENT_ID": "",
+ },
+ },
+
+ // What a self-hoster deploys. Identical to production except that
+ // SELF_HOSTED grants `pro` to every signed-in user and enables /auth/dev.
+ "selfhosted": {
+ "name": "bottlecount-backend",
+ "d1_databases": [
+ {
+ "binding": "db",
+ "database_name": "db",
+ "database_id": "",
+ },
+ ],
+ // Guards the two unauthenticated invite endpoints. They are the only
+ // routes on the Worker that write without an account behind them, and
+ // each open() creates a row, so an unthrottled loop could inflate a
+ // host's funnel or fill the table. Keyed on IP, because there is no
+ // account to key on. The platform only supports a 10s or 60s period, so
+ // this bounds the rate rather than a daily total; 30/minute is far above
+ // a person opening a link and answering, and far below a script.
+ "unsafe": {
+ "bindings": [
+ {
+ "name": "INVITE_RATE_LIMITER",
+ "type": "ratelimit",
+ // Namespace ids only have to be unique within the Worker.
+ "namespace_id": "2001",
+ "simple": { "limit": 30, "period": 60 },
+ },
+ ],
+ },
+ "vars": {
+ "FRONTEND_URL": "",
+ "ENVIRONMENT": "production",
+ "SELF_HOSTED": "true",
+ "GOOGLE_CLIENT_ID": "",
+ },
+ },
+ },
+
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ },
+}
diff --git a/docs/adr/0001-cloudflare-tiers.md b/docs/adr/0001-cloudflare-tiers.md
new file mode 100644
index 0000000..47ab607
--- /dev/null
+++ b/docs/adr/0001-cloudflare-tiers.md
@@ -0,0 +1,107 @@
+# ADR 0001 — Cloudflare, and three ways to run BottleCount
+
+Status: accepted
+Date: 2026-09-18
+
+## Context
+
+BottleCount was built as a static site with no backend at all: Astro and Vue on
+GitHub Pages, every byte of user data in IndexedDB, tickets signed with a
+locally generated HMAC key. That is a genuinely good product for one person
+planning one party, and it is why the app has no sign-in.
+
+It also caps the product. Three of the features the landing page advertises
+cannot work without a server, and today two of them are mockups:
+
+- **The invite link.** `ShareModal` builds a `…/i/-` URL and copies it
+ to the clipboard. Nothing serves that URL. It cannot be served from a static
+ bundle, because the person opening it is not the person who has the party in
+ their IndexedDB.
+- **The RSVP funnel and the spread view.** Both count guests who arrived through
+ an invite link. With no link, they count a list the host typed in themselves.
+- **Co-organisers.** Never started. A second organiser needs to open the same
+ party from their own device.
+
+The Google Sheets sync that once backed multi-scanner check-in is gone from the
+UI — only two unused helpers in `lib/crypto.ts` and some stale prose in the
+README and privacy policy still refer to it. Asking each host to stand up their
+own Apps Script was never a good answer to "where does shared state live".
+
+## Decision
+
+Move to Cloudflare — Pages for the frontend, a Hono Worker for the API, D1 for
+storage — and sell hosting rather than software, the way n8n does.
+
+Three ways to run it:
+
+| | Who signs in | Where data lives | Paid features |
+| --------------- | ---------------------- | ---------------- | ------------------------------ |
+| **Browser** | nobody | IndexedDB | locked |
+| **Hosted** | Google | D1 | unlocked by a one-time payment |
+| **Self-hosted** | Google, or `/auth/dev` | your D1 | unlocked, free |
+
+### The free tier does not log in
+
+This is the constraint everything else bends around. A free user has no account,
+so `GET /api/session` answers anonymous callers with a 200 and a free feature
+set rather than a 401, and every failure to reach it — no backend deployed, a
+Worker that is down, an offline phone — resolves to the same anonymous session
+rather than an error. The planner has to work when the part of it that is meant
+to be optional is missing.
+
+### One table decides what is locked
+
+`shared/tiers.ts` is imported by both the Worker and the frontend. A capability
+the UI hides but the API still serves is a paywall that leaks; one the API
+refuses but the UI offers is a bug report. Both sides reading the same table is
+the only version of this that stays honest.
+
+### Self-hosting grants `pro` on sign-in, not on arrival
+
+`SELF_HOSTED=true` promotes every _signed-in_ user to `pro`. It deliberately
+does not promote anonymous ones: co-organisers and an invite funnel need to know
+who is who even when the server is yours. `/auth/dev` exists so a self-hoster
+can sign in without registering a Google OAuth client.
+
+### Payment is modelled, not yet integrated
+
+`licence_keys` rows are minted by hand (`npm run licence:issue`) and redeemed at
+`POST /api/licences/redeem`, which flips the user's tier. No checkout provider
+is chosen. When one is, its webhook inserts the same rows and nothing else
+changes — which is the point of putting the seam here rather than in the gate.
+
+### Same-origin by service binding
+
+Pages Functions under `functions/` forward `/api/*` and `/auth/*` to the Worker
+over a service binding. That is an internal dispatch, not a network hop, so the
+browser only ever talks to the Pages domain: the `session_token` cookie is
+first-party, `SameSite=Lax` suffices, and no CORS preflight stands between a
+user and signing in. Pointing the frontend straight at `*.workers.dev` would
+make every session cross-site.
+
+The `/auth/*` proxies are three named files rather than one catchall because a
+catchall would also swallow `/auth/callback`, which is a static page — producing
+a 404 at the last step of every sign-in.
+
+## Consequences
+
+- **Parties are still local for everyone.** This change carries accounts, tiers
+ and the gate; it does not move party data. `cloudSync` is therefore declared
+ and locked but not yet backed by anything, and the migration that adds the
+ `parties` tables is deliberately not in `0001` — D1 migrations are
+ append-only, and a shape invented ahead of its first consumer is a shape you
+ migrate away from.
+- **The invite link still resolves to nothing.** It is now gated behind `pro`
+ and built from the real origin instead of a hard-coded `bottlecount.app`, but
+ the `/i/` route that serves it lands with the party-data work.
+- **GitHub Pages becomes the documentation host** and stops serving the app.
+ The same source builds for both; `BUILD_TARGET=docs` sets the root and drops
+ the application routes from the output, since every one of them needs the
+ Worker (superseded in detail by ADR 0002's consequences).
+- **The D1 SQL is not covered by tests.** `@cloudflare/vitest-pool-workers`
+ currently peers on Vitest 4 while this project is on 5, so the route tests run
+ on plain Vitest against fake repositories. They cover routing, the session
+ guard and the tier rules; they cannot catch a mistake in a SQL statement. See
+ `backend/vitest.config.ts`.
+- **Two deploy targets to keep in step.** The Worker must be deployed before
+ Pages on a first run, because the service binding resolves by name.
diff --git a/docs/adr/0002-invite-links-and-the-funnel.md b/docs/adr/0002-invite-links-and-the-funnel.md
new file mode 100644
index 0000000..c267c7e
--- /dev/null
+++ b/docs/adr/0002-invite-links-and-the-funnel.md
@@ -0,0 +1,110 @@
+# ADR 0002 — Invite links, and what the funnel counts
+
+Status: accepted
+Date: 2026-09-18
+Follows: [ADR 0001](0001-cloudflare-tiers.md)
+
+## Context
+
+ADR 0001 built the accounts and the paywall but left the three paid features
+locked and empty. This one fills in two of them: the invite link, and the RSVP
+funnel that counts what happens to it.
+
+The funnel already existed as a UI — four columns and a spread view — reading a
+list the host typed in themselves. Its columns were therefore fiction: "Reached"
+counted people the host had entered, and "Maybe" meant "the host has not heard
+back", which is not a thing a local array can know.
+
+## Decision
+
+### The server holds the invitation, not the party
+
+Publishing a party stores what an invitation card shows — name, date, venue,
+cover, forwarding, capacity — and nothing else. The menu, the shopping list, the
+costs and the locks stay in the host's browser.
+
+This is the smallest thing that makes a link work, and it bounds the damage from
+a leaked slug to "a stranger learns there is a party". It is also why `parties`
+in migration `0002` is not the `Party` type: it is the invitation, and the two
+should not be confused when cloud sync arrives.
+
+### A row is created on open, not on answer
+
+`POST /invite/:slug/open` writes. That is the whole reason "Reached" can be a
+real number: a row that only appears when somebody answers cannot count the
+people who looked and left, and those are exactly the people a host wants to
+chase.
+
+It also renames the states. `accepted`/`pending`/`declined` became
+`confirmed`/`opened`/`declined`, because `pending` used to mean "the host is
+waiting to hear" and now means "they opened the link and stopped". Parties saved
+before this are migrated on load (`store.ts`), since a funnel over the old words
+counts nothing.
+
+### Depth comes from the referrer, and the host is depth 0
+
+Every invite gets a `forward_token`. The host's link carries the party's
+`root_token` and produces depth 0; a guest's own link produces their depth + 1.
+An unrecognised token falls back to depth 0 rather than erroring — the usual
+cause is a link from a party that has since been unpublished, and that guest
+should still be able to RSVP.
+
+A forward token is only handed out once a guest **confirms**. Otherwise someone
+who never replied could seed a referral tree.
+
+### Capacity is checked inside the write
+
+`UPDATE … WHERE (SELECT COUNT(*) … ) < ?` rather than a count followed by an
+update, because two guests racing for the last place would both read "one left".
+Declining is never refused: a full party is still one you can say no to, and
+refusing would strand the row at `opened` and overstate the "maybe" column.
+
+### Identity is the URL, plus one id in `localStorage`
+
+Guests have no account — being able to RSVP without signing up is most of what an
+invite link is for — so the URL is the only credential, and the handlers return
+nothing a link holder should not see: no other guests' names, no owner, no
+budget. The browser keeps its `inviteId` so a reload is the same guest rather
+than a second one; a private window loses it and is counted again, which
+overstates "Reached" slightly and is much better than refusing the RSVP.
+
+The two public endpoints are the only routes on the Worker that write without an
+account behind them, so they sit behind a rate limit binding keyed on IP.
+
+### The host can override, and it has to reach the server
+
+Hosts hear from guests off-platform. Without `PATCH /api/parties/:id/invites/:id`
+the host's Accept button would be overwritten by the next poll twenty seconds
+later — a button that appears to work and then quietly undoes itself. The
+override ignores capacity, because the host is the authority on their own door.
+
+### One guest list, not two
+
+`mergeFunnel` folds the server's invites into `party.invites`, matching on
+`remoteId` and leaving rows without one alone. Those are the guests the host
+typed in by hand, which works on every tier and must survive a refresh that has
+never heard of them. Keeping one array means the guest list, the ticket flow, the
+door scanner and the KPI bar did not need to learn about a second source.
+
+## Consequences
+
+- **Publishing happens on every share-sheet open**, so a renamed party or moved
+ venue reaches guests without a separate "update" button. The slug and root
+ token are excluded from the update, or every link already sent would break.
+- **Unpublishing deletes the party and its invites.** Guests already merged into
+ the host's local list stay there — they are still coming — but they can no
+ longer change their answer.
+- **`/i/` needs a Pages Function.** Slugs are minted at runtime, so
+ `getStaticPaths` cannot know them; `functions/i/[[slug]].ts` rewrites the whole
+ space onto one built page, which reads the slug off the URL. It therefore only
+ works on Cloudflare, which is where the application lives — the documentation
+ build has no `/i` in it at all.
+- **Check-in state is still local.** The server has no idea the door scanner
+ exists, so `mergeFunnel` carries `used`/`usedAt` across refreshes rather than
+ letting the server blank them. Multi-device scanning (`doorScannerSync`) is
+ still declared and locked.
+- **The SQL is still untested.** The race conditions these statements are written
+ to survive — two confirmations for the last place, two devices republishing —
+ are properties of the statements, and the fakes cannot reproduce them. The flow
+ was verified by hand against a local D1; covering it properly still needs
+ `@cloudflare/vitest-pool-workers` (see `backend/vitest.config.ts`).
diff --git a/docs/adr/0003-co-organisers.md b/docs/adr/0003-co-organisers.md
new file mode 100644
index 0000000..0fe22bf
--- /dev/null
+++ b/docs/adr/0003-co-organisers.md
@@ -0,0 +1,117 @@
+# ADR 0003 — Co-organisers, and how two people edit one party
+
+Status: accepted
+Date: 2026-09-18
+Follows: [ADR 0002](0002-invite-links-and-the-funnel.md)
+
+## Context
+
+Co-organisers was the last locked feature, and the only one that could not be
+built on what came before. ADR 0001 left every party in its host's browser; ADR
+0002 put the _invitation card_ on the server — name, date, venue — which is
+enough for a guest to RSVP and useless to a second organiser, who needs the
+menu, the budget and the shopping list.
+
+So this is where the party itself moves to D1. That drags in three questions the
+earlier increments could avoid: what shape it is stored in, who may open it, and
+what happens when two people change it at once.
+
+## Decision
+
+### The party is a document, not a schema
+
+One JSON column, not a table per level. That is already what it is everywhere
+else: the client keeps it in IndexedDB as one object, the calculator reads it
+whole, and nothing queries inside it. Normalising a menu into categories,
+spirits and cocktails would buy queries nobody makes and cost a join per slider.
+
+The card columns (`name`, `date`, `cover`, venue, `allow_forward`,
+`max_capacity`) still exist, because the guest-facing page and the party list
+want them without parsing JSON. They are **derived on read** — `toParty` prefers
+the document, and `listForUser` uses `json_extract` with the column as fallback.
+Only one copy is ever believed, so a patch does not have to keep the other in
+step and the two cannot drift. An earlier draft did mirror them on write, and it
+needed nine `json_patch` evaluations per save to do it.
+
+### Edits travel as merge patches
+
+The obvious design — send the document, last writer wins — loses work silently:
+one organiser builds the menu while the other sets the guest count, and whoever
+saves second erases the first. Optimistic locking with a version instead turns
+that into a rejected write, which is a merge conflict the user has to resolve
+over a change that merges perfectly well.
+
+So a save sends only what changed, as an RFC 7386 merge patch
+(`shared/patch.ts`). Edits to different fields merge; only edits to the _same_
+field are last-writer-wins, which is what anyone expects from two people typing
+in one box.
+
+Two things make this work:
+
+- **The document has no arrays.** Menu, locks and check-offs are keyed records,
+ and the guest list is deliberately _not_ in the document — it lives in the
+ invites table and arrives through the funnel. Merge patches replace arrays
+ wholesale, so a guest list in here would have let two organisers clobber each
+ other's RSVPs.
+- **SQLite's `json_patch` is RFC 7386.** The server applies patches with it, in
+ the same statement that reads and writes the document, so two saves landing at
+ once cannot both read the same version. The client uses the shared
+ implementation for its own bookkeeping. Neither reimplements the other.
+
+`baseVersion` is reported on, not enforced: a client that had fallen behind gets
+the merged document back to catch up, rather than an error.
+
+### Membership is the authorisation, and a 404 is the refusal
+
+Every party route is keyed on the server's id, not the owner's local one — a
+co-organiser has their own IndexedDB numbering and it means nothing here. A
+caller who is not a member gets **404, not 403**, so a party id cannot be probed
+for existence.
+
+`owner` may do everything. `editor` may edit the party, read the funnel, and
+open the guest link — that is running the party, which is the job. They may not
+delete it, close a link the owner opened, or add and remove people: those are
+the actions that take the party away from everybody else. An editor who could
+invite could add back someone the owner had just removed, which would make
+removal meaningless.
+
+### A co-organiser does not pay
+
+The tier check gates **creating** a cloud party, not opening one. The party
+belongs to someone who has already paid, and charging both people to run one
+party would make the feature useless — you cannot co-organise alone. What a free
+co-organiser does not get is parties of their own.
+
+### Storing a party is not opening it to guests
+
+These were one act in ADR 0002, where publishing existed only for the invite
+link. Now that sharing with a co-organiser also stores the party, they have to
+separate: otherwise a party synced so two people could plan it would quietly
+accept RSVPs through a slug its owner never showed anyone. Hence `invites_open`,
+and `POST /api/parties/:id/invite-link` as its own step.
+
+## Consequences
+
+- **`cloudSync` is now real**, and the last locked feature is gone. A signed-in
+ user's parties follow them to any device they sign in on, because
+ `syncPartyList` pulls anything the server has that this browser does not.
+- **Check-in state stays local.** The door scanner is client-side and the server
+ knows nothing about it, so `used`/`usedAt` are carried across funnel refreshes
+ rather than synced. `doorScannerSync` remains declared and unimplemented.
+- **A local-only party is still the free tier's whole story.** Nothing is
+ uploaded until someone shares a party or opens an invite link.
+- **Deleting a shared party deletes it for everyone**, which is why the client
+ calls the server before removing it locally — otherwise a co-organiser keeps
+ it and it reappears on the next list sync.
+- **The document is capped at 256KB.** A menu is user-authored and unbounded in
+ principle; without a cap one party could become a denial-of-service against
+ the row it lives in.
+- **Conflicts on the same field are silent.** Two organisers dragging the same
+ slider is last-writer-wins with no warning. That is the right default for a
+ shared control, but it means the UI shows _that_ a change is unsaved (the
+ header's "Saving…"/"Not saved" chip) and not _whose_ change won.
+- **The SQL still carries the concurrency guarantees the fakes cannot test.**
+ The fakes reproduce the merge rules through the same shared implementation,
+ but do the read and the write separately. The atomicity was verified by hand:
+ two simultaneous patches from one base version, both surviving. Proving it
+ in CI still needs `@cloudflare/vitest-pool-workers`.
diff --git a/docs/adr/0004-shared-doors-and-ticket-codes.md b/docs/adr/0004-shared-doors-and-ticket-codes.md
new file mode 100644
index 0000000..f56bfd2
--- /dev/null
+++ b/docs/adr/0004-shared-doors-and-ticket-codes.md
@@ -0,0 +1,118 @@
+# ADR 0004 — Shared doors, and a code you can read out loud
+
+Status: accepted
+Date: 2026-09-18
+Follows: [ADR 0003](0003-co-organisers.md)
+
+## Context
+
+`doorScannerSync` was the last feature the pricing page sold and the product did
+not deliver. Worse than not delivering it: `featuresFor('pro').doorScannerSync`
+returned `true`, so a paying user's UI did not gate it. They simply got a
+scanner that disagreed with their co-organiser's.
+
+Two separate things were wrong, and the smaller one was the more visible:
+
+- **Check-in never left the device.** `invites.checked_in` existed from
+ migration 0002 and nothing ever wrote it. Each phone kept its own tally, so
+ the second phone would happily admit someone the first had already scanned.
+- **Tickets were signed per device.** The HMAC key was generated into whichever
+ browser first issued a ticket, and the ticket was identified by that browser's
+ own numbering for the guest. A co-organiser's phone therefore could not verify
+ _anything_ the owner's phone had produced. It would not have double-admitted
+ people; it would have rejected all of them.
+
+There was also a hole left by ADR 0003 that only shows up here: a guest an
+organiser typed in existed solely in the typing browser. The co-organiser never
+saw them, and no second phone could check their ticket.
+
+## Decision
+
+### The signing key belongs to the party, and lives in D1
+
+`parties.ticket_key` holds an HMAC-SHA256 key as a JWK, generated **server-side**
+the first time a party is stored, and handed to every member in
+`SharedPartyDTO`. It is deliberately excluded from the publish upsert's update
+set: rotating it on a save would invalidate every ticket already sitting in a
+guest's phone.
+
+It is a shared secret among organisers, which is the right scope. Holding it
+means being able to mint a ticket — and anyone holding it can already add a
+guest through the API, so it grants nothing they did not have. It is never sent
+to a guest.
+
+Verification stays **client-side** rather than becoming an API call. Doors are
+in basements; once the key is fetched the scanner works with no signal, which is
+the property the HMAC was there for in the first place. Check-in is the part
+that needs the network, and it degrades gracefully (below).
+
+### Tickets are keyed by a five-character code
+
+`invites.ticket_code`, unique per party, drawn from
+`23456789ABCDEFGHJKMNPQRSTVWXYZ` — the same alphabet FantasyWiki uses for league
+invitations, with 0/1/I/L/O/U removed because these are read off a phone in the
+dark and typed by someone holding a clipboard. Drawn with rejection sampling so
+no character is rarer than the others, and retried against the unique index up
+to five times, after which a collision is a fault rather than bad luck
+(`lib/ticketCodes.ts`, ported from FantasyWiki's `withUniqueInvitationCode`).
+
+The code is what the QR payload carries, so scanning and typing resolve the same
+way and the door has one lookup rather than two. A scanner with a dead camera is
+not a different code path.
+
+**Manual verification needs the code _and_ the name.** Five characters is short,
+and a guest who overhears another's could otherwise walk in on it. Requiring the
+name means the person at the door is checking something the code does not carry.
+When a code resolves to somebody else, the refusal says so without naming
+them — that would hand a stranger a real guest's name.
+
+`normaliseTicketCode` forgives case, spaces and hyphens, and **nothing else**.
+An earlier draft folded `O` onto `Q` and `I` onto `J` on the theory that door
+staff mistype. But those characters are absent from the alphabet precisely so
+the ambiguity cannot arise, so guessing what someone meant can only turn a
+correct rejection into the wrong guest being admitted.
+
+### The server arbitrates check-in
+
+`POST /api/parties/:id/invites/:inviteId/check-in`, with `AND checked_in = 0` in
+the UPDATE — that clause is what makes the second scan lose when two phones race
+the same ticket. A 409 carries the time of the first scan, so the door says
+"already scanned at 23:14" rather than a bare refusal.
+
+`mergeFunnel` now takes `used`/`usedAt` **from the server** instead of carrying
+the local value forward. That reverses ADR 0003's rule deliberately: a phone
+that did not scan someone must still show them as arrived, and a phone that did
+must not out-vote an undo made on the other one.
+
+A failed check-in request leaves the local one standing. The guest is through
+the door either way; refusing them over a dropped request is the worse mistake,
+and the next sync reconciles it.
+
+### A guest typed in is a real invite
+
+`POST /api/parties/:id/invites` creates a confirmed, depth-0 row with
+`source = 'manual'`. That is what lets the co-organiser see them and the second
+phone check their ticket.
+
+`source` also keeps the funnel honest: "Reached" counts people who opened the
+link, and someone typed in never opened anything. Without the column they would
+inflate the top of the funnel and make the conversion rate a lie.
+
+## Consequences
+
+- **Every feature the pricing page sells is now delivered.** `doorScannerSync`
+ was the last one.
+- **Ticket codes changed shape**, from `BC-3-04217` to `6H85S`. Any ticket
+ issued before this is not verifiable, since the payload is keyed differently
+ and signed with a different key. Nothing is released, so nothing is stranded.
+- **A local-only party keeps a device key and a derived code.** There is no
+ server to hold one and nobody else to agree with. The code comes from an FNV
+ hash of the guest's name so it is stable across reloads, drawn from the same
+ alphabet so a guest cannot tell the difference. Two guests of one party could
+ in principle collide; with one device on one door, the name settles it.
+- **Undo exists**, because the alternative to a wrong check-in being reversible
+ is a guest standing outside while two organisers argue.
+- **Check-in still needs the network to be shared.** Two phones both offline
+ will each admit the same ticket. Making that impossible needs the door to be
+ online at least intermittently; the honest position is that the offline
+ fallback verifies signatures and counts locally, which is what it did before.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index c376276..118d62d 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -9,7 +9,16 @@ import vueParser from 'vue-eslint-parser';
export default tseslint.config(
{
- ignores: ['dist/**', 'node_modules/**', '.astro/**'],
+ // `backend/` carries its own toolchain (Workers globals, its own tsconfig),
+ // so it is linted from inside that package rather than by the frontend's
+ // config. `shared/` is plain TypeScript and stays in scope for both.
+ ignores: [
+ 'dist/**',
+ 'node_modules/**',
+ '.astro/**',
+ 'backend/**',
+ '.wrangler/**',
+ ],
},
js.configs.recommended,
...tseslint.configs.recommended,
diff --git a/functions/_backend.ts b/functions/_backend.ts
new file mode 100644
index 0000000..210bad9
--- /dev/null
+++ b/functions/_backend.ts
@@ -0,0 +1,37 @@
+/**
+ * Hands `/api/*` and `/auth/*` to the Worker over a service binding.
+ *
+ * The point is the origin. A service binding is an internal dispatch, not a
+ * network hop, so the browser only ever talks to the Pages domain — which makes
+ * the `session_token` cookie first-party, lets it be `SameSite=Lax`, and means
+ * no CORS preflight stands between a user and signing in. Pointing the frontend
+ * straight at `*.workers.dev` instead would make every session cross-site.
+ *
+ * A file prefixed with `_` is not itself a route, so this module stays shared
+ * helper code rather than becoming a `/_backend` endpoint.
+ */
+export interface ProxyEnv {
+ BACKEND?: { fetch(request: Request): Promise };
+}
+
+export interface ProxyContext {
+ request: Request;
+ env: ProxyEnv;
+}
+
+export async function proxyToBackend({
+ request,
+ env,
+}: ProxyContext): Promise {
+ // A Pages deployment with no service binding — a self-hoster who has not
+ // wired the Worker up yet. The free tier is entirely client-side, so answer
+ // in the shape the frontend already handles and let the planner carry on,
+ // rather than failing the request.
+ if (!env.BACKEND) {
+ return Response.json(
+ { error: 'backend_unavailable' },
+ { status: 501, headers: { 'cache-control': 'no-store' } },
+ );
+ }
+ return env.BACKEND.fetch(request);
+}
diff --git a/functions/api/[[catchall]].ts b/functions/api/[[catchall]].ts
new file mode 100644
index 0000000..81883cd
--- /dev/null
+++ b/functions/api/[[catchall]].ts
@@ -0,0 +1,4 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/functions/auth/README.md b/functions/auth/README.md
new file mode 100644
index 0000000..2972767
--- /dev/null
+++ b/functions/auth/README.md
@@ -0,0 +1,12 @@
+# Why these are three files and not one catchall
+
+Pages Functions win over static assets on the same path. A
+`functions/auth/[[catchall]].ts` would therefore swallow `/auth/callback` —
+which is a real page the app serves after Google redirects back — and hand it to
+a Worker that has no such route, producing a 404 at the last step of every
+sign-in.
+
+So each backend auth endpoint is named explicitly, and every other `/auth/*`
+path stays a static page. Adding a route to `backend/src/routes/auth.ts` means
+adding a file here too; forgetting to is a 404 on that endpoint, which is
+noisier and easier to diagnose than the alternative failure.
diff --git a/functions/auth/dev.ts b/functions/auth/dev.ts
new file mode 100644
index 0000000..81883cd
--- /dev/null
+++ b/functions/auth/dev.ts
@@ -0,0 +1,4 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/functions/auth/google.ts b/functions/auth/google.ts
new file mode 100644
index 0000000..81883cd
--- /dev/null
+++ b/functions/auth/google.ts
@@ -0,0 +1,4 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/functions/auth/logout.ts b/functions/auth/logout.ts
new file mode 100644
index 0000000..81883cd
--- /dev/null
+++ b/functions/auth/logout.ts
@@ -0,0 +1,4 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/functions/i/[[slug]].ts b/functions/i/[[slug]].ts
new file mode 100644
index 0000000..2b62d18
--- /dev/null
+++ b/functions/i/[[slug]].ts
@@ -0,0 +1,34 @@
+/**
+ * Serves the invite page for every `/i/` URL.
+ *
+ * Pages Functions win over static assets on the same path, so this one has to
+ * hand back the asset itself: it rewrites the request onto `/i/`, which is the
+ * built invite page, and lets the component read the slug off the URL. Without
+ * it, `/i/rooftop-abc123` is a 404 — the build has no page at that path and
+ * cannot have one, because slugs are minted at runtime, long after the build.
+ *
+ * The browser's URL is untouched; only the asset lookup is redirected.
+ */
+interface Env {
+ ASSETS?: { fetch(request: Request): Promise };
+}
+
+interface Ctx {
+ request: Request;
+ env: Env;
+ next(): Promise;
+}
+
+export async function onRequest({
+ request,
+ env,
+ next,
+}: Ctx): Promise {
+ // A deployment without the ASSETS binding still works: fall through and let
+ // the platform serve whatever it would have.
+ if (!env.ASSETS) return next();
+
+ const url = new URL(request.url);
+ url.pathname = '/i/';
+ return env.ASSETS.fetch(new Request(url, request));
+}
diff --git a/functions/invite/[[catchall]].ts b/functions/invite/[[catchall]].ts
new file mode 100644
index 0000000..b3af6a4
--- /dev/null
+++ b/functions/invite/[[catchall]].ts
@@ -0,0 +1,9 @@
+import { proxyToBackend, type ProxyContext } from '../_backend';
+
+/**
+ * The guest-facing endpoints, which are outside `/api/*` because guests have no
+ * account. They still need a proxy of their own — without one, `/invite/...`
+ * falls through to the static build and every RSVP is a 404.
+ */
+export const onRequest = (ctx: ProxyContext): Promise =>
+ proxyToBackend(ctx);
diff --git a/functions/join/[[token]].ts b/functions/join/[[token]].ts
new file mode 100644
index 0000000..c8189f1
--- /dev/null
+++ b/functions/join/[[token]].ts
@@ -0,0 +1,29 @@
+/**
+ * Serves the co-organiser invitation page for every `/join/` URL.
+ *
+ * Same arrangement as `functions/i/[[slug]].ts`: Pages Functions win over
+ * static assets, so this rewrites the request onto the one built page and lets
+ * the component read the token off the URL. Tokens are minted at runtime, so no
+ * build could enumerate them.
+ */
+interface Env {
+ ASSETS?: { fetch(request: Request): Promise };
+}
+
+interface Ctx {
+ request: Request;
+ env: Env;
+ next(): Promise;
+}
+
+export async function onRequest({
+ request,
+ env,
+ next,
+}: Ctx): Promise {
+ if (!env.ASSETS) return next();
+
+ const url = new URL(request.url);
+ url.pathname = '/join/';
+ return env.ASSETS.fetch(new Request(url, request));
+}
diff --git a/package-lock.json b/package-lock.json
index db947b6..85f82c4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"vue": "^3.4.0"
},
"devDependencies": {
+ "@astrojs/check": "^0.9.10",
"@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.5",
"astro-eslint-parser": "^1.4.0",
@@ -30,12 +31,137 @@
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.59.1",
+ "vitest": "^5.0.1",
"vue-eslint-parser": "^10.4.0"
},
"engines": {
"node": ">=22.12.0"
}
},
+ "node_modules/@astrojs/astro2tsx": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/astro2tsx/-/astro2tsx-0.1.0.tgz",
+ "integrity": "sha512-tgprkax8mcF+BiHJQBdoQ+REqtvvnLlYzV0yCeLMdcv3pJIutdlinWNHUzN1jMOT5VRlYTtMmDdaoPrtd3cBAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emnapi/core": "1.11.3",
+ "@napi-rs/wasm-runtime": "1.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/check": {
+ "version": "0.9.10",
+ "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.10.tgz",
+ "integrity": "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/language-server": "^2.16.7",
+ "chokidar": "^4.0.3",
+ "kleur": "^4.1.5",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "astro-check": "bin/astro-check.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/yargs": {
+ "version": "18.1.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
+ "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^8.2.1",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
"node_modules/@astrojs/compiler": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.1.tgz",
@@ -51,6 +177,47 @@
"picomatch": "^4.0.4"
}
},
+ "node_modules/@astrojs/language-server": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.17.0.tgz",
+ "integrity": "sha512-ZvT7UEo7/jBdfjXD3nNYssziEezNKM8G6FUYRnGUidAs7FmowqsQhQ0Xwl5Q4tjqlOjhnpxQuoEb4jwESL+nKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/astro2tsx": "^0.1.0",
+ "@astrojs/yaml2ts": "^0.2.4",
+ "@volar/kit": "~2.4.28",
+ "@volar/language-core": "~2.4.28",
+ "@volar/language-server": "~2.4.28",
+ "@volar/language-service": "~2.4.28",
+ "muggle-string": "^0.4.1",
+ "tinyglobby": "^0.2.16",
+ "volar-service-css": "0.0.71",
+ "volar-service-emmet": "0.0.71",
+ "volar-service-html": "0.0.71",
+ "volar-service-prettier": "0.0.71",
+ "volar-service-typescript": "0.0.71",
+ "volar-service-typescript-twoslash-queries": "0.0.71",
+ "volar-service-yaml": "0.0.71",
+ "vscode-html-languageservice": "^5.6.2",
+ "vscode-uri": "^3.1.0"
+ },
+ "bin": {
+ "astro-ls": "bin/nodeServer.js"
+ },
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "prettier-plugin-astro": ">=0.11.0"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@astrojs/markdown-remark": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.1.tgz",
@@ -144,6 +311,16 @@
"vue": "^3.5.24"
}
},
+ "node_modules/@astrojs/yaml2ts": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.4.tgz",
+ "integrity": "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yaml": "^2.8.3"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -604,12 +781,95 @@
"sisteransi": "^1.0.5"
}
},
+ "node_modules/@emmetio/abbreviation": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
+ "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-abbreviation": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
+ "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-parser": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
+ "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/stream-reader": "^2.2.0",
+ "@emmetio/stream-reader-utils": "^0.1.0"
+ }
+ },
+ "node_modules/@emmetio/html-matcher": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
+ "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.0"
+ }
+ },
+ "node_modules/@emmetio/scanner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
+ "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
+ "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
+ "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
+ "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.3",
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
+ "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
+ "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1720,9 +1980,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1735,6 +1995,27 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
+ "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2251,6 +2532,27 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
+ "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2260,6 +2562,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2643,6 +2952,162 @@
"vue": "^3.0.0"
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
+ "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
+ "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@volar/kit": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
+ "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "typesafe-path": "^0.2.2",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/language-server": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
+ "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "request-light": "^0.7.0",
+ "vscode-languageserver": "^9.0.1",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/language-service": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
+ "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/emmet-helper": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
+ "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emmet": "^2.4.3",
+ "jsonc-parser": "^2.3.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.15.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/l10n": {
+ "version": "0.0.18",
+ "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
+ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vue/babel-helper-vue-transform-on": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-2.0.1.tgz",
@@ -2965,7 +3430,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/astro": {
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/astro": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/astro/-/astro-6.1.10.tgz",
"integrity": "sha512-jQAIki6c862oxRr7OXXC+h3n4wg1EpmKgCH3vv1FtXM9VFmD2iTjlaxrfb0I6eQCwtUjSBxfJBFBDSXHu7Wing==",
@@ -3273,6 +3748,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -3899,6 +4384,23 @@
"integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==",
"license": "ISC"
},
+ "node_modules/emmet": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
+ "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "./packages/scanner",
+ "./packages/abbreviation",
+ "./packages/css-abbreviation",
+ "./"
+ ],
+ "dependencies": {
+ "@emmetio/abbreviation": "^2.3.3",
+ "@emmetio/css-abbreviation": "^2.1.8"
+ }
+ },
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -3941,9 +4443,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
- "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"license": "MIT"
},
"node_modules/esbuild": {
@@ -4440,6 +4942,16 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -4497,6 +5009,23 @@
"dev": true,
"license": "MIT"
},
+ "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==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -5130,6 +5659,13 @@
"node": ">=6"
}
},
+ "node_modules/jsonc-parser": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
+ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5140,6 +5676,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -6201,6 +6747,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -6288,14 +6841,17 @@
}
},
"node_modules/obug": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
},
"node_modules/ofetch": {
"version": "1.5.1",
@@ -6510,6 +7066,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6554,9 +7117,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -6925,6 +7488,13 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/request-light": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
+ "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6934,6 +7504,16 @@
"node": ">=0.10.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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -7257,6 +7837,13 @@
"node": ">=20"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7354,6 +7941,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -7468,6 +8069,16 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/tinyclip": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz",
@@ -7478,22 +8089,22 @@
}
},
"node_modules/tinyexec": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
- "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -7581,8 +8192,8 @@
"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
+ "devOptional": true,
+ "license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -7597,6 +8208,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/typesafe-path": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
+ "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -7611,6 +8229,29 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-auto-import-cache": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
+ "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.8"
+ }
+ },
+ "node_modules/typescript-auto-import-cache/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/typescript-eslint": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz",
@@ -8265,6 +8906,402 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
+ "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.1",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.1",
+ "@vitest/browser-preview": "5.0.1",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.1",
+ "@vitest/coverage-v8": "5.0.1",
+ "@vitest/ui": "5.0.1",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+ "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/volar-service-css": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.71.tgz",
+ "integrity": "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-css-languageservice": "^6.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-emmet": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.71.tgz",
+ "integrity": "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/css-parser": "^0.4.1",
+ "@emmetio/html-matcher": "^1.3.0",
+ "@vscode/emmet-helper": "^2.9.3",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-html": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.71.tgz",
+ "integrity": "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-html-languageservice": "^5.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-prettier": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.71.tgz",
+ "integrity": "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0",
+ "prettier": "^2.2 || ^3.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ },
+ "prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.71.tgz",
+ "integrity": "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-browserify": "^1.0.1",
+ "semver": "^7.6.2",
+ "typescript-auto-import-cache": "^0.3.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-nls": "^5.2.0",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript-twoslash-queries": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.71.tgz",
+ "integrity": "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/volar-service-yaml": {
+ "version": "0.0.71",
+ "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.71.tgz",
+ "integrity": "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8",
+ "yaml-language-server": "~1.23.0"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-css-languageservice": {
+ "version": "6.3.10",
+ "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
+ "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-html-languageservice": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
+ "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "^3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-json-languageservice": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
+ "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsonc-parser": "^3.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-nls": "^5.0.0",
+ "vscode-uri": "^3.0.2"
+ },
+ "engines": {
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/vscode-json-languageservice/node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
+ "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "bin": {
+ "installServerIntoExtension": "bin/installServerIntoExtension"
+ }
+ },
+ "node_modules/vscode-languageserver-protocol": {
+ "version": "3.18.3",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz",
+ "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "9.0.2",
+ "vscode-languageserver-types": "3.18.3"
+ }
+ },
+ "node_modules/vscode-languageserver-textdocument": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
+ "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver-types": {
+ "version": "3.18.3",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz",
+ "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-nls": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
+ "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+ "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vue": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
@@ -8364,6 +9401,23 @@
"node": ">=4"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8469,6 +9523,86 @@
"url": "https://github.com/sponsors/eemeli"
}
},
+ "node_modules/yaml-language-server": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.23.0.tgz",
+ "integrity": "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "ajv-i18n": "^4.2.0",
+ "prettier": "^3.8.1",
+ "request-light": "^0.5.7",
+ "vscode-json-languageservice": "4.1.8",
+ "vscode-languageserver": "^9.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-uri": "^3.0.2",
+ "yaml": "2.8.3"
+ },
+ "bin": {
+ "yaml-language-server": "bin/yaml-language-server"
+ }
+ },
+ "node_modules/yaml-language-server/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==",
+ "dev": true,
+ "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/yaml-language-server/node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yaml-language-server/node_modules/ajv-i18n": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz",
+ "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.0.0-beta.0"
+ }
+ },
+ "node_modules/yaml-language-server/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==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yaml-language-server/node_modules/request-light": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
+ "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
diff --git a/package.json b/package.json
index 15a64ab..1abda3c 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,15 @@
"format:check": "prettier . --check",
"prepare": "husky",
"lint": "eslint .",
- "lint:fix": "eslint . --fix"
+ "lint:fix": "eslint . --fix",
+ "backend": "npm --prefix backend run",
+ "backend:dev": "npm --prefix backend run dev",
+ "backend:test": "npm --prefix backend run test",
+ "test": "vitest run && npm --prefix backend run test",
+ "typecheck": "astro check && npm --prefix backend run typecheck",
+ "install:all": "npm install && npm --prefix backend install",
+ "test:frontend": "vitest run",
+ "build:docs": "BUILD_TARGET=docs BASE_PATH=/BottleCount/ SITE=https://fre0grella.github.io PUBLIC_APP_ORIGIN=https://bottlecount.pages.dev astro build"
},
"engines": {
"node": ">=22.12.0"
@@ -24,6 +32,7 @@
"vue": "^3.4.0"
},
"devDependencies": {
+ "@astrojs/check": "^0.9.10",
"@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.5",
"astro-eslint-parser": "^1.4.0",
@@ -38,6 +47,7 @@
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.59.1",
+ "vitest": "^5.0.1",
"vue-eslint-parser": "^10.4.0"
}
}
diff --git a/shared/collab.ts b/shared/collab.ts
new file mode 100644
index 0000000..bdfb7df
--- /dev/null
+++ b/shared/collab.ts
@@ -0,0 +1,182 @@
+import type { MergePatch } from './patch';
+
+/**
+ * Co-organisers: the contract for a party two people run together.
+ *
+ * ADR 0001 kept every party in the host's browser and ADR 0002 published only
+ * the invitation card. Neither is enough here — a second organiser opens the
+ * party on their own device and needs the menu, the numbers and the shopping
+ * list, not a poster. This is the shape that travels.
+ */
+
+// ── Roles ───────────────────────────────────────────────────────────────────
+
+export const PARTY_ROLES = ['owner', 'editor'] as const;
+
+/**
+ * `owner` is whoever created the party. `editor` is a co-organiser: they may
+ * change anything about the party itself, and may not delete it, unpublish it,
+ * or add and remove people. Those stay with the owner, because they are the
+ * actions that take the party away from everybody else.
+ */
+export type PartyRole = (typeof PARTY_ROLES)[number];
+
+export function isPartyRole(value: unknown): value is PartyRole {
+ return (
+ typeof value === 'string' &&
+ (PARTY_ROLES as readonly string[]).includes(value)
+ );
+}
+
+// ── The synced document ─────────────────────────────────────────────────────
+
+/**
+ * The part of a party that is shared.
+ *
+ * Everything an organiser plans with, and nothing else. Two deliberate
+ * omissions:
+ *
+ * - **`invites`** — the guest list lives in its own table and arrives through
+ * the funnel. Keeping it out is what leaves this document free of arrays,
+ * which is what lets merge patches work (see `patch.ts`).
+ * - **`id`, `createdAt`, `publication`** — each browser's own bookkeeping. A
+ * collaborator's local id for a party is theirs alone and means nothing to
+ * anyone else.
+ */
+export interface PartyDocument {
+ name: string;
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ settings: {
+ guests: number;
+ ticket_price: number;
+ venue_cost: number;
+ equipment_cost: number;
+ alcohol_ml_per_person: number;
+ buffer: number;
+ max_capacity: number | null;
+ };
+ /** `Record }>`. */
+ menu: Record;
+ locks: Record;
+ checked: Record;
+ allowForward: boolean;
+ includeSnacks: boolean;
+}
+
+/** The fields of a local `Party` that belong in the shared document. */
+export const DOCUMENT_FIELDS = [
+ 'name',
+ 'date',
+ 'cover',
+ 'venue',
+ 'settings',
+ 'menu',
+ 'locks',
+ 'checked',
+ 'allowForward',
+ 'includeSnacks',
+] as const satisfies readonly (keyof PartyDocument)[];
+
+// ── Members ─────────────────────────────────────────────────────────────────
+
+export interface PartyMemberDTO {
+ userId: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+ role: PartyRole;
+ addedAt: string;
+}
+
+// ── What the client reads and writes ────────────────────────────────────────
+
+export interface SharedPartyDTO {
+ id: string;
+ document: PartyDocument;
+ /**
+ * Bumped on every accepted write. The client sends the version it was working
+ * from so the server can tell it when it has fallen behind — not to reject
+ * the write, but so the client knows to pull before assuming it is current.
+ */
+ version: number;
+ role: PartyRole;
+ members: PartyMemberDTO[];
+ updatedAt: string;
+ /** Present once the invite link is on, so a collaborator can share it too. */
+ publication: { slug: string; rootToken: string } | null;
+ /**
+ * The party's ticket-signing key, as a JWK.
+ *
+ * Handed to every member because every member may work the door, and a phone
+ * that cannot verify is a phone that rejects real guests. It is a shared
+ * secret among organisers: holding it means being able to mint a ticket, and
+ * anyone who holds it can already add a guest through the API, so it grants
+ * nothing they did not have. It is never sent to a guest.
+ */
+ ticketKey: JsonWebKey | null;
+}
+
+/** One entry in the list of parties a user can open. */
+export interface PartySummaryDTO {
+ id: string;
+ name: string;
+ date: string;
+ cover: number;
+ role: PartyRole;
+ version: number;
+ updatedAt: string;
+ memberCount: number;
+}
+
+export interface PatchPartyRequest {
+ /** What the client believes it is patching. Reported back, never enforced. */
+ baseVersion: number;
+ patch: MergePatch;
+}
+
+export interface PatchPartyResponse {
+ version: number;
+ /**
+ * The full document, returned when the client's `baseVersion` was stale — it
+ * had not seen somebody else's edit yet. Null when it was current, so the
+ * common case costs nothing.
+ */
+ document: PartyDocument | null;
+ updatedAt: string;
+}
+
+// ── Inviting a co-organiser ─────────────────────────────────────────────────
+
+export interface CollaboratorInviteDTO {
+ token: string;
+ createdAt: string;
+}
+
+/** What someone sees before they accept, so they know what they are joining. */
+export interface CollaboratorPreviewDTO {
+ partyName: string;
+ date: string;
+ cover: number;
+ invitedBy: string;
+ /** True when this user is already on the party — the link is then a no-op. */
+ alreadyMember: boolean;
+}
+
+/**
+ * Where a co-organiser invite points.
+ *
+ * Kept beside `inviteUrl` in spirit but separate in fact: a guest invite and a
+ * co-organiser invite grant wildly different things, and one function taking a
+ * flag to decide which is a function that will eventually hand a guest an
+ * editor's link.
+ */
+export function collaboratorUrl(
+ origin: string,
+ base: string,
+ token: string,
+): string {
+ const prefix = base.endsWith('/') ? base : `${base}/`;
+ return `${origin}${prefix}join/${encodeURIComponent(token)}`;
+}
diff --git a/shared/invites.ts b/shared/invites.ts
new file mode 100644
index 0000000..0d23dd8
--- /dev/null
+++ b/shared/invites.ts
@@ -0,0 +1,151 @@
+/**
+ * The invite-link contract, shared by the Worker and the frontend.
+ *
+ * Everything here crosses the network in both directions, so it lives beside
+ * `tiers.ts` for the same reason: a field the server renames and the client
+ * still reads is a bug that typechecks on both sides independently.
+ */
+
+import type { PartyDocument } from './collab';
+
+// ── Status ──────────────────────────────────────────────────────────────────
+
+export const INVITE_STATUSES = ['opened', 'confirmed', 'declined'] as const;
+
+/**
+ * Where someone is in the funnel.
+ *
+ * `opened` is created the moment the link is opened, before any answer — that
+ * is what makes "reached" a real number rather than a guess, and what the
+ * "maybe" column counts. It is not a pending *invitation*: nobody was invited
+ * by name, they followed a link.
+ */
+export type InviteStatus = (typeof INVITE_STATUSES)[number];
+
+/** An answer a guest can give. Opening the link is not an answer. */
+export type InviteAnswer = Exclude;
+
+export function isInviteAnswer(value: unknown): value is InviteAnswer {
+ return value === 'confirmed' || value === 'declined';
+}
+
+// ── What a guest sees before answering ──────────────────────────────────────
+
+/**
+ * The public face of a party. Deliberately thin: anyone with the link can read
+ * this, so it carries what an invitation card would and nothing else — no
+ * budget, no shopping list, no guest names, no owner identity.
+ */
+export interface InvitePartyDTO {
+ slug: string;
+ name: string;
+ /** ISO date, `YYYY-MM-DD`. */
+ date: string;
+ cover: number;
+ venue: { place: string; city: string; time: string };
+ /** Whether a confirmed guest gets a forward link of their own. */
+ allowForward: boolean;
+ /** True when a capacity cap is set and confirmed guests have reached it. */
+ full: boolean;
+}
+
+/** What `POST /invite/:slug/open` answers with. */
+export interface InviteOpenDTO {
+ party: InvitePartyDTO;
+ /** This visitor's row. The client keeps it so a reload is not a second guest. */
+ inviteId: string;
+ /** Their own forward token — only present once they confirm and if allowed. */
+ forwardToken: string | null;
+ /** Their current answer, so returning to the link shows what they already said. */
+ status: InviteStatus;
+ name: string | null;
+ depth: number;
+}
+
+export interface InviteAnswerRequest {
+ inviteId: string;
+ name: string;
+ answer: InviteAnswer;
+}
+
+export interface InviteOpenRequest {
+ /** The forward token from `?r=`, when they arrived through another guest. */
+ referrer?: string | null;
+ /** A row this browser already owns for this party, from a previous visit. */
+ inviteId?: string | null;
+}
+
+// ── What the host sees ──────────────────────────────────────────────────────
+
+/** One row of the host's funnel. Names are visible here; this endpoint is theirs. */
+export interface HostInviteDTO {
+ id: string;
+ name: string | null;
+ status: InviteStatus;
+ /**
+ * 0 for someone who used the host's own link, +1 for each forward after that
+ * — so 1 is a friend of a guest. Matches the "Direct invites" and
+ * "Friends-of-friends" tiers the spread card already draws.
+ */
+ depth: number;
+ /** The referrer's display name, or null at depth 0. */
+ referrer: string | null;
+ forwardToken: string | null;
+ /** The five characters on this guest's ticket. */
+ ticketCode: string;
+ /** 'link' if they RSVPed themselves; 'manual' if an organiser typed them in. */
+ source: 'link' | 'manual';
+ /**
+ * Whether they have walked in, as the *server* sees it — which is what makes
+ * "already scanned" true across every phone on the door rather than on one.
+ */
+ checkedIn: boolean;
+ checkedInAt: string | null;
+ openedAt: string;
+ answeredAt: string | null;
+}
+
+/** What `POST /api/parties/publish` answers with, and what the host stores. */
+export interface PublishedPartyDTO {
+ id: string;
+ slug: string;
+ /** The host's own link. Guests who use it land at depth 0. */
+ rootToken: string;
+ publishedAt: string;
+ allowForward: boolean;
+}
+
+/**
+ * What a host pushes to put a party on the server.
+ *
+ * The whole planning document, not a summary: a co-organiser needs the menu and
+ * the numbers, and the guest-facing card is derived from the same document
+ * server-side rather than sent alongside it — two copies of the party's name
+ * travelling together is two copies that can disagree.
+ */
+export interface PublishPartyRequest {
+ /** The party's id in the host's browser. Republishing with it updates in place. */
+ localId: number;
+ document: PartyDocument;
+}
+
+// ── Link building ───────────────────────────────────────────────────────────
+
+/**
+ * The one place an invite URL is spelled, so the host's share sheet, a guest's
+ * forward link and the page that resolves them cannot drift.
+ *
+ * `base` is the app's base path (`/` on Cloudflare, `/BottleCount/` on GitHub
+ * Pages) — leaving it out is how a build under a prefix hands out links that
+ * miss the prefix.
+ */
+export function inviteUrl(
+ origin: string,
+ base: string,
+ slug: string,
+ token?: string | null,
+): string {
+ const prefix = base.endsWith('/') ? base : `${base}/`;
+ const url = `${origin}${prefix}i/${slug}`;
+ return token ? `${url}?r=${encodeURIComponent(token)}` : url;
+}
diff --git a/shared/patch.spec.ts b/shared/patch.spec.ts
new file mode 100644
index 0000000..fa11d88
--- /dev/null
+++ b/shared/patch.spec.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from 'vitest';
+import { apply, diff, squash, type JsonRecord } from './patch';
+
+describe('diff', () => {
+ it('reports nothing when nothing changed', () => {
+ // Callers skip the write on null. An empty patch would still bump the
+ // version and wake every other client for no reason.
+ expect(diff({ a: 1 }, { a: 1 })).toBeNull();
+ });
+
+ it('is not fooled by key order', () => {
+ expect(diff({ a: 1, b: 2 }, { b: 2, a: 1 })).toBeNull();
+ });
+
+ it('carries only the field that moved', () => {
+ const before = { settings: { guests: 40, ticket_price: 15 } };
+ const after = { settings: { guests: 60, ticket_price: 15 } };
+
+ expect(diff(before, after)).toEqual({ settings: { guests: 60 } });
+ });
+
+ it('turns a removed key into an explicit null', () => {
+ // Removing a spirit has to travel, or the other organiser keeps buying it.
+ const before = { menu: { Vodka: { pct: 1 }, Gin: { pct: 0 } } };
+ const after = { menu: { Vodka: { pct: 1 } } };
+
+ expect(diff(before, after)).toEqual({ menu: { Gin: null } });
+ });
+
+ it('replaces an array wholesale rather than merging it', () => {
+ expect(diff({ xs: [1, 2] }, { xs: [1, 3] })).toEqual({ xs: [1, 3] });
+ });
+
+ it('descends several levels without dragging siblings along', () => {
+ const before = {
+ menu: {
+ Vodka: { macro_pct: 0.5, spirits: { Absolut: { pct: 1 } } },
+ Beer: { macro_pct: 0.5 },
+ },
+ };
+ const after = {
+ menu: {
+ Vodka: { macro_pct: 0.5, spirits: { Absolut: { pct: 0.7 } } },
+ Beer: { macro_pct: 0.5 },
+ },
+ };
+
+ expect(diff(before, after)).toEqual({
+ menu: { Vodka: { spirits: { Absolut: { pct: 0.7 } } } },
+ });
+ });
+});
+
+describe('apply', () => {
+ it('merges nested objects instead of replacing them', () => {
+ const target = { settings: { guests: 40, ticket_price: 15 } };
+
+ expect(apply(target, { settings: { guests: 60 } })).toEqual({
+ settings: { guests: 60, ticket_price: 15 },
+ });
+ });
+
+ it('deletes on null', () => {
+ expect(apply({ a: 1, b: 2 }, { b: null })).toEqual({ a: 1 });
+ });
+
+ it('does not mutate the document it was given', () => {
+ const target: JsonRecord = { settings: { guests: 40 } };
+ const before = structuredClone(target);
+
+ apply(target, { settings: { guests: 60 } });
+
+ expect(target).toEqual(before);
+ });
+
+ it('replaces a scalar with an object and vice versa', () => {
+ expect(apply({ a: 1 }, { a: { b: 2 } })).toEqual({ a: { b: 2 } });
+ expect(apply({ a: { b: 2 } }, { a: 1 })).toEqual({ a: 1 });
+ });
+
+ it('round-trips with diff', () => {
+ const before = {
+ name: 'Rooftop',
+ settings: { guests: 40, buffer: 1.1 },
+ menu: { Vodka: { pct: 0.6 }, Gin: { pct: 0.4 } },
+ };
+ const after = {
+ name: 'Rooftop II',
+ settings: { guests: 55, buffer: 1.1 },
+ menu: { Vodka: { pct: 1 } },
+ };
+
+ const patch = diff(before, after);
+ expect(patch).not.toBeNull();
+ expect(apply(before, patch!)).toEqual(after);
+ });
+});
+
+describe('two organisers at once', () => {
+ it('keeps both edits when they touch different fields', () => {
+ // This is the whole reason for patching rather than sending the document:
+ // one builds the menu while the other works the numbers.
+ const base = {
+ settings: { guests: 40, ticket_price: 15 },
+ menu: { Vodka: { pct: 1 } },
+ };
+
+ const marco = apply(
+ base,
+ diff(base, {
+ ...base,
+ settings: { ...base.settings, guests: 60 },
+ })!,
+ );
+ const giulia = diff(base, { ...base, menu: { Vodka: { pct: 0.5 } } })!;
+
+ const merged = apply(marco, giulia);
+
+ expect(merged).toEqual({
+ settings: { guests: 60, ticket_price: 15 },
+ menu: { Vodka: { pct: 0.5 } },
+ });
+ });
+
+ it('is last-writer-wins on the same field, which is what a shared box means', () => {
+ const base = { settings: { guests: 40 } };
+
+ const first = apply(base, { settings: { guests: 60 } });
+ const second = apply(first, { settings: { guests: 80 } });
+
+ expect(second).toEqual({ settings: { guests: 80 } });
+ });
+});
+
+describe('squash', () => {
+ it('collapses repeated nudges of one slider into a single write', () => {
+ const one = { settings: { guests: 41 } };
+ const two = { settings: { guests: 42 } };
+
+ expect(squash(one, two)).toEqual({ settings: { guests: 42 } });
+ });
+
+ it('keeps edits to different fields', () => {
+ expect(squash({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
+ });
+
+ it('lets a later delete win', () => {
+ expect(
+ squash({ menu: { Gin: { pct: 1 } } }, { menu: { Gin: null } }),
+ ).toEqual({
+ menu: { Gin: null },
+ });
+ });
+
+ it('does not resurrect a subtree under an earlier delete', () => {
+ expect(squash({ menu: null }, { menu: { Gin: { pct: 1 } } })).toEqual({
+ menu: { Gin: { pct: 1 } },
+ });
+ });
+
+ it('applies the same as the two patches applied in order', () => {
+ const base = { settings: { guests: 40, buffer: 1.1 }, name: 'A' };
+ const one = { settings: { guests: 50 } };
+ const two = { settings: { buffer: 1.2 }, name: 'B' };
+
+ expect(apply(base, squash(one, two))).toEqual(apply(apply(base, one), two));
+ });
+});
diff --git a/shared/patch.ts b/shared/patch.ts
new file mode 100644
index 0000000..dbf0499
--- /dev/null
+++ b/shared/patch.ts
@@ -0,0 +1,139 @@
+/**
+ * A JSON merge patch, in the shape of RFC 7386.
+ *
+ * Co-organisers edit the same party at once, usually different parts of it —
+ * one is building the menu while the other works the guest list. Sending the
+ * whole party on every change would make that a race: the last writer wins and
+ * the other's work disappears with nothing to show it happened.
+ *
+ * A patch carries only what changed, so edits to different fields merge
+ * cleanly and only edits to the *same* field are last-writer-wins, which is
+ * what anyone would expect from two people typing into one box.
+ *
+ * This works because the syncable part of a party has no arrays in it. Menu,
+ * locks and check-offs are all keyed records, and the guest list is not in the
+ * document at all — it lives in the invites table and comes back through the
+ * funnel. Merge patches replace arrays wholesale, so had there been one, two
+ * organisers touching the same list would have clobbered each other.
+ */
+
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue =
+ | JsonPrimitive
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+export type JsonRecord = { [key: string]: JsonValue };
+
+/** `null` means "delete this key", exactly as in RFC 7386. */
+export type MergePatch = JsonRecord;
+
+function isPlainObject(value: unknown): value is JsonRecord {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Whether two values are the same as far as the patch is concerned.
+ *
+ * Arrays and primitives compare by value; objects are walked. `JSON.stringify`
+ * would be shorter but depends on key order, which would report spurious
+ * changes for objects that are equal but were built differently — and every
+ * spurious change is a write and a clobbered field.
+ */
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
+ return false;
+ }
+ return a.every((item, i) => deepEqual(item, b[i]));
+ }
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
+ for (const key of keys) {
+ if (!deepEqual(a[key], b[key])) return false;
+ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * What changed between two documents.
+ *
+ * Returns `null` when nothing did, so callers can skip the write rather than
+ * sending an empty patch that still bumps a version and wakes every other
+ * client up.
+ */
+export function diff(before: JsonRecord, after: JsonRecord): MergePatch | null {
+ const patch: MergePatch = {};
+
+ for (const [key, value] of Object.entries(after)) {
+ if (value === undefined) continue;
+ const previous = before[key];
+
+ if (isPlainObject(value) && isPlainObject(previous)) {
+ const nested = diff(previous, value);
+ if (nested !== null) patch[key] = nested;
+ continue;
+ }
+
+ if (!deepEqual(previous, value)) patch[key] = value;
+ }
+
+ // A key that is gone becomes an explicit null — removing a spirit from the
+ // menu has to travel, or the other organiser's copy keeps buying it.
+ for (const key of Object.keys(before)) {
+ if (!(key in after)) patch[key] = null;
+ }
+
+ return Object.keys(patch).length > 0 ? patch : null;
+}
+
+/**
+ * Applies a patch, returning a new document. Never mutates its input — the
+ * server holds one parsed copy per request and the client holds reactive state,
+ * and neither wants this reaching in.
+ */
+export function apply(target: JsonRecord, patch: MergePatch): JsonRecord {
+ const result: JsonRecord = { ...target };
+
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) {
+ delete result[key];
+ continue;
+ }
+
+ const existing = result[key];
+ if (isPlainObject(value) && isPlainObject(existing)) {
+ result[key] = apply(existing, value);
+ continue;
+ }
+
+ result[key] = value;
+ }
+
+ return result;
+}
+
+/**
+ * Folds a second patch onto a first, so a queue of edits collapses into one
+ * write. Used when a client has pending changes it has not managed to send yet:
+ * three nudges of the same slider should reach the server once.
+ */
+export function squash(first: MergePatch, second: MergePatch): MergePatch {
+ const result: MergePatch = { ...first };
+
+ for (const [key, value] of Object.entries(second)) {
+ const existing = result[key];
+ // A later delete wins outright; a later object merges into an earlier one,
+ // but not into an earlier `null` — that would resurrect half a deleted
+ // subtree.
+ if (isPlainObject(value) && isPlainObject(existing)) {
+ result[key] = squash(existing, value);
+ continue;
+ }
+ result[key] = value;
+ }
+
+ return result;
+}
diff --git a/shared/session.ts b/shared/session.ts
new file mode 100644
index 0000000..b21cae4
--- /dev/null
+++ b/shared/session.ts
@@ -0,0 +1,31 @@
+import type { Feature, FeatureSet, Tier } from './tiers';
+
+/** The signed-in user, as the frontend is allowed to see them. */
+export interface SessionUser {
+ id: string;
+ email: string;
+ name: string | null;
+ picture: string | null;
+}
+
+/**
+ * The answer to "what may this browser do?", and the only place the frontend
+ * learns it.
+ *
+ * It is served to anonymous callers too — that is the free tier, and a 401
+ * there would make the browser-only product depend on being logged out
+ * *successfully*. A frontend with no backend at all (the static build) fabricates
+ * the same shape locally, so every consumer sees one type.
+ */
+export interface SessionDTO {
+ authenticated: boolean;
+ user: SessionUser | null;
+ tier: Tier;
+ features: FeatureSet;
+ /** True when this is somebody's own deployment, which grants `pro` outright. */
+ selfHosted: boolean;
+ /** False when no backend is reachable — the pure browser-only build. */
+ backendAvailable: boolean;
+}
+
+export type { Feature, FeatureSet, Tier };
diff --git a/shared/tickets.spec.ts b/shared/tickets.spec.ts
new file mode 100644
index 0000000..c2234be
--- /dev/null
+++ b/shared/tickets.spec.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+import {
+ generateTicketCode,
+ isTicketCode,
+ normaliseTicketCode,
+ TICKET_CODE_ALPHABET,
+ TICKET_CODE_LENGTH,
+} from './tickets';
+
+describe('the ticket code alphabet', () => {
+ it('excludes every character that is mistaken for another', () => {
+ // These are read off a phone in the dark and typed by someone holding a
+ // clipboard. Each of these has a look-alike that is in the alphabet.
+ for (const confusable of ['0', '1', 'I', 'L', 'O', 'U']) {
+ expect(TICKET_CODE_ALPHABET).not.toContain(confusable);
+ }
+ });
+
+ it('is long enough that a party will not collide in practice', () => {
+ // 30^5 ≈ 24 million, against a few hundred guests.
+ expect(TICKET_CODE_ALPHABET.length ** TICKET_CODE_LENGTH).toBeGreaterThan(
+ 10_000_000,
+ );
+ });
+});
+
+describe('generateTicketCode', () => {
+ it('produces codes of the right shape', () => {
+ for (let i = 0; i < 200; i++) {
+ expect(isTicketCode(generateTicketCode())).toBe(true);
+ }
+ });
+
+ it('draws every character of the alphabet given enough codes', () => {
+ // The rejection sampling exists so no character is rarer than the rest; a
+ // `%` fold would quietly favour the first few.
+ const seen = new Set();
+ for (let i = 0; i < 4000; i++) {
+ for (const c of generateTicketCode()) seen.add(c);
+ }
+ expect(seen.size).toBe(TICKET_CODE_ALPHABET.length);
+ });
+
+ it('does not repeat itself', () => {
+ const codes = new Set(
+ Array.from({ length: 1000 }, () => generateTicketCode()),
+ );
+ // A handful of collisions in 1000 draws from 24M would still be suspicious.
+ expect(codes.size).toBe(1000);
+ });
+});
+
+describe('normaliseTicketCode', () => {
+ it('forgives case, spaces and hyphens', () => {
+ expect(normaliseTicketCode(' a b-c 2 3 ')).toBe('ABC23');
+ });
+
+ it('does not guess at characters outside the alphabet', () => {
+ // An earlier version folded O onto Q and I onto J. Both are absent from the
+ // alphabet precisely so that ambiguity cannot arise, so guessing can only
+ // turn a correct rejection into the wrong guest walking in.
+ expect(isTicketCode(normaliseTicketCode('OIOIO'))).toBe(false);
+ expect(normaliseTicketCode('QJQJQ')).toBe('QJQJQ');
+ });
+
+ it('caps the length so a paste cannot smuggle a longer string through', () => {
+ expect(normaliseTicketCode('ABCDEFGHIJ')).toHaveLength(TICKET_CODE_LENGTH);
+ });
+});
+
+describe('isTicketCode', () => {
+ it('rejects anything that is not exactly a code', () => {
+ expect(isTicketCode('ABC2')).toBe(false);
+ expect(isTicketCode('ABC234')).toBe(false);
+ expect(isTicketCode('ABC2O')).toBe(false);
+ expect(isTicketCode('abc23')).toBe(false);
+ expect(isTicketCode(12345)).toBe(false);
+ expect(isTicketCode(null)).toBe(false);
+ });
+});
diff --git a/shared/tickets.ts b/shared/tickets.ts
new file mode 100644
index 0000000..5e7b41d
--- /dev/null
+++ b/shared/tickets.ts
@@ -0,0 +1,122 @@
+/**
+ * Tickets: the code on them, and what their QR carries.
+ *
+ * Shared because a ticket is issued by one device, printed by another and
+ * checked at the door by a third. All three have to agree on the format, and
+ * the door has to agree with the server about which invite a code names.
+ */
+
+// ── The code ────────────────────────────────────────────────────────────────
+
+/**
+ * No I, L, O, U, 0 or 1.
+ *
+ * These get read off a phone screen in the dark and typed by someone holding a
+ * clipboard, so every pair that looks alike in a hurry is gone. U is dropped
+ * with V because handwritten they are the same character. Same alphabet
+ * FantasyWiki uses for league invitations, for the same reason.
+ */
+export const TICKET_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+/**
+ * Five characters: 30^5 ≈ 24 million.
+ *
+ * Only has to be unique *within one party*, so for a party of a few hundred the
+ * chance of a collision is negligible — and a unique index plus a redraw makes
+ * "negligible" into "handled". Short enough to read aloud across a doorway,
+ * which is the whole point of it existing beside the QR.
+ */
+export const TICKET_CODE_LENGTH = 5;
+
+/**
+ * The largest multiple of the alphabet size that fits in a byte. Bytes at or
+ * above it are thrown away rather than folded with `%`, which would hand the
+ * first few characters a slightly better chance than the rest. Nobody is
+ * guessing these — a ticket also needs its guest's name — but an even draw is
+ * free here and awkward to retrofit.
+ */
+const REJECTION_CEILING =
+ Math.floor(256 / TICKET_CODE_ALPHABET.length) * TICKET_CODE_ALPHABET.length;
+
+/**
+ * A fresh ticket code. Uniqueness is not this function's job — it draws, and
+ * the unique index plus a bounded redraw deals with the rare collision.
+ */
+export function generateTicketCode(): string {
+ let code = '';
+ while (code.length < TICKET_CODE_LENGTH) {
+ // A generous batch, so the common case is one trip to the CSPRNG even after
+ // a few rejections.
+ const bytes = new Uint8Array(TICKET_CODE_LENGTH * 2);
+ crypto.getRandomValues(bytes);
+ for (const byte of bytes) {
+ if (byte >= REJECTION_CEILING) continue;
+ code += TICKET_CODE_ALPHABET[byte % TICKET_CODE_ALPHABET.length];
+ if (code.length === TICKET_CODE_LENGTH) break;
+ }
+ }
+ return code;
+}
+
+/** How many codes to try before treating collisions as a fault, not bad luck. */
+export const TICKET_CODE_ATTEMPTS = 5;
+
+export function isTicketCode(value: unknown): value is string {
+ return (
+ typeof value === 'string' &&
+ value.length === TICKET_CODE_LENGTH &&
+ [...value].every((c) => TICKET_CODE_ALPHABET.includes(c))
+ );
+}
+
+/**
+ * What someone typed, as a code.
+ *
+ * Case and stray spaces or hyphens are forgiven, because those carry no
+ * information. Nothing else is: an earlier version folded O onto Q and I onto
+ * J, on the theory that door staff mistype. But O and I are *not in the
+ * alphabet* — the alphabet was chosen to remove exactly that ambiguity — so
+ * guessing what they meant can only turn a correct rejection into the wrong
+ * guest being admitted. A code that does not normalise to a valid one is
+ * refused, and the door tries again.
+ */
+export function normaliseTicketCode(input: string): string {
+ return input
+ .trim()
+ .toUpperCase()
+ .replace(/[\s-]/g, '')
+ .slice(0, TICKET_CODE_LENGTH);
+}
+
+// ── The QR payload ──────────────────────────────────────────────────────────
+
+/**
+ * What a ticket's QR encodes, signed with the party's key.
+ *
+ * Keyed on the ticket code rather than an invite id so that scanning and typing
+ * resolve the same way: the door does one lookup either way, and a scanner with
+ * a broken camera is not a different code path.
+ */
+export interface TicketQRPayload {
+ /** The five-character code, also printed on the ticket. */
+ code: string;
+ /**
+ * The party this ticket is for — its server id on a shared party, or the
+ * browser's local id on a free-tier one. Checked so a ticket for last
+ * weekend's party cannot be scanned at this one.
+ */
+ partyId: string;
+ guestName: string;
+ expiresAt: string;
+}
+
+/** Why a ticket was refused. The door needs to tell these apart. */
+export type TicketRejection =
+ | 'malformed'
+ | 'bad_signature'
+ | 'wrong_party'
+ | 'expired'
+ | 'unknown_code'
+ | 'name_mismatch'
+ | 'not_coming'
+ | 'already_used';
diff --git a/shared/tiers.ts b/shared/tiers.ts
new file mode 100644
index 0000000..2d520f9
--- /dev/null
+++ b/shared/tiers.ts
@@ -0,0 +1,109 @@
+/**
+ * The tier model, shared verbatim by the Astro frontend and the Hono Worker.
+ *
+ * It lives outside both `src/` and `backend/` on purpose. A capability that the
+ * UI hides but the API still serves is a paywall that leaks, and one the API
+ * refuses but the UI offers is a bug report; the only way to keep the two
+ * honest is for them to read the same table. Both sides import this file by
+ * relative path (see `backend/tsconfig.json` and `tsconfig.json`), the way
+ * FantasyWiki shares its `model/` and `dto/` directories.
+ */
+
+// ── Tiers ───────────────────────────────────────────────────────────────────
+
+export const TIERS = ['free', 'pro'] as const;
+
+/**
+ * `free` is what an anonymous browser gets — no account, no server, everything
+ * in IndexedDB. `pro` is the one-time purchase, and is also what every signed-in
+ * user of a self-hosted deployment gets (see {@link resolveTier}).
+ */
+export type Tier = (typeof TIERS)[number];
+
+export function isTier(value: unknown): value is Tier {
+ return (
+ typeof value === 'string' && (TIERS as readonly string[]).includes(value)
+ );
+}
+
+// ── Features ────────────────────────────────────────────────────────────────
+
+export const FEATURES = [
+ /** Shareable RSVP link — guests add themselves instead of the host typing them in. */
+ 'inviteLink',
+ /** The reached → confirmed funnel and the friends-of-friends spread view. */
+ 'rsvpFunnel',
+ /** A second organiser editing the same party. */
+ 'coOrganizers',
+ /** Parties, guests and tickets stored server-side and readable from any device. */
+ 'cloudSync',
+ /** Several phones on the door sharing one check-in state. */
+ 'doorScannerSync',
+] as const;
+
+export type Feature = (typeof FEATURES)[number];
+
+export type FeatureSet = Readonly>;
+
+/**
+ * What each tier may do.
+ *
+ * Everything not listed here is unconditionally free: the whole planning side
+ * (menu, shopping list, costs, break-even), the manual guest list, locally
+ * signed QR tickets and a single door scanner. The paid line is drawn at the
+ * features that need a server to exist at all — a link someone else can open,
+ * a party two people can edit, state shared across devices.
+ */
+// Frozen, not merely `Readonly`: these objects are handed to Vue's `reactive`
+// state and to JSON responses, and `Readonly` is a compile-time promise only.
+// Freezing also tells Vue not to deeply proxy them, which is what we want for a
+// lookup table that never changes.
+const BY_TIER: Readonly> = Object.freeze({
+ free: Object.freeze({
+ inviteLink: false,
+ rsvpFunnel: false,
+ coOrganizers: false,
+ cloudSync: false,
+ doorScannerSync: false,
+ }),
+ pro: Object.freeze({
+ inviteLink: true,
+ rsvpFunnel: true,
+ coOrganizers: true,
+ cloudSync: true,
+ doorScannerSync: true,
+ }),
+});
+
+export function featuresFor(tier: Tier): FeatureSet {
+ return BY_TIER[tier];
+}
+
+// ── Resolution ──────────────────────────────────────────────────────────────
+
+/** Everything that bears on which tier a caller is actually on. */
+export interface TierContext {
+ /** The tier stored on the user row, or `null` when nobody is signed in. */
+ storedTier: Tier | null;
+ /**
+ * True when this deployment is somebody's own Worker rather than the hosted
+ * one. Comes from the `SELF_HOSTED` var in `wrangler.jsonc`, never from the
+ * client.
+ */
+ selfHosted: boolean;
+}
+
+/**
+ * The single place a tier is decided.
+ *
+ * Self-hosting is the third way to get the full product, so a signed-in user of
+ * a self-hosted Worker is `pro` regardless of what their row says — the point of
+ * self-hosting is that there is nobody to pay. It deliberately does not promote
+ * *anonymous* callers: co-organisers and an invite funnel need to know who is
+ * who even when the deployment is yours, so a self-hoster still signs in
+ * (`/auth/dev` exists so they can do that without registering a Google client).
+ */
+export function resolveTier({ storedTier, selfHosted }: TierContext): Tier {
+ if (storedTier === null) return 'free';
+ return selfHosted ? 'pro' : storedTier;
+}
diff --git a/src/components/AccountButton.vue b/src/components/AccountButton.vue
new file mode 100644
index 0000000..b20bdad
--- /dev/null
+++ b/src/components/AccountButton.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
diff --git a/src/components/BottleApp.vue b/src/components/BottleApp.vue
index db3b79f..de6459a 100644
--- a/src/components/BottleApp.vue
+++ b/src/components/BottleApp.vue
@@ -18,6 +18,8 @@ import SendTicketModal from './modals/SendTicketModal.vue';
import TicketModal from './modals/TicketModal.vue';
import AddGuestModal from './modals/AddGuestModal.vue';
import DoorScannerModal from './modals/DoorScannerModal.vue';
+import UpgradeModal from './UpgradeModal.vue';
+import CoOrganisersModal from './modals/CoOrganisersModal.vue';
const store = useStore();
@@ -218,7 +220,15 @@ const tabs = computed(() => {
+
+
+
+
diff --git a/src/components/Canonical.astro b/src/components/Canonical.astro
new file mode 100644
index 0000000..828bd13
--- /dev/null
+++ b/src/components/Canonical.astro
@@ -0,0 +1,34 @@
+---
+import { CANONICAL_ORIGIN } from '../lib/links';
+
+/**
+ * Names the copy of this page that search engines should index.
+ *
+ * The landing page, docs, pricing and the legal pages are built for both the
+ * application host and the documentation host, so each exists at two URLs. This
+ * points both at the application's copy — that domain is the product, and it
+ * serves these pages as well as the app itself.
+ *
+ * On the application build `CANONICAL_ORIGIN` is empty and the page falls back
+ * to `Astro.site`, so it is its own canonical. That is deliberate rather than
+ * omitting the tag: a self-referencing canonical is what stops a URL picking up
+ * a query string or a trailing slash and being treated as a second page.
+ */
+const base = import.meta.env.BASE_URL;
+const origin = CANONICAL_ORIGIN || Astro.site?.origin || '';
+
+// `Astro.url.pathname` carries the base prefix; the canonical target does not
+// use the same one, so strip it. `base.length - 1` keeps the leading slash.
+const pathname = Astro.url.pathname;
+const withoutBase = pathname.startsWith(base)
+ ? pathname.slice(base.length - 1)
+ : pathname;
+
+// Normalise the trailing slash so `/pricing` and `/pricing/` cannot resolve to
+// two different canonicals. The root keeps its slash.
+const path = withoutBase.length > 1 ? withoutBase.replace(/\/$/, '') : '/';
+
+const href = origin ? new URL(path, origin).href : null;
+---
+
+{href && }
diff --git a/src/components/HomeScreen.vue b/src/components/HomeScreen.vue
index fbb18e9..6adef24 100644
--- a/src/components/HomeScreen.vue
+++ b/src/components/HomeScreen.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useStore, COVERS } from '../lib/store';
import Icon from './Icon.vue';
import AppFooter from './AppFooter.vue';
+import AccountButton from './AccountButton.vue';
import type { Party } from '../lib/types';
const store = useStore();
@@ -45,7 +46,7 @@ const partyCards = computed(() => {
})
: '—';
- const accepted = p.invites.filter((i) => i.status === 'accepted').length;
+ const accepted = p.invites.filter((i) => i.status === 'confirmed').length;
const r = store.calcForParty(p);
const avgProfit = (r.profit_min + r.profit_max) / 2;
const profitColor = avgProfit >= 0 ? 'var(--good)' : 'var(--bad)';
@@ -142,6 +143,7 @@ async function handleDelete(e: Event, id: number): Promise {
+ {{ preview.invitedBy }} has asked you to help run
+ this party.
+
+
+ You'll be able to edit the menu, the budget and the guest list, from
+ your own device. You can't delete the party or remove people — that
+ stays with {{ preview.invitedBy }}.
+
-
-
+ Your link is live. Anyone who opens it RSVPs
+ with their own name
+ and lands in the guest list below — including the ones who look and
+ never answer, which is what Reached counts.
+
+
+ Hit
+ Send invite
+ to create your link. Anyone who opens it RSVPs with their own name
+ and lands in the guest list below. You never type them in.
+
+
+
+
+
+
+
+
+
+ Reached
+
+
Maybe
+ {{ invites.length }}
+
+
+
- {{ pending.length }}
+
+
+ Confirmed
+
+
+ {{ confirmed.length }}
+
-
-
-
-
-
-
+
+
+
+ Maybe
+
+
Declined
+ {{ opened.length }}
+
+
+
- {{ declined.length }}
+
+
+ Declined
+
+
+ {{ declined.length }}
+
-
-
-
-
- Filling {{ capacity }} expected toward a {{ maxCap }} cap — green is
- on target, amber is past expected
-
-
- Against {{ capacity }} capacity — solid is confirmed, faded is still
- maybe
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Filling {{ capacity }} expected toward a {{ maxCap }} cap — green is
+ on target, amber is past expected
+
+
+ Against {{ capacity }} capacity — solid is confirmed, faded is still
+ maybe
+
+
- on, your guests can forward the invite to their own friends — anyone
- they bring lands in this tier.
+ With
+ "let guests invite friends"
+ on, your guests can forward the invite to their own friends — anyone
+ they bring lands in this tier.
+