From fc38763e4b7c3cf04e091f4473a5f562d4ebd4e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:25:02 +0000 Subject: [PATCH 1/4] feat: test licence code and a local dev loop that reaches the Worker Every paid feature sits behind a redeemed licence, and there was no way to test any of them: - `astro dev` ran no Pages Functions, so every relative /api/* call 404ed, the session resolved to anonymous and the app stayed on the free tier with the Worker running beside it. /i/ and /join/ 404ed too. - The local Worker had SELF_HOSTED=true, so a signed-in local user was already pro and the upgrade path could not be exercised. - Sign in linked to /auth/google, which 500s without a Google client, so a browser could not sign in locally at all (curl's cookie is not the browser's). - Minted codes are single-use, so each test needed a fresh one. Now: - astro.config.mjs proxies /api, /invite and the three /auth routes to the Worker (BACKEND_DEV_URL, default :8787) and rewrites /i/* and /join/* onto their pages, mirroring functions/. /auth/callback is left alone for the same reason functions/auth/ has no catchall. - BC-TEST-TEST-TEST, from TEST_LICENCE_CODE on the local and preview environments, grants pro without a licence_keys row and can be redeemed by any number of accounts. It is refused whenever ENVIRONMENT is production, since the code is in a public repository. - local now runs SELF_HOSTED=false so the paywall is real; .dev.vars can set it back to "true" to see the self-hosted behaviour. - The session reports devSignIn wherever POST /auth/dev is open (local or self-hosted; one shared predicate), and the header then offers an email sign-in instead of the Google link. Self-hosters without a Google client can now sign in from the browser too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TivKBtoj8GvfK4yeS44Vnr --- README.md | 33 +++-- astro.config.mjs | 62 ++++++++++ backend/.dev.vars.example | 4 + backend/README.md | 25 +++- backend/src/app.ts | 8 +- backend/src/routes/devAuth.ts | 15 ++- backend/src/routes/licences.ts | 44 +++++-- backend/src/routes/session.ts | 16 ++- backend/src/tests/routes/licences.spec.ts | 82 ++++++++++++ backend/src/tests/routes/session.spec.ts | 28 +++++ backend/wrangler.jsonc | 13 +- shared/session.ts | 6 + src/components/AccountButton.vue | 144 +++++++++++++++++++++- src/lib/session.ts | 22 ++++ 14 files changed, 462 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 6496631..e21f85d 100644 --- a/README.md +++ b/README.md @@ -210,14 +210,27 @@ 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 -``` +Then open http://localhost:4321/app. The Astro dev server forwards `/api/*`, +`/invite/*` and the `/auth/*` routes to the Worker on `:8787`, and serves +`/i/` and `/join/` — the same job the Pages Functions do on +Cloudflare — so the browser talks to one origin, exactly as in production. +Point it elsewhere with `BACKEND_DEV_URL`. + +To try every paid feature: + +1. **Sign in** from the header. Locally it asks for an email instead of sending + you to Google — any address works, and each one is a separate account. +2. **Unlock** any locked feature and redeem the test licence + **`BC-TEST-TEST-TEST`**. It is reusable, so a second account (for + co-organisers, say) can redeem it too. +3. **Guests → Send invite** creates the invite link. Open it in a private + window to RSVP as a guest; confirm, and use _Copy my link_ to forward it and + see a friend-of-friend land in the spread view. + +The `local` Worker environment behaves like the hosted product — you sign in as +`free` and the paywall is real. Put `SELF_HOSTED="true"` in +`backend/.dev.vars` to see the self-hosted behaviour instead, where every +signed-in user is `pro`. Checks, all of which CI runs: @@ -318,6 +331,10 @@ npm --prefix backend run licence:issue -- --env production --note "ko-fi #128" 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`. +For testing, `BC-TEST-TEST-TEST` unlocks `pro` on the `local` and `preview` +Workers without a row in D1 (`TEST_LICENCE_CODE` in `backend/wrangler.jsonc`). +It is refused on production whatever the config says, since the code is public. + --- ## Data, Persistence & Privacy diff --git a/astro.config.mjs b/astro.config.mjs index 1030dbc..7a732c9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -64,9 +64,71 @@ function docsOnly() { }; } +/** + * Where `astro dev` finds the Worker — `wrangler dev` in the backend's own + * terminal. Override with `BACKEND_DEV_URL` if it runs somewhere else. + */ +const backendDevUrl = process.env.BACKEND_DEV_URL ?? 'http://localhost:8787'; + +/** + * What `functions/` does on Cloudflare, done by the dev server instead. + * + * The frontend only ever calls its own origin (`/api/session`, never + * `localhost:8787/api/session`), because in production the Pages Functions + * forward those paths to the Worker over a service binding. `astro dev` runs no + * Pages Functions, so without this every call 404s, the session resolves to + * anonymous, and every paid feature sits locked with no way to unlock it. + * + * `/auth/*` is listed path by path for the same reason `functions/auth/` is + * three files rather than a catchall: `/auth/callback` is a page, not a route. + */ +const DEV_PROXY_PATHS = [ + '/api', + '/invite', + '/auth/dev', + '/auth/google', + '/auth/logout', +]; + +/** + * `/i/` and `/join/` are minted at runtime, so the build has one + * page for each and `functions/i/` and `functions/join/` rewrite the whole + * space onto it. This is that rewrite for the dev server; the browser's URL is + * untouched and the page still reads the slug off it. + */ +function devRewrites() { + const REWRITES = [ + [/^\/i\/[^/?#]+/, '/i/'], + [/^\/join\/[^/?#]+/, '/join/'], + ]; + return { + name: 'bottlecount:dev-rewrites', + apply: 'serve', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + for (const [pattern, target] of REWRITES) { + if (req.url && pattern.test(req.url)) { + req.url = req.url.replace(pattern, target); + break; + } + } + next(); + }); + }, + }; +} + export default defineConfig({ site, base, integrations: [vue(), ...(isDocs ? [docsOnly()] : [])], output: 'static', + vite: { + plugins: [devRewrites()], + server: { + proxy: Object.fromEntries( + DEV_PROXY_PATHS.map((path) => [path, { target: backendDevUrl }]), + ), + }, + }, }); diff --git a/backend/.dev.vars.example b/backend/.dev.vars.example index 97a724d..488c6b1 100644 --- a/backend/.dev.vars.example +++ b/backend/.dev.vars.example @@ -9,3 +9,7 @@ JWT_SECRET="dev-only-change-me" # POST /auth/dev instead. GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" + +# Optional: "true" makes local behave like a self-hosted deployment, where every +# signed-in user is pro. Leave it out to test the free tier and the upgrade. +# SELF_HOSTED="true" diff --git a/backend/README.md b/backend/README.md index ebaa03c..e0df128 100644 --- a/backend/README.md +++ b/backend/README.md @@ -65,20 +65,32 @@ 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: +The `local` environment behaves like the hosted product: `SELF_HOSTED` is +`"false"`, so a new account is `free` and the paywall is real. `/auth/dev` still +works, because `ENVIRONMENT` is `local`: ```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 -X POST http://localhost:8787/api/licences/redeem \ + -H 'content-type: application/json' \ + -d '{"code":"BC-TEST-TEST-TEST"}' -b 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. +`BC-TEST-TEST-TEST` is the test licence (`TEST_LICENCE_CODE`, set on `local` +and `preview`). Unlike a minted code it is reusable and never written to +`licence_keys`, and it is refused whenever `ENVIRONMENT` is `production`. Put +`SELF_HOSTED="true"` in `.dev.vars` to see the self-hosted behaviour instead. + +Run the Astro dev server (`npm run dev` at the repo root) beside it and open +http://localhost:4321/app. It proxies `/api/*`, `/invite/*` and the `/auth/*` +routes here (see `astro.config.mjs`), so the browser sees one origin, as it +does behind the Pages Functions in production — and signing in from the header +offers the same email sign-in as `/auth/dev`. ## Deploying @@ -112,6 +124,9 @@ 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. +To test the upgrade without minting anything, redeem `BC-TEST-TEST-TEST` on +`local` or `preview` (see above). + ## Structure ``` diff --git a/backend/src/app.ts b/backend/src/app.ts index 6087839..9621086 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -21,6 +21,8 @@ export type Bindings = { ENVIRONMENT: string; /** "true" on a self-hosted deployment — see shared/tiers.ts. */ SELF_HOSTED?: string; + /** A reusable licence for testing. Ignored on production — see routes/licences.ts. */ + TEST_LICENCE_CODE?: string; /** Guards the unauthenticated invite endpoints. Absent locally. */ INVITE_RATE_LIMITER?: { limit(o: { key: string }): Promise<{ success: boolean }>; @@ -47,9 +49,9 @@ 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. + // one origin, and in development the Astro dev server's proxy does the same + // (astro.config.mjs), so CORS normally never comes up. This covers a page + // that calls the Worker's own port directly. app.use( '*', cors({ diff --git a/backend/src/routes/devAuth.ts b/backend/src/routes/devAuth.ts index 90d20ec..18d8fc4 100644 --- a/backend/src/routes/devAuth.ts +++ b/backend/src/routes/devAuth.ts @@ -11,6 +11,18 @@ type Bindings = { const devAuth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>(); +/** + * Whether this Worker signs people in without Google. Also read by + * `/api/session`, so the app offers the email sign-in exactly where this route + * would accept it. + */ +export function devSignInEnabled(env: { + ENVIRONMENT: string; + SELF_HOSTED?: string; +}): boolean { + return env.SELF_HOSTED === 'true' || env.ENVIRONMENT === 'local'; +} + /** * Sign in without Google. * @@ -26,8 +38,7 @@ const devAuth = new Hono<{ Bindings: Bindings; Variables: AppVariables }>(); * 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(); + if (!devSignInEnabled(c.env)) return c.notFound(); const body = await c.req .json<{ email?: string; name?: string }>() diff --git a/backend/src/routes/licences.ts b/backend/src/routes/licences.ts index 730f9bc..9025dfd 100644 --- a/backend/src/routes/licences.ts +++ b/backend/src/routes/licences.ts @@ -1,14 +1,33 @@ import { Hono } from 'hono'; import type { JwtVariables } from 'hono/jwt'; -import { featuresFor, resolveTier } from '../../../shared/tiers'; +import { featuresFor, resolveTier, type Tier } from '../../../shared/tiers'; import type { AppVariables } from '../appEnv'; import { LICENCE_ERRORS } from '../repositories/licenceRepository'; import { userErrorStatus } from './helpers'; type Bindings = { SELF_HOSTED?: string; + ENVIRONMENT: string; + TEST_LICENCE_CODE?: string; }; +/** + * Whether `code` is this deployment's test licence. + * + * Every paid feature sits behind a redeemed code, and a minted one is spent on + * first use — so testing the upgrade, or a second account, would mean minting + * again each time. The test code is reusable, never written to `licence_keys`, + * and comes from a var set only on the `local` and `preview` environments. + * + * Refused on production whatever the var says: the code is in a public + * repository, so a copy-pasted env block must not become a free licence. + */ +function isTestLicence(env: Bindings, code: string): boolean { + if (env.ENVIRONMENT === 'production') return false; + const expected = env.TEST_LICENCE_CODE?.trim().toUpperCase(); + return !!expected && code.toUpperCase() === expected; +} + const licences = new Hono<{ Bindings: Bindings; Variables: AppVariables & JwtVariables; @@ -32,18 +51,21 @@ licences.post('/redeem', async (c) => { 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); + let grantedTier: Tier; + if (isTestLicence(c.env, code)) { + grantedTier = 'pro'; + } else { + 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); + } + grantedTier = redeemed.value.tier; } - const updated = await c.var.repositories.users.setTier( - sub, - redeemed.value.tier, - ); + const updated = await c.var.repositories.users.setTier(sub, grantedTier); if (!updated.ok) { return c.json({ error: updated.error }, userErrorStatus(updated.error)); } diff --git a/backend/src/routes/session.ts b/backend/src/routes/session.ts index 741aefb..624ab2e 100644 --- a/backend/src/routes/session.ts +++ b/backend/src/routes/session.ts @@ -4,16 +4,18 @@ import { verify } from 'hono/jwt'; import type { SessionDTO } from '../../../shared/session'; import { featuresFor, resolveTier } from '../../../shared/tiers'; import type { AppVariables } from '../appEnv'; +import { devSignInEnabled } from './devAuth'; type Bindings = { JWT_SECRET: string; + ENVIRONMENT: 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 { +function anonymous(selfHosted: boolean, devSignIn: boolean): SessionDTO { const tier = resolveTier({ storedTier: null, selfHosted }); return { authenticated: false, @@ -22,6 +24,7 @@ function anonymous(selfHosted: boolean): SessionDTO { features: featuresFor(tier), selfHosted, backendAvailable: true, + devSignIn, }; } @@ -36,23 +39,25 @@ function anonymous(selfHosted: boolean): SessionDTO { */ session.get('/', async (c) => { const selfHosted = c.env.SELF_HOSTED === 'true'; + const devSignIn = devSignInEnabled(c.env); const token = getCookie(c, 'session_token'); - if (!token) return c.json(anonymous(selfHosted)); + if (!token) return c.json(anonymous(selfHosted, devSignIn)); let sub: string; try { const payload = await verify(token, c.env.JWT_SECRET, 'HS256'); - if (typeof payload.sub !== 'string') return c.json(anonymous(selfHosted)); + if (typeof payload.sub !== 'string') + return c.json(anonymous(selfHosted, devSignIn)); sub = payload.sub; } catch { - return c.json(anonymous(selfHosted)); + return c.json(anonymous(selfHosted, devSignIn)); } // 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)); + if (!found.ok) return c.json(anonymous(selfHosted, devSignIn)); const user = found.value; const tier = resolveTier({ storedTier: user.tier, selfHosted }); @@ -69,6 +74,7 @@ session.get('/', async (c) => { features: featuresFor(tier), selfHosted, backendAvailable: true, + devSignIn, }; return c.json(dto); }); diff --git a/backend/src/tests/routes/licences.spec.ts b/backend/src/tests/routes/licences.spec.ts index 96235dd..0fc2227 100644 --- a/backend/src/tests/routes/licences.spec.ts +++ b/backend/src/tests/routes/licences.spec.ts @@ -111,6 +111,88 @@ describe('POST /api/licences/redeem', () => { }); }); +describe('the test licence', () => { + const TEST_CODE = 'BC-TEST-TEST-TEST'; + + it('upgrades the caller without a licence_keys row', async () => { + const users = fakeUsers([aUser({ tier: 'free' })]); + const res = await request('/api/licences/redeem', { + repositories: fakeRepositories(users, fakeLicences()), + env: { ENVIRONMENT: 'local', TEST_LICENCE_CODE: TEST_CODE }, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { code: TEST_CODE }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ tier: 'pro' }); + expect(users.rows.get('user-1')?.tier).toBe('pro'); + }); + + it('can be redeemed by more than one account', async () => { + // A co-organiser test needs two accounts, and a minted code is spent on + // first use — this one never is. + const users = fakeUsers([ + aUser({ id: 'user-1', email: 'a@example.com' }), + aUser({ id: 'user-2', email: 'b@example.com' }), + ]); + const repositories = fakeRepositories(users, fakeLicences()); + const env = { ENVIRONMENT: 'preview', TEST_LICENCE_CODE: TEST_CODE }; + + for (const id of ['user-1', 'user-2']) { + const res = await request('/api/licences/redeem', { + repositories, + env, + cookie: await sessionCookie(id), + method: 'POST', + body: { code: TEST_CODE }, + }); + expect(res.status).toBe(200); + } + expect(users.rows.get('user-2')?.tier).toBe('pro'); + }); + + it('accepts it typed in lower case', async () => { + const res = await request('/api/licences/redeem', { + repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()), + env: { ENVIRONMENT: 'local', TEST_LICENCE_CODE: TEST_CODE }, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { code: ' bc-test-test-test ' }, + }); + + expect(res.status).toBe(200); + }); + + it('is refused on production even when the var is set', async () => { + // The code is in a public repository; a copied env block must not turn it + // into a free licence on the deployment people pay for. + const users = fakeUsers([aUser({ tier: 'free' })]); + const res = await request('/api/licences/redeem', { + repositories: fakeRepositories(users, fakeLicences()), + env: { ENVIRONMENT: 'production', TEST_LICENCE_CODE: TEST_CODE }, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { code: TEST_CODE }, + }); + + expect(res.status).toBe(404); + expect(users.rows.get('user-1')?.tier).toBe('free'); + }); + + it('does not exist where the var is unset', async () => { + const res = await request('/api/licences/redeem', { + repositories: fakeRepositories(fakeUsers([aUser()]), fakeLicences()), + env: { ENVIRONMENT: 'local' }, + cookie: await sessionCookie('user-1'), + method: 'POST', + body: { code: TEST_CODE }, + }); + + expect(res.status).toBe(404); + }); +}); + describe('the /api/* guard', () => { it('protects everything except the session endpoint', async () => { const repositories = fakeRepositories(); diff --git a/backend/src/tests/routes/session.spec.ts b/backend/src/tests/routes/session.spec.ts index 9d8c400..8ee1ec3 100644 --- a/backend/src/tests/routes/session.spec.ts +++ b/backend/src/tests/routes/session.spec.ts @@ -81,3 +81,31 @@ describe('GET /api/session', () => { expect(dto.selfHosted).toBe(true); }); }); + +describe('devSignIn', () => { + // The app offers an email sign-in only where POST /auth/dev would accept it; + // anywhere else the button would lead to a 404. + it('is on for local development', async () => { + const dto = await getSession({ + repositories: fakeRepositories(), + env: { ENVIRONMENT: 'local' }, + }); + expect(dto.devSignIn).toBe(true); + }); + + it('is on for a self-hosted Worker', async () => { + const dto = await getSession({ + repositories: fakeRepositories(), + env: { ENVIRONMENT: 'production', SELF_HOSTED: 'true' }, + }); + expect(dto.devSignIn).toBe(true); + }); + + it('is off on the hosted deployment', async () => { + const dto = await getSession({ + repositories: fakeRepositories(), + env: { ENVIRONMENT: 'production', SELF_HOSTED: 'false' }, + }); + expect(dto.devSignIn).toBe(false); + }); +}); diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index a03fa78..3a6f425 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -39,9 +39,14 @@ "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", + // "false" so local behaves like the hosted product: you sign in as + // free and the paywall is real. POST /auth/dev still works, because + // ENVIRONMENT is "local". To try the self-hosted behaviour instead + // (every signed-in user is pro), put SELF_HOSTED="true" in .dev.vars. + "SELF_HOSTED": "false", + // Reusable licence for testing — redeem it in the app to unlock pro. + // Refused on production whatever this says (routes/licences.ts). + "TEST_LICENCE_CODE": "BC-TEST-TEST-TEST", }, }, @@ -77,6 +82,8 @@ "FRONTEND_URL": "https://preview.bottlecount.pages.dev", "ENVIRONMENT": "preview", "SELF_HOSTED": "false", + // Reusable licence for testing on the preview deployment. + "TEST_LICENCE_CODE": "BC-TEST-TEST-TEST", "GOOGLE_CLIENT_ID": "", }, }, diff --git a/shared/session.ts b/shared/session.ts index b21cae4..e74aeab 100644 --- a/shared/session.ts +++ b/shared/session.ts @@ -26,6 +26,12 @@ export interface SessionDTO { selfHosted: boolean; /** False when no backend is reachable — the pure browser-only build. */ backendAvailable: boolean; + /** + * True when `POST /auth/dev` is open — local development or a self-hosted + * Worker — so the app can offer an email sign-in rather than sending the user + * to a Google client that may not be registered. + */ + devSignIn: boolean; } export type { Feature, FeatureSet, Tier }; diff --git a/src/components/AccountButton.vue b/src/components/AccountButton.vue index b20bdad..f6ab4f3 100644 --- a/src/components/AccountButton.vue +++ b/src/components/AccountButton.vue @@ -1,7 +1,7 @@