Skip to content

feat: move to Cloudflare and add the three-tier model - #1

Merged
Fre0Grella merged 6 commits into
mainfrom
claude/cloudflare-ecosystem-migration-3szd35
Sep 21, 2026
Merged

Fre0Grella merged 6 commits into
mainfrom
claude/cloudflare-ecosystem-migration-3szd35

Conversation

@Fre0Grella

Copy link
Copy Markdown
Owner

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 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
The docs workflow was building the whole application under /BottleCount/ —
including /app, /auth/callback and /i. Every one of those needs the Worker,
and there is none behind GitHub Pages, so the result looked like the product
and then failed: sign-in 404ed, invite links 404ed, and the paid features sat
locked with no way to unlock them. A copy of the product that fails halfway
through is worse than no copy.

The application lives on Cloudflare Pages and serves both tiers from there.
Free is not a separate deployment — it is the same app with nobody signed in,
which is why /api/session answers anonymous callers instead of rejecting them.

- BUILD_TARGET=docs deletes /app, /auth and /i from the output. Done in
  astro.config.mjs, beside the list it applies, rather than in a workflow step
  a local docs build would skip. (astro:routes:resolved looked like the right
  hook but is informational — splicing its array is accepted and ignored, and
  the pages are emitted anyway.)
- src/lib/links.ts centralises the app's URL. The docs build points it at the
  Cloudflare deployment via PUBLIC_APP_URL; the app build keeps it relative.
  Every `${base}app` call site went through it — they were all correct until
  the two builds stopped being the same site, and nothing fails at build time
  when one is missed, only for whoever clicks it.
- CI builds both and fails if the documentation build contains an application.
- Comments and docs that called the backend-less case "the GitHub Pages build"
  now name what it actually is: a Worker that is down, or a self-hosted Pages
  project with no service binding. The graceful degradation still matters for
  those; it is no longer a deployment target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
The landing page, docs, pricing and the two legal pages are built for both
hosts, so each exists at two URLs and search engines have no way to tell which
one is the page. Each now carries a <link rel="canonical"> pointing at the
Cloudflare copy: that domain is the product, and it serves these pages as well
as the app itself.

The application build emits the same tag rather than none, so each page is its
own canonical — that is what stops a URL picking up a query string or a
trailing slash and being counted as a second page.

Collapsed PUBLIC_APP_URL into PUBLIC_APP_ORIGIN while doing it. A canonical
origin and an app URL would have been two settings obliged to name the same
host, which is the kind of pair that drifts the first time only one is
updated; src/lib/links.ts derives both from one.

The docs build settings moved from the workflow into `npm run build:docs`, so
a local build produces exactly what CI publishes. That meant dropping
withastro/action, which runs `npm run build` and cannot be pointed elsewhere.

CI now fails if any page in the documentation build lacks a canonical. A
missing one is silent otherwise — nothing in the build or the browser
complains, it just quietly splits a page's ranking across two domains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
The last locked feature, and the only one that could not be built on what
came before. ADR 0002 put the invitation card on the server: enough for a
guest to RSVP, useless to a second organiser, who needs the menu, the budget
and the shopping list. So this is where the party itself moves to D1.

How edits travel:
- The party is one JSON document, not a table per menu level. That is already
  what it is everywhere else, and normalising it would buy queries nobody
  makes and cost a join per slider.
- A save sends only what changed, as an RFC 7386 merge patch. Sending the
  whole document would mean one organiser building the menu erases the guest
  count the other just set; optimistic locking would turn that into a
  conflict over a change that merges perfectly well.
- It works because the document has no arrays — menu, locks and check-offs
  are keyed records, and the guest list is deliberately not in it. And
  because SQLite's json_patch is the same RFC the client implements, applied
  inside the statement that reads and writes, so two saves landing together
  cannot both read the same version.
- baseVersion is reported on, not enforced: a client that fell behind gets
  the merged document back rather than an error.
- The card columns are now derived from the document on read, so a patch has
  nothing to keep in step and the two cannot drift.

Who may do what:
- Membership is the authorisation, keyed on the server's party id — a
  co-organiser's local numbering means nothing here. A non-member gets 404,
  not 403, so an id cannot be probed.
- An editor edits, reads the funnel and opens the guest link. They may not
  delete the party, close a link the owner opened, or add and remove people:
  an editor who could invite could undo a removal.
- A co-organiser does not pay. The tier gates creating a cloud party, not
  opening one — you cannot co-organise alone, and the party is already paid
  for. What a free co-organiser lacks is parties of their own.
- Storing a party no longer opens it to guests (invites_open). Otherwise a
  party synced so two people could plan it would accept RSVPs through a slug
  nobody was ever shown.

Frontend: a sync engine that diffs against what the server is believed to
hold (so a failed push is self-healing), debounces, replays pending edits
over anything pulled mid-edit, and polls. Co-organisers sheet, /join page,
and a header chip because a shared party failing to save silently is the
worst thing this can do.

121 tests. The concurrency the fakes cannot reach was verified by hand
against a local D1: two patches from one base version, both surviving, with
a deletion and a new menu category among them.

See docs/adr/0003-co-organisers.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
doorScannerSync was the last thing the pricing page sold and the product
didn't deliver — and featuresFor('pro').doorScannerSync already returned
true, so a paying user's UI didn't gate it. They just got a scanner that
disagreed with their co-organiser's.

Two things were wrong, and the smaller one was the more visible:

- Check-in never left the device. invites.checked_in existed since 0002 and
  nothing wrote it, so each phone kept its own tally and the second would
  admit someone the first had already scanned.
- Tickets were signed per device. The HMAC key was generated into whichever
  browser first issued one, and the ticket was identified by that browser's
  numbering for the guest. A co-organiser's phone couldn't verify anything
  the owner's phone made — it wouldn't double-admit people, it would reject
  all of them.

The key now belongs to the party and lives in D1, generated server-side on
first store and handed to every member. It is excluded from the publish
upsert deliberately: rotating it on a save would invalidate every ticket
already in a guest's phone. Verification stays client-side, because doors
are in basements and that is what the HMAC was for.

Tickets are keyed by a five-character code, same alphabet FantasyWiki uses
for league invites (no 0/1/I/L/O/U — these get read off a phone in the dark),
rejection-sampled and retried against a unique index. The QR carries the
code, so scanning and typing resolve identically and a dead camera isn't a
different code path. Manual checks need the code AND the name: five
characters is short enough to overhear. normaliseTicketCode forgives case
and spaces and nothing else — an earlier draft folded O onto Q, which can
only turn a correct rejection into the wrong guest walking in.

Check-in is now a server write with `AND checked_in = 0`, so two phones
racing a ticket cannot both win; the 409 carries the first scan's time so the
door says "already scanned at 23:14". mergeFunnel takes check-in from the
server now rather than carrying the local value — reversing ADR 0003 on
purpose, because a phone that didn't scan someone must still show them as in.
A failed request leaves the local check-in standing: the guest is through the
door either way. Undo exists, because the alternative to a reversible mistake
is a guest outside while two organisers argue.

Also closes a hole from co-organisers: a guest typed in by hand was local to
the typing browser, so the other organiser never saw them and no second phone
could check their ticket. They're real invites now, marked source='manual' so
they don't inflate "reached" — they never opened a link.

146 tests. Verified against local D1: both organisers get byte-identical
keys, Bruno's scan of Giulia's ticket is refused with Anna's timestamp, undo
frees it again.

See docs/adr/0004-shared-doors-and-ticket-codes.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjA8unpJiBtv3F1gVM5sp9
@Fre0Grella
Fre0Grella merged commit 442a2a9 into main Sep 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants