mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
26270e9c25
* rrs stuff * studio service yay * rrs whitelist * moree studiooooooo fellowwww * rec room studio room loading yay * fixes yay * fix 2 because idk yayuh * Update cdn.app.ts * ccontent length fix? * Preserve content-length and upgrade metadata Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable. * fix x524234 * shirt thing? * [auth] remove grant table as we already have audit_log for now * rename table to generic device_grant * cleanup/lint --------- Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
298 lines
16 KiB
Markdown
298 lines
16 KiB
Markdown
# 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.<DOMAIN>`,
|
|
`accounts.<DOMAIN>`), 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/<snowflake>`
|
|
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 '<client id>' |
|
|
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID --scopes workers --remote
|
|
printf '<client secret>' |
|
|
wrangler secrets-store secret create <store-id> --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://<your domain>/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 '<bot token>' |
|
|
wrangler secrets-store secret create <store-id> --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
|
|
```
|