# www The public web frontend — the repo's first browser-facing worker (every other app is a backend service). A React SPA (built with Vite) for creating an account and setting/resetting its email and password, served by a Hono worker. ## Architecture www is a **backend-for-frontend (BFF)**. The browser only ever talks to www; www forwards to the `auth` and `accounts` workers server-side. This keeps the account JWT off other origins and sidesteps CORS (those workers set no CORS headers). - **React SPA** (`src/client/`, entry `index.html` → `src/client/main.tsx`) is built by Vite into `dist/client` and served via the `ASSETS` binding, with `not_found_handling: single-page-application` for client-side routes. - **Worker** (`src/www.app.ts`) exposes the `/api/*` BFF routes and falls back to the static assets for everything else. Upstream hosts are derived from the shared base domain (`auth.`, `accounts.`), where `DOMAIN` is injected at deploy time (see `run-wrangler-deploy`). For local dev/preview, point the `DOMAIN` var in `wrangler.jsonc` at a deployed domain so the BFF can reach those workers. ### BFF endpoints | Method | Path | Upstream | | ------ | --------------- | -------------------------------------------------------- | | GET | `/api/config` | none — whether signup is open, plus the Turnstile key | | POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) | | POST | `/api/login` | auth `POST /connect/token` (username + password) | | POST | `/api/logout` | clears the session cookie | | GET | `/api/me` | accounts `GET /account/me` | | POST | `/api/email` | accounts `POST /account/me/email` | | POST | `/api/password` | auth `POST /account/me/changepassword` | `/device` is a client-side page, not a BFF route. Rec Room Studio opens it (from the `verification_uri` auth returns) so a signed-in player can approve the editor's device code. The page calls auth `POST /connect/device/approve` itself. On signup/login the access token returned by `auth` is stored in an httpOnly `rf_token` cookie; the other routes read it and forward it as a Bearer token. `/api/signup` also takes an optional `email`, saved with a second call to accounts `POST /account/me/email` once the session exists — `create_account` has no email field, the accounts worker owns it. The address is format-checked before the account is created, since a rejection afterwards would leave a player with an account whose email silently didn't save; a failure of the save itself is logged and does not fail the signup, because by then the account is real and a retry would spend another slot against auth's per-IP cap. ### Signup and Turnstile `POST /api/signup` creates an account with no platform identity (a password account), so it's the one BFF route a bot could farm — `auth` binds no Steam id to it and only its coarse per-IP cap applies. It therefore runs behind a [Turnstile](https://developers.cloudflare.com/turnstile/) check: the browser posts the widget's token, and the worker verifies it against Turnstile's `siteverify` server-side before calling `auth`. The secret key never leaves the worker, and the browser never talks to `siteverify` itself. Two Secrets Store secrets configure it, `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY`, bound from the same account-level store every worker uses for `JWT_SECRET` (see `wrangler.jsonc` and `src/turnstile.ts`) — the site key is public, but keeping it with its secret makes the pair the single switch. Creating both is what opens signup; if either fails to resolve, `/api/config` reports `signupEnabled: false` (so the SPA shows sign-in only) and `/api/signup` returns 403, so an unconfigured worker serves no signup rather than an unprotected one. A store read that throws is treated the same as a missing key — `/api/config` is on the homepage's critical path and must not 500 when signup isn't set up. Because `.get()` caches per isolate, changing either value in the store needs a `www` redeploy before a warm worker picks it up. For local dev, seed the two names into the **local** store (miniflare's, keyed by the literal `local` store id — it is per-directory, so run these in `apps/www`) with Turnstile's documented always-passes test keypair, which needs no widget and no account: ```sh printf '1x00000000000000000000AA' | wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers printf '1x0000000000000000000000000000000AA' | wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers ``` The tests seed the same pair into their own local store in `beforeAll`. ### Benefits claim and Discord The **Claim benefits** tab on the account page lets a player prove they hold one of the qualifying roles in the community Discord and, if they do, gives their account Rec Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so that route has to keep working on a cold load even though nothing links to it. It runs a standard OAuth2 **authorization code** flow: 1. The tab sends the browser to Discord's consent screen, using the URL `www` assembles in `/api/config` plus a `state` nonce the page mints and stashes in `sessionStorage`. 2. Discord redirects back to `/claim?code=…&state=…`. The page checks the nonce is the one it minted, strips the query, and posts only the `code` to `POST /api/benefits/claim` with the player's bearer token. 3. The worker swaps the code for an access token with the client secret, reads `GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the token, and — if any one of the configured roles is there — writes `hasPlus` onto the account and links the Discord id. **A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps `hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from that claim alone — no database read on either path. The token the player's game is holding was minted before they claimed, it lasts a day, and the client never refreshes it, so they have to restart Rec Room and sign in again. The claim page says so. The browser never holds a Discord access token: the client secret can't ship to a page, which is why this is the second feature (after signup) with a server side. The scopes are `identify` and `guilds.members.read`, which let the token's owner read **their own** membership in one guild — so no bot is needed and this worker holds no credential that could read anyone else's roles. The verified Discord id is stored as a link in `platform_account` (the `auth` worker's table of account ↔ external identities, migration 0007) under `PlatformType.Discord` (101) — the same place a Steam or Meta identity lives, because that is what it is. Only `hasPlus` goes on the account itself. Nobody logs in with it. `auth`'s `verifyPlatformProof` can prove exactly two platforms (Steam and Meta), so a `cached_login` naming 101 is refused outright, and the login picker filters to those same platforms (`CACHED_LOGIN_PLATFORMS`). That filter matters for privacy as well as correctness: the picker is public and unauthenticated, so without it `GET /cachedlogin/forplatformid/101/` would tell anyone which RecFlare account a given Discord user owns. Storing the link there is what makes the claim once-only **per Discord user**, not per account: a second claim from the same Discord member on a different account is refused (409), answered from the table's index rather than a scan of every account blob. Re-claiming on the same account is idempotent — the link is `INSERT OR IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to reload. Nothing revokes Plus: losing the role later leaves the flag set, so it records "held the role once", not "holds it today". **The first link pays the supporter gift once.** `econ`'s cron hands every account holding a role mapped in `DISCORD_ROLE_TOKENS` (`RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`, see the `econ` README) that role's tokens in a gift box each time it fires. The claim pays the same box, through the same `grantDiscordRoleGift`, the first time an account links a Discord — so a new supporter isn't waiting a whole schedule for their first one. "First" is decided from the link table before the write: a re-claim, and a claim that puts a second Discord identity on an account that already has one, both pay nothing. The response's `tokensAwarded` says what was paid (null when nothing was), and the claim page mentions the box. The grant runs after Plus is granted and the link written, and a failure in it is logged rather than failing the claim. Unset, the var awards nothing and the claim is as before. The link also records the member's **roles** (`platform_account.role`, a JSON array of role ids). The claim writes them from the member record it read; on their own they'd be a snapshot from claim time, because the claim revokes the player's token the moment it's done and holds nothing that could ask again. The optional **role refresh** is what keeps them current — see below. Four settings configure it, and **all four** are required or the claim stays closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab, and `/api/benefits/claim` returns 403). A half-configured app is treated as unconfigured on purpose: a client id and secret with no guild/roles would authenticate a player and have no question left to ask about them. - `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` — Secrets Store, same account-level store as `JWT_SECRET` and the Turnstile pair. The id is public (it ships to the browser inside the authorize URL) but lives beside its secret so one place configures the feature. - `DISCORD_GUILD_ID` / `DISCORD_BENEFITS_ROLE_IDS` — plain vars in `wrangler.jsonc`, not credentials. Both hold Discord **snowflakes: all digits, no letters**. Turn on Developer Mode in Discord (Settings → Advanced), then right-click the server or the role and Copy ID. These are ids, not names — `Supporter` is what the role is _called_, `1077000000000000002` is what goes in the var — and they're quoted as strings because a snowflake is too large to survive as a JSON number. `DISCORD_BENEFITS_ROLE_IDS` is a **list**, separated by commas and/or whitespace, so several tiers can qualify for the same benefit. **Any one** of them is enough — they are alternatives, not requirements: ```jsonc "DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002" ``` Blank entries are dropped, so a trailing comma is harmless. A value that parses to no ids at all counts as unset and closes the claim, rather than opening it with nothing to check against. ```sh printf '' | wrangler secrets-store secret create --name DISCORD_CLIENT_ID --scopes workers --remote printf '' | wrangler secrets-store secret create --name DISCORD_CLIENT_SECRET --scopes workers --remote ``` The two ids are **not secrets**, and setting only the secrets is the usual reason the page never appears. Put them in the root `.env` as operator knobs, where they ride along as `--var` on deploy (see `recflare_vars`), rather than editing `wrangler.jsonc` — that keeps your server's ids out of the repo: ```sh RECFLARE_DISCORD_GUILD_ID=1077000000000000000 RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 ``` Use **commas with no spaces** there. Those knobs become `--var` flags that the deploy script word-splits, so a value containing a space breaks it. (`parseRoleIds` also accepts whitespace, which is fine in `wrangler.jsonc` but not via `.env`.) Then redeploy `www` — the Secrets Store `.get()` caches per isolate, so a warm worker won't pick up newly created secrets until it restarts. **Diagnosing a claim that won't appear:** fetch `/api/config`. If `benefitsEnabled` is `false`, the gate is closed and it isn't a UI problem — `www` logs `discord is half-configured, so benefit claims are closed` with a flag per input (`hasClientId`, `hasClientSecret`, `hasGuildId`, `roleIdCount`), which names exactly which one is missing. `wrangler tail www` shows it. In the [Discord developer portal](https://discord.com/developers/applications), add `https:///claim` to the app's **Redirects**. It has to match byte for byte: `www` derives the redirect URI from the incoming request's own origin (never from the request body, which would turn the client secret into a redemption oracle for someone else's app), so add `http://localhost:5173/claim` too if you want the flow to work under `pnpm turbo dev`. ### Discord role refresh (cron) A daily cron on `www` (`triggers.crons` in `wrangler.jsonc`, 04:30 UTC) re-reads every claimed member's roles through a **bot token** and rewrites `platform_account.role` — so the table says what a member holds now, not what they held the day they claimed. It is the one credential the claim deliberately lacks, and it is optional: without it the cron logs `discord role sweep: off` once a day and nothing changes. It touches **only the role snapshot**. `hasPlus` is never revoked or granted by the sweep: a member who left the guild is recorded as holding no roles and keeps their Plus, exactly as `runx admin grant-plus` accounts (which have no link at all) do. Read the roles and the flag together to decide what to do about a lapsed supporter. To turn it on: 1. On the same Discord application, open **Bot** and create the bot user; **Reset Token** and copy it. No privileged intents and no permissions are needed — reading one member's record only requires the bot to be in the guild. 2. Invite the bot to your server (**OAuth2 → URL Generator**, scope `bot`, no permissions) and accept. 3. Store the token beside the other Discord credentials: ```sh printf '' | wrangler secrets-store secret create --name DISCORD_BOT_TOKEN --scopes workers --remote ``` 4. Redeploy `www`. **The binding must exist for the deploy to succeed** — as with `META_APP_SECRET` on `auth`, an operator who doesn't want the sweep still creates `DISCORD_BOT_TOKEN` with any placeholder value. With a placeholder the sweep gets a 401 on its first call, logs `discord role sweep: stopping, discord refused the bot token (401)` and writes nothing; with no Discord links in the table it never calls Discord at all. Each run walks **every** Discord link, one Discord call and one D1 write per link, in one cron invocation. A Worker invocation has a fixed budget of both (50 subrequests on the free plan, 1000 on paid), so that is the ceiling on claimed members before the run would need splitting up. A run **stops without writing** when Discord refuses the token (401), the bot may not read members (403), or the bot is not in the guild (404 `Unknown Guild`) — a bot that hasn't been invited yet must not blank every snapshot as "left". A member who has genuinely left (404 `Unknown Member`) is written as holding no roles. Transient errors skip that one link and leave its snapshot for the next run, and the sweep honors Discord's rate-limit headers between calls. `wrangler tail www` shows a summary line per run: `discord role sweep: refreshed N of M links, …`. ## Development ### Run in dev mode ```sh pnpm turbo dev ``` ### Run in preview mode ```sh pnpm turbo preview ``` ### Run tests ```sh pnpm test ``` ### Deploy ```sh pnpm turbo deploy ```