From b8506c5fd5456563fca2c1af9e1254ed2a2902b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:17:53 +0000 Subject: [PATCH 1/6] feat: move to Cloudflare and add the three-tier model BottleCount was a static bundle with no backend, which caps three features the landing page already advertises: the invite link is a mockup pointing at a URL nothing serves, the RSVP funnel counts a list the host typed in themselves, and co-organisers were never started. All three need a server, because the person opening an invite is not the person holding the party in their IndexedDB. This adds that server and the business model around it: free in the browser with no account, one-time payment for the hosted version, free if you host it yourself. See docs/adr/0001-cloudflare-tiers.md. Backend (new, backend/): - Hono Worker on Cloudflare Workers, D1 behind repository interfaces - Google OAuth -> HS256 JWT in an httpOnly session_token cookie - POST /auth/dev signs in without Google; 404 unless local or self-hosted - GET /api/session answers anonymous callers with the free tier rather than 401, since "logged out" is a supported state and not an error - POST /api/licences/redeem turns a code into pro; codes are minted by hand (npm run licence:issue) until a checkout provider is chosen - 28 tests over routing, the /api/* guard and tier resolution Frontend: - Pages Functions forward /api/* and /auth/* to the Worker over a service binding, keeping the session cookie first-party. The /auth proxies are named files, not a catchall, which would swallow /auth/callback - ProLock gates the funnel and spread view; openShare gates the invite link - Upgrade prompt, account control, /pricing page, /auth/callback - Every path to a missing backend resolves to the anonymous free session, so the planner still works with the Worker absent, down or unreachable shared/tiers.ts is imported by both sides. A capability the UI hides but the API serves is a paywall that leaks; one the API refuses but the UI offers is a bug report. Also: - astro.config takes base/site from the environment: Cloudflare at /, GitHub Pages at /BottleCount/ for the docs - privacy and terms rewritten - they described a Google Sheets integration that no longer exists and no account model, which is now wrong in both directions - CI workflow; Cloudflare deploy workflow that skips without secrets Party data is still local on every tier. cloudSync is declared and locked but not yet backed by anything, and the parties migration is deliberately absent: D1 migrations are append-only, and a shape invented ahead of its first consumer is a shape you migrate away from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9 --- .gitattributes | 9 + .github/workflows/check.yml | 40 + .github/workflows/deploy-cloudflare.yml | 95 + .github/workflows/deploy.yml | 15 +- .gitignore | 6 + .prettierignore | 11 +- README.md | 220 +- astro.config.mjs | 11 +- backend/.dev.vars.example | 11 + backend/.gitignore | 4 + backend/README.md | 107 + .../migrations/0001_users_and_licences.sql | 52 + backend/package-lock.json | 2673 +++++++++++++++++ backend/package.json | 31 + backend/scripts/issue-licence.mjs | 70 + backend/src/app.ts | 89 + backend/src/appEnv.ts | 6 + backend/src/composition.ts | 14 + backend/src/index.ts | 3 + backend/src/repositories/d1/index.ts | 10 + .../repositories/d1/licenceRepositoryD1.ts | 65 + .../src/repositories/d1/userRepositoryD1.ts | 136 + backend/src/repositories/licenceRepository.ts | 28 + backend/src/repositories/repositories.ts | 8 + backend/src/repositories/result.ts | 18 + backend/src/repositories/userRepository.ts | 39 + backend/src/routes/auth.ts | 82 + backend/src/routes/devAuth.ts | 59 + backend/src/routes/frontendUrl.ts | 13 + backend/src/routes/helpers.ts | 9 + backend/src/routes/issueSession.ts | 51 + backend/src/routes/licences.ts | 58 + backend/src/routes/session.ts | 76 + backend/src/tests/routes/devAuth.spec.ts | 63 + backend/src/tests/routes/licences.spec.ts | 142 + backend/src/tests/routes/session.spec.ts | 83 + backend/src/tests/support/fakeRepositories.ts | 116 + backend/src/tests/support/harness.ts | 65 + backend/src/tests/tiers.spec.ts | 60 + backend/tsconfig.json | 19 + backend/vitest.config.ts | 21 + backend/wrangler.jsonc | 94 + docs/adr/0001-cloudflare-tiers.md | 105 + eslint.config.mjs | 11 +- functions/_backend.ts | 36 + functions/api/[[catchall]].ts | 4 + functions/auth/README.md | 12 + functions/auth/dev.ts | 4 + functions/auth/google.ts | 4 + functions/auth/logout.ts | 4 + package-lock.json | 897 +++++- package.json | 9 +- shared/session.ts | 31 + shared/tiers.ts | 109 + src/components/AccountButton.vue | 128 + src/components/BottleApp.vue | 8 + src/components/HomeScreen.vue | 2 + src/components/Icon.vue | 5 + src/components/NavBar.astro | 1 + src/components/ProLock.vue | 84 + src/components/UpgradeModal.vue | 263 ++ src/components/modals/ShareModal.vue | 13 +- src/components/tabs/GuestsTab.vue | 906 +++--- src/lib/session.ts | 84 + src/lib/store.ts | 68 + src/pages/auth/callback.astro | 44 + src/pages/pricing.astro | 296 ++ src/pages/privacy.astro | 399 +-- src/pages/terms.astro | 343 ++- tsconfig.json | 7 +- wrangler.toml | 27 + 71 files changed, 7850 insertions(+), 836 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/deploy-cloudflare.yml create mode 100644 backend/.dev.vars.example create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/migrations/0001_users_and_licences.sql create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/scripts/issue-licence.mjs create mode 100644 backend/src/app.ts create mode 100644 backend/src/appEnv.ts create mode 100644 backend/src/composition.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/repositories/d1/index.ts create mode 100644 backend/src/repositories/d1/licenceRepositoryD1.ts create mode 100644 backend/src/repositories/d1/userRepositoryD1.ts create mode 100644 backend/src/repositories/licenceRepository.ts create mode 100644 backend/src/repositories/repositories.ts create mode 100644 backend/src/repositories/result.ts create mode 100644 backend/src/repositories/userRepository.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/devAuth.ts create mode 100644 backend/src/routes/frontendUrl.ts create mode 100644 backend/src/routes/helpers.ts create mode 100644 backend/src/routes/issueSession.ts create mode 100644 backend/src/routes/licences.ts create mode 100644 backend/src/routes/session.ts create mode 100644 backend/src/tests/routes/devAuth.spec.ts create mode 100644 backend/src/tests/routes/licences.spec.ts create mode 100644 backend/src/tests/routes/session.spec.ts create mode 100644 backend/src/tests/support/fakeRepositories.ts create mode 100644 backend/src/tests/support/harness.ts create mode 100644 backend/src/tests/tiers.spec.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/vitest.config.ts create mode 100644 backend/wrangler.jsonc create mode 100644 docs/adr/0001-cloudflare-tiers.md create mode 100644 functions/_backend.ts create mode 100644 functions/api/[[catchall]].ts create mode 100644 functions/auth/README.md create mode 100644 functions/auth/dev.ts create mode 100644 functions/auth/google.ts create mode 100644 functions/auth/logout.ts create mode 100644 shared/session.ts create mode 100644 shared/tiers.ts create mode 100644 src/components/AccountButton.vue create mode 100644 src/components/ProLock.vue create mode 100644 src/components/UpgradeModal.vue create mode 100644 src/lib/session.ts create mode 100644 src/pages/auth/callback.astro create mode 100644 src/pages/pricing.astro create mode 100644 wrangler.toml 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..214ac0a --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,40 @@ +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 + + - name: Backend tests + run: npm run backend:test + + # Catches the failure mode a typecheck cannot: a build that trips over + # the shared/ imports crossing the package boundary. + - name: Build frontend + run: npm run build diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml new file mode 100644 index 0000000..5382627 --- /dev/null +++ b/.github/workflows/deploy-cloudflare.yml @@ -0,0 +1,95 @@ +# Cloudflare — the app's real home. +# +# Two deployments in order, and the order matters: the Pages project binds to +# the Worker by service name, so the Worker has to exist before Pages can +# resolve the binding on a first deploy. +# +# Requires two repository secrets, CLOUDFLARE_API_TOKEN and +# CLOUDFLARE_ACCOUNT_ID. Without them the workflow stops at the guard below +# rather than failing every push on a fork or a clone that has no Cloudflare +# account behind it. +name: Deploy to Cloudflare + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + # `secrets` is not readable from a job-level `if`, so the check is a job of + # its own that publishes a plain output the others can gate on. + configured: + runs-on: ubuntu-latest + outputs: + ready: ${{ steps.check.outputs.ready }} + steps: + - id: check + env: + TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [ -n "$TOKEN" ] && [ -n "$ACCOUNT" ]; then + echo "ready=true" >> "$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 BASE_PATH: Cloudflare serves the app at the root, which is the + # default in astro.config.mjs. + - 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..e8cf122 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,14 @@ -name: Deploy to GitHub Pages +# GitHub Pages — documentation and the public showcase. +# +# The app's home is Cloudflare Pages (see deploy-cloudflare.yml). This build is +# the same source served under the repository's path prefix, which is why +# BASE_PATH is set here and defaults to "/" everywhere else: hard-coding either +# root in astro.config.mjs would break the other host. +# +# There is no backend behind this deployment, and that is fine by design — the +# Pages Functions proxy is absent, /api/session is unreachable, and the app +# settles on the free, browser-only tier. +name: Deploy docs to GitHub Pages on: push: @@ -23,6 +33,9 @@ jobs: with: package-manager: 'npm' node-version: 22 + env: + BASE_PATH: /BottleCount/ + SITE: https://fre0grella.github.io 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..eada303 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,52 @@ # 🍾 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). [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Astro](https://img.shields.io/badge/Astro-5.x-BC52EE?logo=astro&logoColor=white)](https://astro.build/) [![Vue](https://img.shields.io/badge/Vue-3.x-4FC08D?logo=vuedotjs&logoColor=white)](https://vuejs.org/) +[![Hono](https://img.shields.io/badge/Hono-Workers-E36002?logo=hono&logoColor=white)](https://hono.dev/) [![Dexie](https://img.shields.io/badge/Dexie-IndexedDB-F7DF1E?logo=javascript&logoColor=black)](https://dexie.org/) -[![GitHub Pages](https://img.shields.io/badge/Deploy-GitHub%20Pages-222222?logo=githubpages&logoColor=white)](https://pages.github.com/) +[![Cloudflare](https://img.shields.io/badge/Deploy-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://developers.cloudflare.com/pages/) [![License: PolyForm NC](https://img.shields.io/badge/License-PolyForm_NC-3db077)](LICENSE) -**Live:** https://fre0grella.github.io/BottleCount +**App:** https://bottlecount.pages.dev · **Docs:** https://fre0grella.github.io/BottleCount --- ## 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). --- @@ -56,11 +85,12 @@ Alcohol intensity presets still map to pure alcohol targets per person: 🌿 Sof - 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. +- Multi-scanner check-in on the hosted and self-hosted tiers, where several + phones on the door can agree on who has already walked in. -### 📱 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 +98,48 @@ 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. + +Remove the binding — or deploy the static build anywhere else, as the GitHub +Pages job does — and the app degrades cleanly to the free browser-only 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` | +### 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 +180,136 @@ 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 +``` --- -## Deploy to GitHub Pages +## Deploying -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`. +### 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 docs) + +Push to `main` and `.github/workflows/deploy.yml` builds the same source with +`BASE_PATH=/BottleCount/`. There is no backend behind that deployment, which is +fine: the app settles on the free browser-only tier. + +--- + +## Self-hosting + +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; party data is still local for every tier today, and moving it is the next piece of work ([ADR 0001](docs/adr/0001-cloudflare-tiers.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) + +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..cb5d065 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,9 +1,16 @@ import { defineConfig } from 'astro/config'; import vue from '@astrojs/vue'; +// The same source builds for two hosts, and they disagree about the root: +// Cloudflare Pages serves the app at `/`, GitHub Pages serves the docs under +// `/BottleCount/`. Hard-coding either breaks the other, so both come from the +// environment and Cloudflare — the app's home — is the default. +const site = process.env.SITE ?? 'https://bottlecount.pages.dev'; +const base = process.env.BASE_PATH ?? '/'; + export default defineConfig({ - site: 'https://fre0grella.github.io', - base: '/BottleCount/', + site, + base, integrations: [vue()], 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..44db704 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,107 @@ +# BottleCount backend + +A Hono Worker on Cloudflare, backed by D1. It exists to serve the three things a +static bundle cannot: who you are, what you have paid for, and (next) the party +data two people need to share. + +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` | + +`/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. + +## 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 and the redemption rules. They cannot catch +a mistake in a SQL statement. + +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/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..dba31b6 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,89 @@ +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 devAuth from './routes/devAuth'; +import licences from './routes/licences'; +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; +}; + +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); + + return app; +} diff --git a/backend/src/appEnv.ts b/backend/src/appEnv.ts new file mode 100644 index 0000000..98aae15 --- /dev/null +++ b/backend/src/appEnv.ts @@ -0,0 +1,6 @@ +import type { Repositories } from './repositories/repositories'; + +/** Everything the `*`-middleware puts on the context for routes to read. */ +export interface AppVariables { + repositories: Repositories; +} 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/repositories/d1/index.ts b/backend/src/repositories/d1/index.ts new file mode 100644 index 0000000..fe46548 --- /dev/null +++ b/backend/src/repositories/d1/index.ts @@ -0,0 +1,10 @@ +import type { Repositories } from '../repositories'; +import { LicenceRepositoryD1 } from './licenceRepositoryD1'; +import { UserRepositoryD1 } from './userRepositoryD1'; + +export function d1Repositories(db: D1Database): Repositories { + return { + users: new UserRepositoryD1(db), + licences: new LicenceRepositoryD1(db), + }; +} 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/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/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/repositories.ts b/backend/src/repositories/repositories.ts new file mode 100644 index 0000000..f19046d --- /dev/null +++ b/backend/src/repositories/repositories.ts @@ -0,0 +1,8 @@ +import type { LicenceRepository } from './licenceRepository'; +import type { UserRepository } from './userRepository'; + +/** Everything a route can reach storage through. */ +export interface Repositories { + users: UserRepository; + licences: LicenceRepository; +} 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/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/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/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/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/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/fakeRepositories.ts b/backend/src/tests/support/fakeRepositories.ts new file mode 100644 index 0000000..c533ca1 --- /dev/null +++ b/backend/src/tests/support/fakeRepositories.ts @@ -0,0 +1,116 @@ +import type { Tier } from '../../../../shared/tiers'; +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(), +): Repositories { + return { users, licences }; +} + +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/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..720c2e5 --- /dev/null +++ b/backend/wrangler.jsonc @@ -0,0 +1,94 @@ +{ + "$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", + }, + ], + "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": "", + }, + ], + "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": "", + }, + ], + "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": "", + }, + ], + "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..ab4fc0f --- /dev/null +++ b/docs/adr/0001-cloudflare-tiers.md @@ -0,0 +1,105 @@ +# 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 keeps working** and becomes the documentation host. The same + source builds for both, with `BASE_PATH` deciding the root. +- **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/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..2391841 --- /dev/null +++ b/functions/_backend.ts @@ -0,0 +1,36 @@ +/** + * 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 is the browser-only build: the + // free tier works entirely client-side, so say so in the shape the frontend + // already handles instead of 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/package-lock.json b/package-lock.json index db947b6..866354c 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", @@ -36,6 +37,130 @@ "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 +176,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 +310,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 +780,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" } @@ -1735,6 +1994,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 +2531,16 @@ "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/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2643,6 +2933,104 @@ "vue": "^3.0.0" } }, + "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", @@ -3899,6 +4287,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", @@ -4497,6 +4902,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 +5552,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 +5569,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 +6640,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", @@ -6510,6 +6956,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", @@ -6925,6 +7378,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 +7394,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", @@ -7487,13 +7957,13 @@ } }, "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 +8051,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 +8067,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 +8088,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 +8765,309 @@ } } }, + "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", @@ -8469,6 +9272,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..5c42007 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,13 @@ "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": "npm --prefix backend run test", + "typecheck": "astro check && npm --prefix backend run typecheck", + "install:all": "npm install && npm --prefix backend install" }, "engines": { "node": ">=22.12.0" @@ -24,6 +30,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", 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/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..cb7c3fb --- /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..e03769f 100644 --- a/src/components/BottleApp.vue +++ b/src/components/BottleApp.vue @@ -18,6 +18,7 @@ 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'; const store = useStore(); @@ -219,6 +220,13 @@ const tabs = computed(() => { + + + diff --git a/src/components/HomeScreen.vue b/src/components/HomeScreen.vue index fbb18e9..b82e51b 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(); @@ -142,6 +143,7 @@ async function handleDelete(e: Event, id: number): Promise {
+ ', tune: '', pencil: '', + lock: '', + user: '', + logout: + '', + key: '', }; diff --git a/src/components/NavBar.astro b/src/components/NavBar.astro index 24b9827..8432e3f 100644 --- a/src/components/NavBar.astro +++ b/src/components/NavBar.astro @@ -5,6 +5,7 @@ const base = import.meta.env.BASE_URL; const links = [ { href: base, label: 'Home', key: 'home' }, { href: `${base}app`, label: 'App', key: 'app' }, + { href: `${base}pricing`, label: 'Pricing', key: 'pricing' }, { href: `${base}docs`, label: 'Docs', key: 'docs' }, ]; --- diff --git a/src/components/ProLock.vue b/src/components/ProLock.vue new file mode 100644 index 0000000..2321212 --- /dev/null +++ b/src/components/ProLock.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/components/UpgradeModal.vue b/src/components/UpgradeModal.vue new file mode 100644 index 0000000..e39b355 --- /dev/null +++ b/src/components/UpgradeModal.vue @@ -0,0 +1,263 @@ + + + diff --git a/src/components/modals/ShareModal.vue b/src/components/modals/ShareModal.vue index fda282e..caf0b2d 100644 --- a/src/components/modals/ShareModal.vue +++ b/src/components/modals/ShareModal.vue @@ -16,6 +16,10 @@ const cover = computed(() => { return COVERS[p.cover] ?? COVERS[0]; }); +// Built from the origin the app is actually served from, so a preview +// deployment, a self-hosted domain and the hosted app each hand out a link that +// points back at themselves. The `/i/` route that resolves these is still to +// come — see docs/adr/0001-cloudflare-tiers.md. const inviteLink = computed(() => { const p = party.value; if (!p) return ''; @@ -24,7 +28,14 @@ const inviteLink = computed(() => { .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') || 'party'; - return `bottlecount.app/i/${slug}-${p.id}`; + const origin = + typeof window === 'undefined' + ? 'bottlecount.pages.dev' + : window.location.host; + // BASE_URL, not a bare `/`: a build served under a path prefix would + // otherwise hand out links that miss the prefix entirely. + const base = import.meta.env.BASE_URL as string; + return `${origin}${base}i/${slug}-${p.id}`; }); const venueWhere = computed(() => { diff --git a/src/components/tabs/GuestsTab.vue b/src/components/tabs/GuestsTab.vue index 2d28975..2ec20ce 100644 --- a/src/components/tabs/GuestsTab.vue +++ b/src/components/tabs/GuestsTab.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue'; import { useStore } from '../../lib/store'; import Icon from '../Icon.vue'; +import ProLock from '../ProLock.vue'; const store = useStore(); @@ -119,446 +120,410 @@ const isPhone = computed(() => store.state.device === 'phone'); animation: bcFadeUp 0.3s ease both; " > - -
+ - +
- +
- - + + + +
+ RSVP funnel +
+ +
+ +
- RSVP funnel + Share your invite link — anyone who opens it RSVPs + with their own name + and lands in the guest list below. You never type them in.
- -
- -
- Share your invite link — anyone who opens it RSVPs - with their own name - and lands in the guest list below. You never type them in. -
- - -
- +
-
- - +
+
+ + Reached +
+
Reached + {{ invites.length }} +
+ +
- {{ invites.length }} -
-
- - -
-
- - + + Confirmed +
+
Confirmed + {{ accepted.length }} +
+ +
- {{ accepted.length }} -
-
- - -
-
- - + + Maybe +
+
Maybe + {{ pending.length }} +
+ +
- {{ pending.length }} +
+ + Declined +
+
+ {{ declined.length }} +
- + +
+ + +
-
- - Declined -
+ +
+ +
+ +
+
- {{ declined.length }} -
+ >
-
- - -
- - -
-
-
- -
- -
- -
-
-
- - {{ accepted.length }}/{{ hasMax ? maxCap : capacity }} - {{ hasMax ? 'to cap' : '' }} - - - {{ capacity }} expected - + > + + {{ accepted.length }}/{{ hasMax ? maxCap : capacity }} + {{ hasMax ? 'to cap' : '' }} + + + {{ capacity }} expected + +
- - -
- +
- +
- - -
-
- How far the word spread -
-
- guests inviting their own friends -
-
- -
-
- {{ spreadFactor }} -
-
- spread factor -
-
-
- - -
- -
-
- + +
+
★ + How far the word spread +
+
+ guests inviting their own friends +
-
- 1 -
-
- You -
-
- the host + +
+
+ {{ spreadFactor }} +
+
+ spread factor +
- +
+
- -
-
- {{ direct.length }} -
-
- Direct invites -
-
- invited by you +
+
+ 1 +
+
+ You +
+
+ the host +
-
- -
+
- +
+ +
+
+ {{ direct.length }} +
+
+ Direct invites +
+
+ invited by you +
+ +
- {{ viral.length }} -
-
- Friends-of-friends -
-
- forwarded by guests +
+ +
+
+ {{ viral.length }} +
+
+ Friends-of-friends +
+
+ forwarded by guests +
-
- -
- With - "let guests invite friends" +
- 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. +
-
+
; + return ( + typeof dto.authenticated === 'boolean' && + typeof dto.tier === 'string' && + typeof dto.features === 'object' && + dto.features !== null + ); +} + +/** + * Asks the Worker who the caller is. + * + * Never throws and never rejects. Every failure — no backend deployed, a 501 + * from the proxy, a network error, a body that isn't a session — resolves to + * {@link ANONYMOUS_SESSION}, because the alternative is an app that refuses to + * start when the part of it that is meant to be optional is missing. + */ +export async function fetchSession(): Promise { + try { + const res = await fetch('/api/session', { + credentials: 'include', + headers: { accept: 'application/json' }, + }); + if (!res.ok) return ANONYMOUS_SESSION; + const body: unknown = await res.json(); + return isSessionDTO(body) ? body : ANONYMOUS_SESSION; + } catch { + return ANONYMOUS_SESSION; + } +} + +/** Clears the httpOnly session cookie, which only the server can do. */ +export async function logout(): Promise { + try { + await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); + } catch { + /* Already effectively signed out as far as the user is concerned. */ + } +} + +export interface RedeemResult { + ok: boolean; + error?: string; +} + +/** Exchanges a purchased licence code for the `pro` tier. */ +export async function redeemLicence(code: string): Promise { + try { + const res = await fetch('/api/licences/redeem', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code }), + }); + if (res.ok) return { ok: true }; + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `http_${res.status}` }; + } catch { + return { ok: false, error: 'network_error' }; + } +} diff --git a/src/lib/store.ts b/src/lib/store.ts index b30e410..0b1b77c 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -7,6 +7,9 @@ import type { Settings, } from './types'; import { db } from './db'; +import { fetchSession, ANONYMOUS_SESSION } from './session'; +import type { SessionDTO } from '../../shared/session'; +import type { Feature } from '../../shared/tiers'; import { loadCatalog, defaultExtras } from './catalog'; import { calculate } from './core'; import { rebalance, menuErrorKeys } from './menu'; @@ -71,6 +74,13 @@ export const SIMPLE: string[] = ['Beer', 'Wine']; interface StoreState { ready: boolean; + /** + * Who the browser is and what it may do. Starts anonymous, so the app is + * usable before — and without — an answer from the server. + */ + session: SessionDTO; + /** True while the first /api/session call is in flight. */ + sessionLoading: boolean; route: 'home' | 'party'; activeId: number | null; tab: 'plan' | 'menu' | 'shop' | 'guests'; @@ -89,6 +99,8 @@ interface StoreState { doorOpen: boolean; scanResult: { ok: boolean; name: string; sub?: string } | null; scanHistory: { name: string; time: string }[]; + /** The feature whose upgrade prompt is open, or null. */ + upgradeFor: Feature | null; // ui expandedCat: string | null; expandedSpirit: string | null; @@ -98,6 +110,8 @@ interface StoreState { const state = reactive({ ready: false, + session: ANONYMOUS_SESSION, + sessionLoading: true, route: 'home', activeId: null, tab: 'plan', @@ -115,6 +129,7 @@ const state = reactive({ doorOpen: false, scanResult: null, scanHistory: [], + upgradeFor: null, expandedCat: null, expandedSpirit: null, }); @@ -154,10 +169,48 @@ function update(mutator: (p: Party) => void): void { }); } +/** + * Whether the current session may use a paid feature. + * + * Every gate in the UI goes through here rather than reading `tier` directly, + * so that self-hosting and a future third tier stay a change to + * `shared/tiers.ts` instead of a hunt through components. + */ +function can(feature: Feature): boolean { + return state.session.features[feature] === true; +} + +/** Opens the "this needs BottleCount Pro" prompt for a locked feature. */ +function requestUpgrade(feature: Feature): void { + state.upgradeFor = feature; +} + +function closeUpgrade(): void { + state.upgradeFor = null; +} + +/** + * Re-reads the session. Called on load, and again after signing in or + * redeeming a licence, since both change what the app may do. + */ +async function refreshSession(): Promise { + state.sessionLoading = true; + try { + state.session = await fetchSession(); + } finally { + state.sessionLoading = false; + } +} + async function load(): Promise { state.catalog = await loadCatalog(); state.parties = await db.parties.toArray(); + // Deliberately not awaited: the planner is local-first and must render + // without waiting on a network round-trip that may never come back. Paid + // features stay locked until it does, which is the correct default. + void refreshSession(); + // Backfill fields added after a party was first saved. for (const p of state.parties) { if (p.settings.max_capacity === undefined) p.settings.max_capacity = null; @@ -522,7 +575,17 @@ function closeIngMgr(): void { state.ingMgrOpen = false; } +/** + * The invite link is the paid feature here, and the share sheet is the only way + * to reach it — so the gate lives on the opener rather than inside the modal. + * A second caller added later inherits it for free, which a check in the + * component would not give us. + */ function openShare(): void { + if (!can('inviteLink')) { + requestUpgrade('inviteLink'); + return; + } state.shareOpen = true; } function closeShare(): void { @@ -596,6 +659,11 @@ export const store = { reloadCatalog, // loader load, + // session & entitlements + can, + refreshSession, + requestUpgrade, + closeUpgrade, // invites addInvite, setInviteStatus, diff --git a/src/pages/auth/callback.astro b/src/pages/auth/callback.astro new file mode 100644 index 0000000..0f7e126 --- /dev/null +++ b/src/pages/auth/callback.astro @@ -0,0 +1,44 @@ +--- +import '../../styles/theme.css'; +const base = import.meta.env.BASE_URL; +--- + + + + + + + Signing you in — BottleCount + + + + + + + + + diff --git a/src/pages/pricing.astro b/src/pages/pricing.astro new file mode 100644 index 0000000..016cfdc --- /dev/null +++ b/src/pages/pricing.astro @@ -0,0 +1,296 @@ +--- +import '../styles/global.css'; +import NavBar from '../components/NavBar.astro'; +import Footer from '../components/Footer.astro'; +const base = import.meta.env.BASE_URL; + +// The three ways to run BottleCount. Everything in `planning` is unconditional; +// the paid line is drawn at the features that need a server to exist at all. +// Keep this table in step with shared/tiers.ts — it is the sales copy for the +// same decisions that file encodes. +const plans = [ + { + key: 'browser', + name: 'Browser', + price: 'Free', + priceNote: 'no account, forever', + pitch: + 'The whole planner, running entirely in your browser. Nothing is uploaded, because there is nowhere to upload it to.', + cta: { label: 'Open the app', href: `${base}app` }, + featured: false, + has: [ + 'Auto-balancing drink menu', + 'Shopping list with price ranges', + 'Live budget and break-even', + 'Custom ingredients and cocktails', + 'Guest list you type yourself', + 'Signed QR tickets', + 'Door scanner on one device', + ], + hasnt: [ + 'Shareable invite link', + 'RSVP funnel and spread view', + 'Co-organisers', + 'Sync across your devices', + ], + }, + { + key: 'hosted', + name: 'Hosted', + price: '€29', + priceNote: 'one payment, no subscription', + pitch: + 'Everything above, plus the parts that need a server: links other people can open, a party two of you can run, and your data on more than one device.', + cta: { label: 'Sign in to get started', href: '/auth/google' }, + featured: true, + has: [ + 'Everything in Browser', + 'Shareable invite link — guests add themselves', + 'RSVP funnel and friends-of-friends spread', + 'Co-organisers on the same party', + 'Your parties on every device you sign in on', + 'Several phones scanning the same door', + ], + hasnt: [], + }, + { + key: 'selfhost', + name: 'Self-hosted', + price: 'Free', + priceNote: 'your Cloudflare account', + pitch: + 'The same code, deployed to your own Cloudflare account. Every paid feature is on, because there is nobody to pay — you are running the server.', + cta: { + label: 'Read the setup guide', + href: 'https://github.com/Fre0Grella/BottleCount#self-hosting', + }, + featured: false, + has: [ + 'Everything in Hosted', + 'Your own D1 database', + 'Sign in without a Google client, if you prefer', + 'Free tier of Cloudflare is enough for a party', + ], + hasnt: ['Support from us', 'Updates unless you deploy them'], + }, +]; +--- + + + + + + + Pricing · BottleCount + + + + + +
+
+

Three ways to run it

+

+ The planning side is free and always will be — it never needed a + server. What you pay for is the part that does: a link your guests can + open, a party two people can run, and your data somewhere other than + one browser. If you would rather run that server yourself, the code is + the same and it costs nothing. +

+
+ +
+ { + plans.map((plan) => ( +
+ {plan.featured &&
Most complete
} +

{plan.name}

+
{plan.price}
+
{plan.priceNote}
+

{plan.pitch}

+ + {plan.cta.label} + +
    + {plan.has.map((item) => ( +
  • {item}
  • + ))} + {plan.hasnt.map((item) => ( +
  • {item}
  • + ))} +
+
+ )) + } +
+ +
+

Questions people actually ask

+ +

Is the free tier a trial?

+

+ No. It has no expiry, no account and no card. If planning a party in + one browser is all you need, that is the finished product and you are + done. +

+ +

What does "one payment" mean?

+

+ You pay once and the features stay on. There is no renewal to forget + and nothing to cancel. +

+ +

What happens to my existing parties if I sign in?

+

+ Nothing, for now. Parties still live in your browser on every plan; + moving them to your account is the next thing being built, and it will + import what you already have rather than start you over. +

+ +

Why is self-hosting free when hosting costs money?

+

+ Because hosting is the thing being sold, not the software. Running the + Worker, the database and the door-scanner sync is what the payment + covers. Do that yourself and there is nothing left to charge for. +

+
+
+
+ + + + diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 3a3da98..1eefa0b 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -1,194 +1,205 @@ ---- -import '../styles/global.css'; -import NavBar from '../components/NavBar.astro'; -import Footer from '../components/Footer.astro'; -const base = import.meta.env.BASE_URL; ---- - - - - - - - Privacy Policy · BottleCount - - - - -
- -
-
- - - - \ No newline at end of file +--- +import '../styles/global.css'; +import NavBar from '../components/NavBar.astro'; +import Footer from '../components/Footer.astro'; +const base = import.meta.env.BASE_URL; +--- + + + + + + + Privacy Policy · BottleCount + + + + +
+ +
+
+ + + + diff --git a/src/pages/terms.astro b/src/pages/terms.astro index 80a48bc..56082a5 100644 --- a/src/pages/terms.astro +++ b/src/pages/terms.astro @@ -1,145 +1,198 @@ ---- -import '../styles/global.css'; -import NavBar from '../components/NavBar.astro'; -import Footer from '../components/Footer.astro'; -const base = import.meta.env.BASE_URL; ---- - - - - - - - Terms of Service · BottleCount - - - - -
- -
-
- - - - \ No newline at end of file +--- +import '../styles/global.css'; +import NavBar from '../components/NavBar.astro'; +import Footer from '../components/Footer.astro'; +const base = import.meta.env.BASE_URL; +--- + + + + + + + Terms of Service · BottleCount + + + + +
+ +
+
+ + + + diff --git a/tsconfig.json b/tsconfig.json index d1ef7de..f38641e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,10 @@ "jsx": "preserve", "jsxImportSource": "vue", "verbatimModuleSyntax": true - } + }, + // `backend/` is a separate package with its own tsconfig and its own globals + // (D1Database and friends come from @cloudflare/workers-types, which the + // frontend must not load). `shared/` is deliberately left in: both sides + // compile it, which is what keeps the tier model honest. + "exclude": ["dist", "backend", ".astro", ".wrangler"] } diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..47932bc --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,27 @@ +# Cloudflare Pages config for the frontend. +# +# The service binding is what makes /api/* and /auth/* same-origin — see +# functions/_backend.ts. Omit it (or deploy without it) and the app still runs: +# the proxy answers 501, the frontend reads that as "no backend" and stays on +# the free, browser-only tier. +name = "bottlecount" +pages_build_output_dir = "dist" +compatibility_date = "2026-03-17" + +[[services]] +binding = "BACKEND" +service = "bottlecount-backend" + +[env.preview.vars] +PUBLIC_BACKEND = "true" + +[[env.preview.services]] +binding = "BACKEND" +service = "bottlecount-backend-preview" + +[env.production.vars] +PUBLIC_BACKEND = "true" + +[[env.production.services]] +binding = "BACKEND" +service = "bottlecount-backend" From 3f4d15da92b1f2bcde47f90efcf853bcc4d346df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 12:09:52 +0000 Subject: [PATCH 2/6] feat: invite links, and a funnel that counts something real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funnel shipped as a UI over a list the host typed in themselves, so its columns were fiction: "Reached" counted people the host had entered, and "Maybe" meant "not heard back", which a local array cannot know. The invite link next to it copied a URL nothing served. This makes both real. Backend: - migration 0002: published parties and invites. The published party is the invitation, not the party — name, date, venue, cover, forwarding, capacity. Menu, shopping list and costs are never uploaded - POST /invite/:slug/open writes a row on open, before any answer. That is what makes "reached" a number rather than a guess - POST /invite/:slug/answer takes confirmed or declined. Answering again overwrites, so changing your mind is not a second guest - Depth comes from the referrer's token: the host's link is 0, each forward adds one. A forward token is only issued once a guest confirms, or someone who never replied could seed a referral tree - Capacity is checked inside the UPDATE, so two guests racing for the last place cannot both take it. Declining is never refused — a full party is still one you can say no to - PATCH /api/parties/:id/invites/:id lets the host override an answer. Without it the host's Accept button would be overwritten by the next poll - /invite/* is mounted outside /api/* (guests have no account) behind an IP-keyed rate limit — the only routes that write without an account Frontend: - InviteScreen: the RSVP page. No account, no IndexedDB; it knows only what the link and two endpoints tell it. functions/i/[[slug]].ts rewrites the /i/* space onto it, since slugs are minted long after the build - mergeFunnel folds the server's invites into party.invites, matching on remoteId and leaving hand-typed guests alone — they work on every tier and must survive a refresh that has never heard of them - the share sheet publishes on open, so a renamed party reaches guests; the slug survives, so links already sent keep working - statuses renamed accepted/pending -> confirmed/opened, with a backfill on load: "pending" meant "host is waiting", "opened" means "they looked" Tests: 65 backend (depth chains, capacity, owner isolation, returning browsers) and 12 frontend covering the merge, which is the piece that could silently delete a host's guest list. The flow was also driven by hand against a local D1 — see the ADR for what the fakes still cannot cover. Privacy policy, docs and pricing updated: D1 now holds guest RSVPs, and saying otherwise would have been wrong in a way that matters. See docs/adr/0002-invite-links-and-the-funnel.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9 --- .github/workflows/check.yml | 5 +- README.md | 5 +- backend/README.md | 40 +- .../migrations/0002_parties_and_invites.sql | 66 +++ backend/src/app.ts | 12 + backend/src/appEnv.ts | 6 + backend/src/lib/tokens.ts | 40 ++ backend/src/repositories/d1/index.ts | 4 + .../src/repositories/d1/inviteRepositoryD1.ts | 244 ++++++++ .../src/repositories/d1/partyRepositoryD1.ts | 140 +++++ backend/src/repositories/inviteRepository.ts | 74 +++ backend/src/repositories/partyRepository.ts | 42 ++ backend/src/repositories/repositories.ts | 4 + backend/src/routes/invites.ts | 179 ++++++ backend/src/routes/parties.ts | 208 +++++++ backend/src/tests/routes/funnel.spec.ts | 283 ++++++++++ backend/src/tests/routes/invites.spec.ts | 422 ++++++++++++++ backend/src/tests/support/fakeInvites.ts | 236 ++++++++ backend/src/tests/support/fakeRepositories.ts | 5 +- backend/wrangler.jsonc | 72 +++ docs/adr/0002-invite-links-and-the-funnel.md | 110 ++++ functions/i/[[slug]].ts | 34 ++ functions/invite/[[catchall]].ts | 9 + package-lock.json | 283 +++++++++- package.json | 6 +- shared/invites.ts | 137 +++++ src/components/HomeScreen.vue | 2 +- src/components/InviteScreen.vue | 534 ++++++++++++++++++ src/components/modals/DoorScannerModal.vue | 4 +- src/components/modals/ShareModal.vue | 53 +- src/components/tabs/GuestsTab.vue | 169 ++++-- src/lib/invites.spec.ts | 164 ++++++ src/lib/invites.ts | 184 ++++++ src/lib/store.ts | 191 ++++++- src/lib/types.ts | 45 +- src/pages/docs.astro | 26 +- src/pages/i/index.astro | 38 ++ src/pages/pricing.astro | 15 +- src/pages/privacy.astro | 35 +- vitest.config.ts | 19 + 40 files changed, 4027 insertions(+), 118 deletions(-) create mode 100644 backend/migrations/0002_parties_and_invites.sql create mode 100644 backend/src/lib/tokens.ts create mode 100644 backend/src/repositories/d1/inviteRepositoryD1.ts create mode 100644 backend/src/repositories/d1/partyRepositoryD1.ts create mode 100644 backend/src/repositories/inviteRepository.ts create mode 100644 backend/src/repositories/partyRepository.ts create mode 100644 backend/src/routes/invites.ts create mode 100644 backend/src/routes/parties.ts create mode 100644 backend/src/tests/routes/funnel.spec.ts create mode 100644 backend/src/tests/routes/invites.spec.ts create mode 100644 backend/src/tests/support/fakeInvites.ts create mode 100644 docs/adr/0002-invite-links-and-the-funnel.md create mode 100644 functions/i/[[slug]].ts create mode 100644 functions/invite/[[catchall]].ts create mode 100644 shared/invites.ts create mode 100644 src/components/InviteScreen.vue create mode 100644 src/lib/invites.spec.ts create mode 100644 src/lib/invites.ts create mode 100644 src/pages/i/index.astro create mode 100644 vitest.config.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 214ac0a..c4beb15 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,8 +31,9 @@ jobs: - name: Typecheck run: npm run typecheck - - name: Backend tests - run: npm run backend:test + # 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. diff --git a/README.md b/README.md index eada303..d8d2bdb 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,9 @@ redeems it in the app, which flips their tier to `pro`. 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; party data is still local for every tier today, and moving it is the next piece of work ([ADR 0001](docs/adr/0001-cloudflare-tiers.md)). +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. Turning on an **invite link** additionally publishes what an invitation card shows — the party's name, date, venue and cover — plus a row per guest who opens it, with the name they give and their answer. Your menu, shopping list, costs and budget are never uploaded on any tier; moving the rest of the party to your account is still to come ([ADR 0001](docs/adr/0001-cloudflare-tiers.md), [ADR 0002](docs/adr/0002-invite-links-and-the-funnel.md)). Because browser storage is still local storage, export/import backup tools are an important part of the workflow for portability and recovery. @@ -304,6 +306,7 @@ Because browser storage is still local storage, export/import backup tools are a ## 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) The backend has [its own README](backend/README.md) covering routes, local setup, deployment and what the tests do and do not cover. diff --git a/backend/README.md b/backend/README.md index 44db704..8ebe9b3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,19 +8,33 @@ 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` | +| 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` | +| `POST /api/parties/publish` | session + `pro` | Publish or refresh a party's invite card | +| `DELETE /api/parties/:localId/publish` | session + `pro` | Turn the link off; deletes its invites | +| `GET /api/parties/:localId/invites` | session + `pro` | The host's RSVP funnel | +| `PATCH /api/parties/:localId/invites/:id` | session + `pro` | Host overriding a guest's answer | +| `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. +`/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 @@ -97,8 +111,14 @@ 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 and the redemption rules. They cannot catch -a mistake in a SQL statement. +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, whereas the fake does the +check and the write separately. The fakes reproduce the rule, not the atomicity. 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 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/src/app.ts b/backend/src/app.ts index dba31b6..5a822e5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,7 +6,9 @@ import { repositoriesFor } from './composition'; import type { Repositories } from './repositories/repositories'; import auth from './routes/auth'; 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 = { @@ -18,6 +20,10 @@ export type Bindings = { 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 }>; @@ -84,6 +90,12 @@ export function createApp(overrides: AppOverrides = {}): App { // 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); + + // 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 index 98aae15..d1af4a4 100644 --- a/backend/src/appEnv.ts +++ b/backend/src/appEnv.ts @@ -3,4 +3,10 @@ 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/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 index fe46548..f33b147 100644 --- a/backend/src/repositories/d1/index.ts +++ b/backend/src/repositories/d1/index.ts @@ -1,10 +1,14 @@ import type { Repositories } from '../repositories'; +import { InviteRepositoryD1 } from './inviteRepositoryD1'; import { LicenceRepositoryD1 } from './licenceRepositoryD1'; +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), }; } diff --git a/backend/src/repositories/d1/inviteRepositoryD1.ts b/backend/src/repositories/d1/inviteRepositoryD1.ts new file mode 100644 index 0000000..de9bf68 --- /dev/null +++ b/backend/src/repositories/d1/inviteRepositoryD1.ts @@ -0,0 +1,244 @@ +import type { InviteAnswer, InviteStatus } from '../../../../shared/invites'; +import { INVITE_STATUSES } from '../../../../shared/invites'; +import { newToken } from '../../lib/tokens'; +import { + INVITE_ERRORS, + type Invite, + type InviteRepository, + 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; + 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, + 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; + } + } + + const row = await this.db + .prepare( + `INSERT INTO invites ( + id, party_id, name, status, depth, referrer_id, forward_token, opened_at + ) VALUES (?, ?, NULL, 'opened', ?, ?, ?, ?) RETURNING *`, + ) + .bind( + crypto.randomUUID(), + partyId, + depth, + referrerId, + newToken(), + new Date().toISOString(), + ) + .first(); + + return row ? ok(toInvite(row)) : err(INVITE_ERRORS.NOT_FOUND); + } + + 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 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/partyRepositoryD1.ts b/backend/src/repositories/d1/partyRepositoryD1.ts new file mode 100644 index 0000000..de30813 --- /dev/null +++ b/backend/src/repositories/d1/partyRepositoryD1.ts @@ -0,0 +1,140 @@ +import type { PublishPartyRequest } from '../../../../shared/invites'; +import { newSlug, newToken } from '../../lib/tokens'; +import { + PARTY_ERRORS, + type PartyRepository, + 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; +} + +function toParty(row: PartyRow): PublishedParty { + 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, + }; +} + +export class PartyRepositoryD1 implements PartyRepository { + constructor(private readonly db: D1Database) {} + + async publish( + ownerId: string, + snapshot: PublishPartyRequest, + ): Promise> { + const now = new Date().toISOString(); + + // ON CONFLICT rather than a read-then-write: republishing is what happens + // every time the host opens the share sheet, so it has to be one round trip + // and it has to be 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. + 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, published_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, + updated_at = excluded.updated_at + RETURNING *`, + ) + .bind( + crypto.randomUUID(), + ownerId, + snapshot.localId, + newSlug(snapshot.name), + snapshot.name, + snapshot.date, + snapshot.cover, + snapshot.venue.place, + snapshot.venue.city, + snapshot.venue.time, + snapshot.allowForward ? 1 : 0, + snapshot.maxCapacity, + newToken(), + now, + now, + ) + .first(); + + return row ? ok(toParty(row)) : err(PARTY_ERRORS.NOT_FOUND); + } + + 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 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/inviteRepository.ts b/backend/src/repositories/inviteRepository.ts new file mode 100644 index 0000000..34717cf --- /dev/null +++ b/backend/src/repositories/inviteRepository.ts @@ -0,0 +1,74 @@ +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; + checkedIn: boolean; + checkedInAt: string | null; + openedAt: string; + answeredAt: string | null; +} + +/** 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', +} 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>; +} diff --git a/backend/src/repositories/partyRepository.ts b/backend/src/repositories/partyRepository.ts new file mode 100644 index 0000000..45d536a --- /dev/null +++ b/backend/src/repositories/partyRepository.ts @@ -0,0 +1,42 @@ +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; +} + +export const PARTY_ERRORS = { + NOT_FOUND: 'party_not_found', + NOT_OWNER: 'party_not_owner', +} 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>; + findByOwnerAndLocalId( + ownerId: string, + localId: number, + ): 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 index f19046d..8636a4e 100644 --- a/backend/src/repositories/repositories.ts +++ b/backend/src/repositories/repositories.ts @@ -1,8 +1,12 @@ +import type { InviteRepository } from './inviteRepository'; import type { LicenceRepository } from './licenceRepository'; +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; } diff --git a/backend/src/routes/invites.ts b/backend/src/routes/invites.ts new file mode 100644 index 0000000..a5cd4ca --- /dev/null +++ b/backend/src/routes/invites.ts @@ -0,0 +1,179 @@ +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'), + ); + // An unpublished or never-published party is a 404 either way; there is + // nothing useful to tell a link holder apart from "this is not a party". + if (!found.ok) 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) 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/parties.ts b/backend/src/routes/parties.ts new file mode 100644 index 0000000..39e3f55 --- /dev/null +++ b/backend/src/routes/parties.ts @@ -0,0 +1,208 @@ +import { Hono } from 'hono'; +import type { JwtVariables } from 'hono/jwt'; +import type { + HostInviteDTO, + PublishedPartyDTO, + PublishPartyRequest, +} 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 { PARTY_ERRORS } from '../repositories/partyRepository'; +import type { PublishedParty } from '../repositories/partyRepository'; + +type Bindings = { + SELF_HOSTED?: string; +}; + +const parties = new Hono<{ + Bindings: Bindings; + Variables: AppVariables & JwtVariables; +}>(); + +function toPublishedDTO(party: PublishedParty): PublishedPartyDTO { + return { + id: party.id, + slug: party.slug, + rootToken: party.rootToken, + publishedAt: party.publishedAt, + allowForward: party.allowForward, + }; +} + +/** + * Publishing is the paid feature, so the check is here and not only in the UI. + * + * It reads the tier 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. + */ +parties.use('*', async (c, next) => { + const sub = c.get('jwtPayload')?.sub; + if (typeof sub !== 'string') return c.json({ error: 'unauthenticated' }, 401); + + const found = await c.var.repositories.users.findById(sub); + if (!found.ok) return c.json({ error: 'unauthenticated' }, 401); + + const tier = resolveTier({ + storedTier: found.value.tier, + selfHosted: c.env.SELF_HOSTED === 'true', + }); + if (!featuresFor(tier).inviteLink) { + return c.json({ error: 'upgrade_required', feature: 'inviteLink' }, 403); + } + + c.set('userId', sub); + return next(); +}); + +function parseSnapshot(body: unknown): PublishPartyRequest | null { + if (typeof body !== 'object' || body === null) return null; + const b = body as Partial; + if (typeof b.localId !== 'number' || !Number.isInteger(b.localId)) + return null; + if (typeof b.name !== 'string' || b.name.trim() === '') return null; + if (typeof b.date !== 'string') return null; + + const venue = b.venue ?? { place: '', city: '', time: '' }; + return { + localId: b.localId, + // Bounded because these are rendered on a page anyone with the link can + // open; a host is not a threat, but a stolen session is. + name: b.name.trim().slice(0, 80), + date: b.date.slice(0, 10), + cover: Number.isInteger(b.cover) ? Math.max(0, Math.min(5, b.cover!)) : 0, + venue: { + place: String(venue.place ?? '').slice(0, 120), + city: String(venue.city ?? '').slice(0, 80), + time: String(venue.time ?? '').slice(0, 10), + }, + allowForward: b.allowForward !== false, + maxCapacity: + typeof b.maxCapacity === 'number' && Number.isFinite(b.maxCapacity) + ? Math.max(1, Math.round(b.maxCapacity)) + : null, + }; +} + +/** + * `POST /api/parties/publish` — make a party openable by link. + * + * Idempotent on (owner, localId): the host's share sheet calls it every time it + * opens, which is what keeps the guest-facing card in step with a renamed party + * or a moved venue. The slug survives, so links already sent keep working. + */ +parties.post('/publish', async (c) => { + const userId = c.get('userId') as string; + const snapshot = parseSnapshot(await c.req.json().catch(() => null)); + if (!snapshot) return c.json({ error: 'invalid_party' }, 400); + + const published = await c.var.repositories.parties.publish(userId, snapshot); + if (!published.ok) return c.json({ error: published.error }, 500); + + return c.json(toPublishedDTO(published.value)); +}); + +/** `DELETE /api/parties/:localId/publish` — turn the link off for good. */ +parties.delete('/:localId/publish', async (c) => { + const userId = c.get('userId') as string; + const localId = Number(c.req.param('localId')); + if (!Number.isInteger(localId)) + return c.json({ error: 'invalid_party' }, 400); + + const found = await c.var.repositories.parties.findByOwnerAndLocalId( + userId, + localId, + ); + // Already gone is the state the caller wanted, so it is not an error. + if (!found.ok) return c.json({ ok: true }); + + const removed = await c.var.repositories.parties.unpublish( + userId, + found.value.id, + ); + if (!removed.ok) return c.json({ error: removed.error }, 500); + + return c.json({ ok: true }); +}); + +/** + * `GET /api/parties/:localId/invites` — the funnel. + * + * Names are in here, which is why it is owner-scoped: the lookup is by + * (owner, localId), so there is no id a caller could substitute to read someone + * else's guest list. + */ +parties.get('/:localId/invites', async (c) => { + const userId = c.get('userId') as string; + const localId = Number(c.req.param('localId')); + if (!Number.isInteger(localId)) + return c.json({ error: 'invalid_party' }, 400); + + const found = await c.var.repositories.parties.findByOwnerAndLocalId( + userId, + localId, + ); + if (!found.ok) { + const status = found.error === PARTY_ERRORS.NOT_FOUND ? 404 : 500; + return c.json({ error: found.error }, status); + } + + const listed = await c.var.repositories.invites.listForParty(found.value.id); + if (!listed.ok) return c.json({ error: listed.error }, 500); + + const invites: HostInviteDTO[] = listed.value.map((invite) => ({ + id: invite.id, + name: invite.name, + status: invite.status, + depth: invite.depth, + referrer: invite.referrerName, + forwardToken: invite.forwardToken, + openedAt: invite.openedAt, + answeredAt: invite.answeredAt, + })); + + return c.json({ party: toPublishedDTO(found.value), invites }); +}); + +/** + * `PATCH /api/parties/:localId/invites/:inviteId` — the host overriding an answer. + * + * Hosts do talk to their guests off-platform ("she told me at work she's + * coming"), and without this the funnel would poll their change straight back + * out again a few seconds later. It is deliberately a different route from the + * guest's own answer: this one is owner-scoped, ignores capacity, and can send + * a row back to `opened`, none of which a guest may do. + */ +parties.patch('/:localId/invites/:inviteId', async (c) => { + const userId = c.get('userId') as string; + const localId = Number(c.req.param('localId')); + if (!Number.isInteger(localId)) + return c.json({ error: 'invalid_party' }, 400); + + 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 found = await c.var.repositories.parties.findByOwnerAndLocalId( + userId, + localId, + ); + if (!found.ok) return c.json({ error: found.error }, 404); + + const updated = await c.var.repositories.invites.setStatus({ + inviteId: c.req.param('inviteId'), + partyId: found.value.id, + status: status as InviteStatus, + }); + if (!updated.ok) return c.json({ error: updated.error }, 404); + + return c.json({ ok: true, status: updated.value.status }); +}); + +export default parties; diff --git a/backend/src/tests/routes/funnel.spec.ts b/backend/src/tests/routes/funnel.spec.ts new file mode 100644 index 0000000..c95b50f --- /dev/null +++ b/backend/src/tests/routes/funnel.spec.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { + HostInviteDTO, + InviteOpenDTO, + PublishedPartyDTO, +} from '../../../../shared/invites'; +import type { Repositories } from '../../repositories/repositories'; +import { fakeInvites, fakeParties, resetFakeIds } from '../support/fakeInvites'; +import { aUser, fakeLicences, fakeUsers } from '../support/fakeRepositories'; +import { request, sessionCookie } from '../support/harness'; + +let repositories: Repositories; +let party: PublishedPartyDTO; + +beforeEach(async () => { + resetFakeIds(); + repositories = { + users: fakeUsers([aUser({ tier: 'pro' })]), + licences: fakeLicences(), + parties: fakeParties(), + invites: fakeInvites(), + }; + const res = await request('/api/parties/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { + localId: 7, + name: 'Rooftop', + date: '2026-10-02', + cover: 0, + venue: { place: '', city: '', time: '21:00' }, + allowForward: true, + maxCapacity: null, + }, + }); + party = (await res.json()) as PublishedPartyDTO; +}); + +/** 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/7/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 the host has not published', async () => { + const res = await request('/api/parties/99/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/7/invites', { + repositories, + cookie: await sessionCookie('user-2'), + }); + + expect(res.status).toBe(404); + }); + + it('refuses a free host', async () => { + repositories.users = fakeUsers([aUser({ tier: 'free' })]); + const res = await request('/api/parties/7/invites', { + repositories, + cookie: await sessionCookie('user-1'), + }); + + expect(res.status).toBe(403); + }); +}); + +describe('the host overriding an answer', () => { + async function override( + inviteId: string, + status: string, + cookie = 'user-1', + ): Promise { + return request(`/api/parties/7/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/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { + localId: 7, + name: 'Rooftop', + date: '2026-10-02', + allowForward: true, + maxCapacity: 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..bc186dd --- /dev/null +++ b/backend/src/tests/routes/invites.spec.ts @@ -0,0 +1,422 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { + InviteOpenDTO, + PublishedPartyDTO, +} from '../../../../shared/invites'; +import type { Repositories } from '../../repositories/repositories'; +import { fakeInvites, fakeParties, resetFakeIds } from '../support/fakeInvites'; +import { aUser, fakeLicences, fakeUsers } from '../support/fakeRepositories'; +import { request, sessionCookie } from '../support/harness'; + +/** A pro host with one published party, and the repositories behind them. */ +async function aPublishedParty( + overrides: { maxCapacity?: number | null; allowForward?: boolean } = {}, +): Promise<{ repositories: Repositories; party: PublishedPartyDTO }> { + const repositories: Repositories = { + users: fakeUsers([aUser({ tier: 'pro' })]), + licences: fakeLicences(), + parties: fakeParties(), + invites: fakeInvites(), + }; + + const res = await request('/api/parties/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { + localId: 7, + name: 'Rooftop', + date: '2026-10-02', + cover: 1, + venue: { place: 'The Roof', city: 'Milan', time: '21:00' }, + allowForward: overrides.allowForward ?? true, + maxCapacity: overrides.maxCapacity ?? null, + }, + }); + expect(res.status).toBe(200); + return { repositories, party: (await res.json()) as PublishedPartyDTO }; +} + +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: Repositories = { + users: fakeUsers([aUser({ tier: 'free' })]), + licences: fakeLicences(), + parties: fakeParties(), + invites: fakeInvites(), + }; + + const res = await request('/api/parties/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { localId: 1, name: 'Party', date: '2026-10-02' }, + }); + + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ feature: 'inviteLink' }); + }); + + it('allows a free host on a self-hosted deployment', async () => { + const repositories: Repositories = { + users: fakeUsers([aUser({ tier: 'free' })]), + licences: fakeLicences(), + parties: fakeParties(), + invites: fakeInvites(), + }; + + const res = await request('/api/parties/publish', { + repositories, + env: { SELF_HOSTED: 'true' }, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { localId: 1, name: 'Party', date: '2026-10-02' }, + }); + + expect(res.status).toBe(200); + }); + + it('refuses an anonymous caller', async () => { + const res = await request('/api/parties/publish', { + repositories: { + users: fakeUsers(), + licences: fakeLicences(), + parties: fakeParties(), + invites: fakeInvites(), + }, + method: 'POST', + body: { localId: 1, name: 'Party', date: '2026-10-02' }, + }); + + 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/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { + localId: 7, + name: 'Rooftop (moved)', + date: '2026-10-09', + cover: 1, + venue: { place: 'The Roof', city: 'Milan', time: '22:00' }, + allowForward: true, + maxCapacity: null, + }, + }); + + const republished = (await res.json()) as PublishedPartyDTO; + expect(republished.slug).toBe(party.slug); + expect(republished.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 snapshot with no name', async () => { + const { repositories } = await aPublishedParty(); + const res = await request('/api/parties/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { localId: 8, name: ' ', date: '2026-10-02' }, + }); + + 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. + await request('/api/parties/publish', { + repositories, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { localId: 9, name: 'Other', date: '2026-11-01' }, + }); + const other = await repositories.parties.findByOwnerAndLocalId('user-1', 9); + expect(other.ok).toBe(true); + if (!other.ok) return; + + const opened = await open(repositories, other.value.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 unpublishes', async () => { + const { repositories, party } = await aPublishedParty(); + + const removed = await request('/api/parties/7/publish', { + 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/support/fakeInvites.ts b/backend/src/tests/support/fakeInvites.ts new file mode 100644 index 0000000..e88ac1c --- /dev/null +++ b/backend/src/tests/support/fakeInvites.ts @@ -0,0 +1,236 @@ +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 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; +} { + const rows = new Map(seed.map((p) => [p.id, p])); + + return { + rows, + async publish( + ownerId: string, + snapshot: PublishPartyRequest, + ): Promise> { + const now = new Date().toISOString(); + for (const row of rows.values()) { + if (row.ownerId === ownerId && row.localId === snapshot.localId) { + // Republishing keeps the slug and root token — links already sent + // have to keep working. + const updated: PublishedParty = { + ...row, + name: snapshot.name, + date: snapshot.date, + cover: snapshot.cover, + venue: snapshot.venue, + allowForward: snapshot.allowForward, + maxCapacity: snapshot.maxCapacity, + updatedAt: now, + }; + rows.set(row.id, updated); + return ok(updated); + } + } + const party: PublishedParty = { + id: nextId('party'), + ownerId, + localId: snapshot.localId, + slug: `${snapshot.name.toLowerCase().replace(/\W+/g, '-')}-${nextId('s')}`, + name: snapshot.name, + date: snapshot.date, + cover: snapshot.cover, + venue: snapshot.venue, + allowForward: snapshot.allowForward, + maxCapacity: snapshot.maxCapacity, + rootToken: nextId('root'), + publishedAt: now, + updatedAt: now, + }; + rows.set(party.id, party); + 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 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 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'), + 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 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/fakeRepositories.ts b/backend/src/tests/support/fakeRepositories.ts index c533ca1..d198fea 100644 --- a/backend/src/tests/support/fakeRepositories.ts +++ b/backend/src/tests/support/fakeRepositories.ts @@ -1,4 +1,5 @@ import type { Tier } from '../../../../shared/tiers'; +import { fakeInvites, fakeParties } from './fakeInvites'; import type { LicenceKey, LicenceRepository, @@ -85,8 +86,10 @@ export function fakeLicences(seed: LicenceKey[] = []): LicenceRepository { export function fakeRepositories( users = fakeUsers(), licences = fakeLicences(), + parties = fakeParties(), + invites = fakeInvites(), ): Repositories { - return { users, licences }; + return { users, licences, parties, invites }; } export function aUser(overrides: Partial = {}): User { diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index 720c2e5..a03fa78 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -18,6 +18,24 @@ "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", @@ -37,6 +55,24 @@ "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", @@ -55,6 +91,24 @@ "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", @@ -78,6 +132,24 @@ "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", 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..5d84cf1 --- /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. Invite links + therefore do not work on a static-only host — which is moot, since they are a + paid feature and that host has no backend. +- **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/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/package-lock.json b/package-lock.json index 866354c..85f82c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,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" }, "engines": { @@ -1979,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": { @@ -2541,6 +2542,17 @@ "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", @@ -2550,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", @@ -2933,6 +2952,64 @@ "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", @@ -3353,6 +3430,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", @@ -3661,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", @@ -4346,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": { @@ -4845,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", @@ -6734,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", @@ -7007,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" @@ -7727,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", @@ -7824,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", @@ -7938,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", @@ -7948,9 +8089,9 @@ } }, "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" @@ -8765,6 +8906,99 @@ } } }, + "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", @@ -9167,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", diff --git a/package.json b/package.json index 5c42007..dc1b9fe 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "backend": "npm --prefix backend run", "backend:dev": "npm --prefix backend run dev", "backend:test": "npm --prefix backend run test", - "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" + "install:all": "npm install && npm --prefix backend install", + "test:frontend": "vitest run" }, "engines": { "node": ">=22.12.0" @@ -45,6 +46,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/invites.ts b/shared/invites.ts new file mode 100644 index 0000000..325addb --- /dev/null +++ b/shared/invites.ts @@ -0,0 +1,137 @@ +/** + * 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. + */ + +// ── 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; + 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; +} + +/** The snapshot a host pushes so guests have something to open. */ +export interface PublishPartyRequest { + /** The party's id in the host's browser. Republishing with it updates in place. */ + localId: number; + name: string; + date: string; + cover: number; + venue: { place: string; city: string; time: string }; + allowForward: boolean; + maxCapacity: number | null; +} + +// ── 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/src/components/HomeScreen.vue b/src/components/HomeScreen.vue index b82e51b..6adef24 100644 --- a/src/components/HomeScreen.vue +++ b/src/components/HomeScreen.vue @@ -46,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)'; diff --git a/src/components/InviteScreen.vue b/src/components/InviteScreen.vue new file mode 100644 index 0000000..df65199 --- /dev/null +++ b/src/components/InviteScreen.vue @@ -0,0 +1,534 @@ + + + + + diff --git a/src/components/modals/DoorScannerModal.vue b/src/components/modals/DoorScannerModal.vue index 9c4e289..3cb96e4 100644 --- a/src/components/modals/DoorScannerModal.vue +++ b/src/components/modals/DoorScannerModal.vue @@ -80,7 +80,7 @@ async function onScanResult(result: { data: string }): Promise { return; } - const accepted = party.invites.filter((i) => i.status === 'accepted'); + const accepted = party.invites.filter((i) => i.status === 'confirmed'); const alreadyUsed = accepted.find( (i) => @@ -160,7 +160,7 @@ onUnmounted(stopScanner); // ── Derived stats ────────────────────────────────────────────────────────── function accepted() { return (store.activeParty()?.invites ?? []).filter( - (i) => i.status === 'accepted', + (i) => i.status === 'confirmed', ); } diff --git a/src/components/modals/ShareModal.vue b/src/components/modals/ShareModal.vue index caf0b2d..b8570ae 100644 --- a/src/components/modals/ShareModal.vue +++ b/src/components/modals/ShareModal.vue @@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'; import { useStore, COVERS } from '../../lib/store'; import Modal from '../Modal.vue'; import Icon from '../Icon.vue'; +import { inviteUrl } from '../../../shared/invites'; const store = useStore(); @@ -16,28 +17,36 @@ const cover = computed(() => { return COVERS[p.cover] ?? COVERS[0]; }); -// Built from the origin the app is actually served from, so a preview -// deployment, a self-hosted domain and the hosted app each hand out a link that -// points back at themselves. The `/i/` route that resolves these is still to -// come — see docs/adr/0001-cloudflare-tiers.md. +/** + * The host's own link, or '' until the party has been published. + * + * The slug is the server's, not one derived from the party name: renaming the + * party must not change a link already sent, and only the server knows which + * slug it handed out. `openShare` publishes on open, so this fills in a moment + * after the sheet appears — `publishing` covers the gap. + */ +const publication = computed(() => party.value?.publication ?? null); + const inviteLink = computed(() => { - const p = party.value; - if (!p) return ''; - const slug = - p.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') || 'party'; + const pub = publication.value; + if (!pub) return ''; const origin = typeof window === 'undefined' ? 'bottlecount.pages.dev' : window.location.host; - // BASE_URL, not a bare `/`: a build served under a path prefix would - // otherwise hand out links that miss the prefix entirely. - const base = import.meta.env.BASE_URL as string; - return `${origin}${base}i/${slug}-${p.id}`; + return inviteUrl( + origin, + import.meta.env.BASE_URL as string, + pub.slug, + pub.rootToken, + ); }); +const publishing = computed(() => store.state.publishing); +const publishFailed = computed( + () => !publishing.value && !publication.value && store.state.publishError, +); + const venueWhere = computed(() => { const p = party.value; if (!p) return ''; @@ -79,9 +88,13 @@ const channels: Channel[] = [ ].map((ch) => ({ ...ch, onClick: () => { + // Nothing to share until the party is published — sharing a blank link is + // worse than the button doing nothing for the second it takes. + if (!inviteLink.value) return; if (ch.label === 'Copy link') { - const link = `https://${inviteLink.value}`; - navigator.clipboard?.writeText(link).catch(() => {}); + navigator.clipboard + ?.writeText(`https://${inviteLink.value}`) + .catch(() => {}); } sent.value = true; }, @@ -224,7 +237,11 @@ watch( " > - {{ inviteLink }} + Creating your link… + + Couldn't reach the server — try again in a moment. + + {{ inviteLink }}
diff --git a/src/components/tabs/GuestsTab.vue b/src/components/tabs/GuestsTab.vue index 2ec20ce..5a15a0b 100644 --- a/src/components/tabs/GuestsTab.vue +++ b/src/components/tabs/GuestsTab.vue @@ -1,8 +1,9 @@