{ "$schema": "node_modules/wrangler/config-schema.json", "name": "www", "main": "src/www.app.ts", "compatibility_date": "2026-06-16", "compatibility_flags": ["nodejs_compat"], "routes": [], // The shared `recflare` D1. Read for the public `/server-status` head-count and the // benefits claim; WRITTEN by the claim (`hasPlus`) and by the staff moderation panel, // which files reports and hands down bans on the `report` table (see src/staff.ts) and // records every ban and lift on `audit_log`. // The schema is owned by the workers that write it (presence lives in // apps/rooms/migrations, `report` and `audit_log` in apps/api/migrations), so no migrations_dir: www // never migrates. The "local" placeholder is spliced with RECFLARE_D1 at deploy time, // as it is for every other worker. "d1_databases": [ { "binding": "DB", "database_name": "recflare", "database_id": "local" } ], // React SPA client build (Vite emits it to dist/www/client). Static assets are // served directly; any non-asset request falls through to the Worker, which // serves API routes and returns index.html for client-side routes (SPA). // // `run_worker_first` (an ARRAY) is an EXCLUSIVE allowlist: it replaces the default // routing entirely, so the Worker runs ONLY for the listed patterns and every other // path is served assets-first (with the SPA fallback → index.html). It must therefore // list EVERY route the Worker handles, not just the new ones — otherwise `/api/*` // falls through to index.html and both signup and the site config break (the SPA // reads the other workers' hostnames from `/api/config`). Why it's needed at all: with // SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers // send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker, // so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's // routes (`/api/*`, `/docs*`, `/privacy` and `/server-status`). // `/docs/scalar.standalone.js` is // deliberately excluded so it's served directly as the static asset it is. // // `/privacy` is here for the same reason `/docs` is, and it matters more: the Meta // Horizon Store re-fetches the privacy policy URL to confirm it's live, and dropping // it from this list would serve that fetch the SPA shell instead of the policy. "assets": { "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy", "/server-status"] }, // The Turnstile keypair guarding web signup, out of the same account-level Secrets // Store every other worker binds for JWT_SECRET — values live there, never in this // file. The "local" store_id placeholder is replaced with RECFLARE_SECRETS_STORE at // deploy time, exactly as it is for the other workers. // // wrangler secrets-store secret create --name TURNSTILE_SITE_KEY \ // --scopes workers --remote // wrangler secrets-store secret create --name TURNSTILE_SECRET_KEY \ // --scopes workers --remote // // Creating both is what OPENS signup; if either can't be resolved it stays closed, so // an operator who skips this gets no signup rather than an unprotected one. The SITE // key is public (it ships to the browser to render the widget) and is kept here beside // its secret so one place configures signup. See src/turnstile.ts. "secrets_store_secrets": [ { "binding": "TURNSTILE_SITE_KEY", "store_id": "local", "secret_name": "TURNSTILE_SITE_KEY" }, { "binding": "TURNSTILE_SECRET_KEY", "store_id": "local", "secret_name": "TURNSTILE_SECRET_KEY" }, // The shared HS256 signing key, bound here for the ONE www route that acts on a // specific account: the benefits claim writes `hasPlus` onto the caller's row, so it // has to verify which account is calling rather than trust the SPA. { "binding": "JWT_SECRET", "store_id": "local", "secret_name": "JWT_SECRET" }, // The Discord OAuth2 application behind the benefits claim. Same store, same // public-key-beside-its-secret arrangement as the Turnstile pair: the client id // ships to the browser to build the authorize URL, the secret never leaves the // worker (see src/discord.ts). // // wrangler secrets-store secret create --name DISCORD_CLIENT_ID \ // --scopes workers --remote // wrangler secrets-store secret create --name DISCORD_CLIENT_SECRET \ // --scopes workers --remote // // These two PLUS the DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS vars below are what // OPENS the claim; with any of the four missing it stays closed, so an operator who // skips this gets no claim rather than one that grants Plus without checking. { "binding": "DISCORD_CLIENT_ID", "store_id": "local", "secret_name": "DISCORD_CLIENT_ID" }, { "binding": "DISCORD_CLIENT_SECRET", "store_id": "local", "secret_name": "DISCORD_CLIENT_SECRET" }, // The bot user's token, for the scheduled role refresh (see `triggers` below and // src/discord-roles.ts). Unlike the pair above it is not part of the claim: it lets // the worker re-read a claimed member's roles WITHOUT that member's own OAuth token, // which is the one thing the claim's design cannot do. Add a bot to the same Discord // application (Bot → Reset Token), invite it to the guild with no permissions — reading // a member needs none — and: // // wrangler secrets-store secret create --name DISCORD_BOT_TOKEN \ // --scopes workers --remote // // The binding has to resolve for the deploy to succeed, so an operator who wants no // sweep still creates the secret with any placeholder value: the sweep then logs that // it's off and does nothing, and the claim is unaffected either way. { "binding": "DISCORD_BOT_TOKEN", "store_id": "local", "secret_name": "DISCORD_BOT_TOKEN" } ], // The Discord role refresh: once a day, re-reads every claimed member's roles through // the bot token and rewrites `platform_account.role` (src/discord-roles.ts). One // Discord call and one D1 write per link, so a guild with more claimed members than a // cron invocation's subrequest budget (50 free, 1000 paid) is the point to split it up. "triggers": { "crons": ["30 4 * * *"] }, // The `auth` worker, reached directly instead of over its public hostname. This is // about the CLIENT IP, not latency: a Worker subrequest to https://auth. // re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's // own address — so auth recorded the SAME `signupIp` for every browser signup and its // per-IP cap (3 by default) locked out every player after the third account ever // created. A service binding skips the edge, so the real browser IP www forwards on // that header survives (see src/upstream.ts `postAuthForm`). // // Only auth is bound, and only for SIGNUP — the one call this worker still makes on // the browser's behalf, because Turnstile's secret key can't ship to a page. Sign-in, // the profile mutations and the photo feed are posted by the browser straight to // auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real // client IP for free. "services": [{ "binding": "AUTH", "service": "auth" }], // The notifications hub, owned by the `notify` worker. Bound for ONE action: a ban // handed down from the staff panel ejects the player from the instance they are // standing in, which needs a `ModerationKick` frame pushed to their client (see // src/staff.ts). Without it a ban would not bite until their next matchmake, so // someone banned mid-session keeps playing. "durable_objects": { "bindings": [ { "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub", "script_name": "notify" } ] }, "upload_source_maps": true, "observability": { "enabled": false }, "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment // Base domain every worker hostname is derived from (auth., // accounts., …). Overridden at deploy time with the real RECFLARE_DOMAIN // (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which // is how one client build works for any operator. For local dev, point it at a // deployed domain so the page has real workers to call. "DOMAIN": "rec.example.com", // The Discord server the benefits claim checks membership of, and the roles in it // that grant Rec Room Plus. Every value here is a Discord SNOWFLAKE — all digits, no // letters — copied off a client with Developer Mode on (right-click the server or the // role → Copy ID). They are ids, not names: "Supporter" is what the role is called, // 1077000000000000002 is what goes here. Not credentials, so they live in this file // rather than in the Secrets Store; quoted as STRINGS because a snowflake exceeds // 2^53 and would lose precision as a JSON number. // // ROLE_IDS is a LIST, separated by commas and/or whitespace. Any ONE of them // qualifies, so several tiers can share the benefit: // // "DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002" // // Empty by default: an operator who hasn't set up a Discord app has no server to // point at, and an empty value (or one that parses to no ids) closes the claim — see // src/discord.ts `discordConfig` — instead of leaving a form that grants Plus to // anyone who signs in with Discord. "DISCORD_GUILD_ID": "", "DISCORD_BENEFITS_ROLE_IDS": "" } }