Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
run: |
failed=0
while IFS= read -r page; do
if ! grep -q '<link rel="canonical" href="https://bottlecount.pages.dev' "$page"; then
if ! grep -q '<link rel="canonical" href="https://bottlecount-epj.pages.dev' "$page"; then
echo "::error::$page has no canonical pointing at the application host"
failed=1
fi
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Run it free in your browser with no account, pay once for the hosted version, or
[![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)

**App:** https://bottlecount.pages.dev — the product, free and paid tiers alike
**App:** https://bottlecount-epj.pages.dev — the product, free and paid tiers alike
**Docs:** https://fre0grella.github.io/BottleCount — documentation only, no app

---
Expand Down Expand Up @@ -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:
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/<slug>` and `/join/<token>` — 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`.

```bash
curl -X POST http://localhost:8787/auth/dev \
-H 'content-type: application/json' \
-d '{"email":"you@example.com"}' -c cookies.txt
```
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:

Expand Down Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const isDocs = target === 'docs';

const site =
process.env.SITE ??
(isDocs ? 'https://fre0grella.github.io' : 'https://bottlecount.pages.dev');
(isDocs
? 'https://fre0grella.github.io'
: 'https://bottlecount-epj.pages.dev');
const base = process.env.BASE_PATH ?? (isDocs ? '/BottleCount/' : '/');

/**
Expand Down Expand Up @@ -64,9 +66,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/<slug>` and `/join/<token>` 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 }]),
),
},
},
});
4 changes: 4 additions & 0 deletions backend/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
25 changes: 20 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

```
Expand Down
8 changes: 5 additions & 3 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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({
Expand Down
15 changes: 13 additions & 2 deletions backend/src/routes/devAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 }>()
Expand Down
44 changes: 33 additions & 11 deletions backend/src/routes/licences.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
}
Expand Down
16 changes: 11 additions & 5 deletions backend/src/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +24,7 @@ function anonymous(selfHosted: boolean): SessionDTO {
features: featuresFor(tier),
selfHosted,
backendAvailable: true,
devSignIn,
};
}

Expand All @@ -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 });
Expand All @@ -69,6 +74,7 @@ session.get('/', async (c) => {
features: featuresFor(tier),
selfHosted,
backendAvailable: true,
devSignIn,
};
return c.json(dto);
});
Expand Down
Loading
Loading