1 Commits

Author SHA1 Message Date
Devin Zuczek 5010f62371 add subroom permissions 2026-08-03 15:16:10 -04:00
40 changed files with 482 additions and 3223 deletions
-13
View File
@@ -64,16 +64,3 @@ RECFLARE_DOMAIN=rec.example.com
# 0 means players start broke. Applies only to players who haven't been granted yet —
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
#
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
# --scopes workers --remote
#
# Setting them both is what opens web signup; with either missing it stays closed. See
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
# caps above apply regardless.
+6 -74
View File
@@ -43,12 +43,10 @@ services but would require small code changes.
## Prerequisites
**You must have all these requirements or RecFlare deployment will fail!**
- node 24 (https://nodejs.org)
- pnpm (install with `npm install -g pnpm`)
- bun (https://bun.sh)
- jq/awk/sed (on Windows try `winget jq` etc.)
- node (modern)
- pnpm
- bun
- jq/awk/sed
- A Cloudflare account with a zone (domain) you control, for deploying.
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
@@ -67,8 +65,6 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
Create a new .env file from the template:
@@ -81,11 +77,11 @@ Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export
(Optional) - per-app subdomain overrides come from
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
if you wanted to merge two services together.
**Create the storage resources:**
The workers bind Cloudflare storage primitives. Create them once against your
The workers bind Cloudflare storage primitive. Create them once against your
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
nothing in version control needs editing. Authenticate wrangler first
@@ -108,24 +104,6 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
```
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
and only to authenticate itself to Meta when validating a headset login's nonce —
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
it. Create it too:
```bash
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
> 500 ("Meta platform verification is not configured") and nothing else is affected.
> Steam and password sign-ins are unaffected either way. Put the real value in later
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
@@ -221,52 +199,6 @@ single address, so raise it (or set it to `0`) if real players report being lock
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
### Signing up on the website (Turnstile)
Players get an account by launching the game, which needs no setup. The website can create
one too — that path has no platform identity behind it, so it runs behind a
[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until
you configure one**. Two steps, both one-time:
1. Create the widget: Cloudflare dashboard → **Turnstile****Add widget**, mode
**Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against
real keys). It gives you a **site key** and a **secret key**.
2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch
that opens signup, and store values survive deploys:
```bash
wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
--scopes workers --remote
wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
--scopes workers --remote
```
Then `just deploy -F www`. The site key is public — the browser needs it to render the
widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is
configured in one place. The secret key never leaves the worker: `/api/signup` verifies the
token against Turnstile server-side before it calls `auth`.
Signup opens only when **both** resolve. With either missing, `/api/config` reports signup
closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs
you the signup form, never an unprotected one. That is also how you turn signup back off:
`wrangler secrets-store secret delete <store-id> --name TURNSTILE_SECRET_KEY --remote`,
then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one
until fresh isolates start). For local dev, seed the same two names into the local store
from `apps/www` — Turnstile's documented always-passes test keypair
(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a
widget:
```bash
cd apps/www
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
```
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
the only cap that can see a web signup.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
-2
View File
@@ -2,8 +2,6 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
+1 -3
View File
@@ -112,9 +112,7 @@ function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
// everyone can be old.
birthday: '1904-01-01T00:00:00.000Z',
birthday: null,
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
+9 -9
View File
@@ -2,12 +2,7 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
GAME_VERSION,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import '../../api.app'
@@ -49,9 +44,14 @@ const TEST_ROOMS = [
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
+1 -1
View File
@@ -12,7 +12,7 @@
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://recflare.net"
"MoreInfoUrl": "https://github.com/djdevin/recflare"
},
"InstagramImages": [
{
+20 -72
View File
@@ -42,79 +42,35 @@ route without documenting it fails rather than silently shipping an incomplete s
without matchmaking. A posted `password` becomes the login credential.
- **`cached_login`** — logs into an already-linked account using platform ownership as
the credential; no password. The posted `account_id` must be linked to exactly the
identity `platform_auth` proves.
identity the Steam ticket proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
all, which is what closes id/username-only takeover. When it also carries a verifying
`platform_auth`, that identity is **linked** to the account (see below).
all, which is what closes id/username-only takeover.
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
### Verifiable platforms: Steam and Meta
### Steam is the only verifiable platform
Only an identity we can _prove_ is ever bound to an account, so any grant that
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
asserts a platform) must be a platform we can verify. Two are:
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
Steam's signature against Steam's system public key. Verified **offline**: no
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
client-supplied `platform_id`.
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
to a user id, so here the posted `platform_id` is an _input_ to the check and a
spoofed one fails. This means an outbound request on every Meta login, and no Meta
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client.
Everything else is refused. Whichever platform, the identity that gets bound or linked
is the verified one, never the raw `platform_id` field.
### One account, many platform identities
An account can be reached from several platform identities — a player's PC and their
headset both open the same account, with no password after the first time. The links
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
(platform, platform id, account).
That table is the **one source of truth** for both halves of a cached login: the picker
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
`cached_login` grant asks it whether the account it was handed is linked to the identity
just proven. They used to be two separate checks over the account blob's single
`platformId`, which could disagree — the client would be offered an account that then
answered "no linked account" forever.
A second device is linked by **logging in with a password there**: the client posts its
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
verified identity is ever linked, since a link is a password-free way into the account.
A proof that doesn't verify never fails the login — it just leaves that device without a
cached login.
The account blob keeps `platform`/`platformId` as the account's **primary** identity
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
of one identity per account invites exactly the picker/grant disagreement above. Look
identities up in `platform_account`.
`platform_auth` tickets are verified **offline**`src/steam-ticket.ts` parses the
ticket and checks Steam's signature against Steam's system public key. No publisher
Web API key, no network call. Steam (platform `0`) is therefore the only platform
whose identity can be proven, so any grant that authenticates _by platform identity_
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
ever written to an account's `platformId`.
## Signup caps
`create_account` is capped on two independent arms, per verified platform identity and
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
arm is coarse and will produce false positives behind NAT, shared campus and mobile
`create_account` is capped on two independent arms, per verified platform id and per
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
is coarse and will produce false positives behind NAT, shared campus and mobile
networks. Both default to 3.
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
limit could otherwise have accounts created for it with a password and link its way into
all of them. Hitting it never fails a password login — the account just doesn't get a
cached login on that device.
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
@@ -122,12 +78,11 @@ small private server, or when a shared network is being locked out.
## Bindings
| Binding | Type | Notes |
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
| Binding | Type | Notes |
| -------------------- | ------------- | ------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
table, so they stay independent of the `rooms` worker's migrations on the same
@@ -154,19 +109,12 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
# sign-ins then answer 500 until it holds the real value.
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
```
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
The Meta secret is read per request, so updating it takes effect without a redeploy.
Rotating the store value invalidates all existing tokens (clients re-authenticate).
@@ -1,35 +0,0 @@
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
-- PC and a headset gets a cached login on both. The account blob's single
-- `platformId`/`platform` pair could only hold one, so logging in on the second
-- device meant a password every time.
--
-- Links move into their own table, which becomes the one source of truth for both
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
-- stay as the account's *primary* identity — the first one linked — for the account
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
-- because nothing ever defaulted that field: an account can carry a platformId with no
-- platform recorded, and back when Steam was the only verifiable platform an unset one
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
-- tests run.
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> '';
@@ -1,23 +0,0 @@
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
-- one source of truth for the login picker and the `cached_login` grant. The column's
-- last reader was 0007's own backfill, which has already run.
--
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
-- open?" — it only ever holds the account's primary identity, so an account reachable
-- from a PC and a headset appears here under one of them. That is exactly the split
-- that used to have the picker offer an account the grant then refused.
--
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
-- generated column and its index only — a virtual column stores nothing, so no account
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
--
-- Safe to run before or after the deploy that ships it: no worker queries this column,
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
-- run again; the exported copy selects the blob instead so tests keep working.)
DROP INDEX IF EXISTS idx_accounts_platform_id;
ALTER TABLE account DROP COLUMN platform_id;
+118 -310
View File
@@ -3,12 +3,13 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByIds,
getAccountsByPlatformId,
getPasswordHash,
getRoomById,
hashPassword,
@@ -18,17 +19,16 @@ import {
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
@@ -38,25 +38,26 @@ import {
TokenRequest,
TokenResponse,
} from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
} as const
/**
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
@@ -165,155 +166,48 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
/**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no
* platform recorded — and until Meta verification landed Steam was the only identity
* we could prove, so an unset one *is* Steam. Every account bound since records its
* platform explicitly; this default only covers those older rows.
* platform recorded — and Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
*/
function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* Whether an account is the one linked to a given platform identity — the single
* check behind both the cached-login picker and the `cached_login` grant. It lives in
* one place on purpose: if the picker offers an account the grant then rejects, the
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity (the SteamID64 from a verified
* platform_auth ticket), never the client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
platform: number,
platformId: string
): boolean {
if (!account.platformId || platformId === '') return false
return account.platformId === platformId && accountPlatform(account) === platform
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
*
* The platform and id come from the LINK, not from the account: an account linked to
* both a Steam and a Meta identity appears in both pickers, and each has to report the
* identity that picker was asked about — that's what the client posts back, and what
* the grant then checks the link against.
* (the platform_auth ticket) is the credential for a cached login — no prompt.
*/
function toCachedLogin(account: Account, link: PlatformLink) {
function toCachedLogin(account: Account) {
return {
platform: link.platform,
platformId: link.platformId,
platform: accountPlatform(account),
platformId: account.platformId ?? '',
accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false,
}
}
/**
* Project a set of links into picker entries, dropping any whose account no longer
* exists. One batched account read rather than one per link.
*
* Order follows the links (oldest first), so the picker is stable between launches.
*/
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
if (links.length === 0) return []
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
const byId = new Map(accounts.map((a) => [a.accountId, a]))
return links.flatMap((link) => {
const account = byId.get(link.accountId)
return account ? [toCachedLogin(account, link)] : []
})
}
/**
* Link the platform identity a password login proved to the account it logged into,
* so the next launch on that device is a cached login. Called only with a VERIFIED
* identity — a link is a password-free way into the account.
*
* Already linked is the common case (every subsequent login on that device) and costs
* one read and nothing else.
*
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
* an identity could otherwise sit at the limit, have accounts created for it with a
* password, and link its way into all of them. Reaching it does NOT fail the login —
* the password was valid — it just leaves the account without a cached login, so the
* player types their password each time rather than being locked out.
*
* The first identity linked also becomes the account's primary (the blob's
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
* claims report. Later platforms link without disturbing it.
*/
async function linkLoginIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
maxAccountsPerIdentity: number
): Promise<void> {
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
if (
maxAccountsPerIdentity > 0 &&
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
) {
logger.info('platform link refused: account limit reached for this platform identity', {
accountId,
platform,
platformId,
})
return
}
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
logger.info('linked platform identity to account', { accountId, platform, platformId })
const account = await getAccount(db, accountId)
if (account && !account.platformId) {
await updateAccount(db, accountId, { platform, platformId })
}
}
/**
* What a login's `platform_auth` proved, if anything. Failures are split because the
* callers act on them differently: a grant that authenticates BY platform identity has
* to refuse, while a password grant — which has already proven who it is — carries on
* and just doesn't link.
*
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
* and is the one case that warrants a 5xx.
*/
type PlatformProof =
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
| { status: 'none' }
| { status: 'verified'; platform: number; platformId: string }
| { status: 'unsupported' }
| { status: 'unconfigured' }
| { status: 'rejected'; reason: string }
/**
* Verify a login's `platform_auth` and return the identity it proves.
*
* The two verifiable platforms prove the id in opposite directions, which is why they
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
* so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
* validated *against* the posted `platform_id`, so that field is an input, and a
* spoofed one fails validation rather than being ignored. Either way the id that comes
* back is proven, never the raw client-supplied field, and only a proven id is ever
* written to an account or linked to one.
*/
async function verifyPlatformProof(
env: App['Bindings'],
platform: number,
platformAuth: string,
postedPlatformId: string
): Promise<PlatformProof> {
if (platform === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
}
if (platform === PlatformType.Oculus) {
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
// holding an empty/placeholder value) — the same misconfiguration from the player's
// side, so it takes the same branch.
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') return { status: 'unconfigured' }
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
return {
status: 'verified',
platform: PlatformType.Oculus,
platformId: verified.identity.userId,
}
}
return { status: 'unsupported' }
}
const app = new Hono<App>()
.use(
'*',
@@ -356,43 +250,55 @@ const app = new Hono<App>()
tags: ['Cached login'],
summary: 'Accounts linked to a platform id',
description: [
'Accounts the client may offer on its login screen for this platform identity',
'the links this identity has, so an entry here is always redeemable by a',
'`cached_login` grant (both read the same table). An account linked to several',
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
'and the client falls back to a fresh login or create_account.',
'Accounts the client may offer on its login screen for this platform identity.',
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
'back to a fresh login or create_account.',
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
'canned, non-redeemable entry with `requirePassword: true`.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
description: 'PlatformType integer. A non-numeric value disables the link filter.',
schema: { type: 'string' },
},
{
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
description: 'Platform-native id — a SteamID64 for Steam.',
schema: { type: 'string' },
},
],
responses: {
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
),
},
}),
async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// Listed straight from the link table, which is also what the `cached_login`
// grant authorizes against — so the picker can't offer an account the grant
// then refuses.
const links = Number.isNaN(platformInt)
? await getLinksForPlatformId(c.env.DB, id)
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
return c.json(await toCachedLogins(c.env.DB, links))
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
// the real path would always yield []. Hand back one canned entry instead, so the
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
// genuine cached login there is no platform ticket behind this, so the client must
// prompt. Delete this branch once Oculus platform auth lands.
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
accounts
.filter(
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
)
.map(toCachedLogin)
)
}
)
@@ -406,8 +312,8 @@ const app = new Hono<App>()
description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the',
'response cannot be mapped back to a specific input id — the client uses each',
'entrys own `platformId`. No platform accompanies these ids, so each matches on',
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
'entrys own `platformId`. Unlike the single-id route, results are NOT filtered to',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
@@ -418,8 +324,7 @@ const app = new Hono<App>()
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
// No platform accompanies these ids, so they match on any platform.
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
}
return c.json(out)
}
@@ -440,13 +345,12 @@ const app = new Hono<App>()
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'`platform`, that platform must be Steam and `platform_auth` must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
'link for that identity cannot be cached-logged-into.',
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
'`account_id` must be linked to exactly the identity that ticket proves. An account',
'with no stored platform identity cannot be cached-logged-into.',
'',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
@@ -454,22 +358,13 @@ const app = new Hono<App>()
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
'that verifies, that identity is LINKED to the account — this is how a player who',
'signed up on one platform gets a cached login on a second device. The login is',
'never failed over the link: an unverifiable proof (or one over the per-identity',
'cap) just leaves the account without a cached login there.',
'which is what closes id/username-only takeover.',
'',
'**Platform identity.** An account can be reached from several platform identities;',
'the links are the one thing both the picker and `cached_login` consult, and only a',
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
'when it is unset. The first identity linked also becomes the accounts primary',
'(what the account DTO and a refreshed token report); later ones only link.',
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
'`platform_id` and is the only value ever written to an account. Password and refresh',
'grants carry their own credential and are not gated this way.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
@@ -483,17 +378,13 @@ const app = new Hono<App>()
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ')
),
500: json(
OAuthError,
[
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
'(no nonce can be validated without it).',
].join(' ')
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
),
},
}),
@@ -528,91 +419,43 @@ const app = new Hono<App>()
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
// verifyPlatformProof). Only a verified identity is ever bound or linked.
//
// Two grants are GATED on it — they have no other credential, so an unverifiable
// platform is fatal:
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
// (see below), which is how a player who created an account on Steam gets a cached
// login on their headset. If it doesn't verify, the login still succeeds — it just
// links nothing, because a link is a password-free way into the account and must
// never rest on an unproven id.
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
const platformAsserted = !Number.isNaN(platformInt)
const gatedOnPlatform =
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
// The password grant only spends a verification when the client actually offered
// one; the rest of the time there is nothing to link.
const proof: PlatformProof =
gatedOnPlatform || (platformAsserted && platformAuth !== '')
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
: { status: 'none' }
let verifiedPlatformId: string | null = null
let verifiedPlatform: number | null = null
if (proof.status === 'verified') {
verifiedPlatform = proof.platform
verifiedPlatformId = proof.platformId
} else if (proof.status !== 'none') {
// Log every failure, including the ones a password grant shrugs off: a player
// who silently never gets a cached login on their headset has no other symptom,
// and this line is where "Meta rejected the nonce" becomes visible.
logger.info('platform_auth not verified', {
platform: platformInt,
platformId,
grantType,
status: proof.status,
reason: proof.status === 'rejected' ? proof.reason : undefined,
})
}
if (gatedOnPlatform && proof.status !== 'verified') {
if (proof.status === 'unsupported') {
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== PlatformType.Steam) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam and Meta can be verified',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
if (proof.status === 'unconfigured') {
// An operator misconfiguration, not the client's fault: without the app secret
// every Meta player is locked out, so it answers 500 the way an unset
// JWT_SECRET does below rather than blaming the credential. (We never fall
// back to trusting the posted id — that would let anyone log into any
// Meta-linked account by naming its user id.)
logger.error('refusing a Meta login: META_APP_SECRET is empty')
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{
error: 'server_error',
error_description: 'Meta platform verification is not configured',
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
},
500
400
)
}
// The reason is for the operator; the client is told only that it was rejected.
// A wrong app secret and a stale nonce look identical from the client side.
return c.json(
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
400
)
verifiedSteamId = verified.steamId
platformId = verified.steamId
}
// From here on `platformId` is the PROVEN identity wherever there is one — the
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
// never the raw client-supplied field.
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
@@ -639,16 +482,10 @@ const app = new Hono<App>()
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
{
error: 'invalid_grant',
@@ -672,15 +509,14 @@ const app = new Hono<App>()
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
// binds nothing. The account blob keeps this first identity as its PRIMARY one
// (for the account DTO and the refresh grant's claims); the link written just
// below is what a later cached login is actually authorized against.
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
@@ -688,14 +524,6 @@ const app = new Hono<App>()
lastLoginIp: clientIp || undefined,
})
accountId = String(account.accountId)
if (verifiedPlatformId !== null) {
await linkPlatformIdentity(
c.env.DB,
account.accountId,
verifiedPlatform ?? 0,
verifiedPlatformId
)
}
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
@@ -719,25 +547,18 @@ const app = new Hono<App>()
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
// table says that account is linked to exactly this platform identity — this is
// the check that keeps anyone but that platform user out of the account
// (platform ownership is the credential; no password needed). An account with no
// link for the presented identity must use a password.
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
//
// The picker lists straight from the same table, so it can only offer accounts
// this check accepts.
//
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
// the ticket, or the Meta user id the nonce validated against — never the raw
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
return c.json(
{
error: 'invalid_grant',
@@ -779,19 +600,6 @@ const app = new Hono<App>()
)
}
accountId = String(resolvedId)
// The password proved the account; the platform proof (when the client sent one
// and it verified) proves the device's platform identity. Linking the two is
// what gives a player who signed up on Steam a cached login on their headset —
// they type their password once there, and never again.
if (verifiedPlatformId !== null) {
await linkLoginIdentity(
c.env.DB,
resolvedId,
verifiedPlatform ?? 0,
verifiedPlatformId,
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
)
}
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
-7
View File
@@ -12,13 +12,6 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
// only to authenticate US to Meta's graph API when validating a login nonce (see
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
// offline, Meta logins are impossible without it, so an empty value fails those
// logins with a 500 rather than silently trusting the client's platform_id.
META_APP_SECRET: SecretsStoreSecret
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
-172
View File
@@ -1,172 +0,0 @@
/**
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
*
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
* nonce that means nothing on its own. The only way to know it is genuine is to ask
* Meta — which is why this path makes an outbound request on every Meta login and
* cannot work at all without the app secret.
*
* A Meta login posts
*
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
* platform_id = <the Meta user id>
*
* and validation is what BINDS those two together: `user_nonce_validate` answers
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
* an *input* here rather than something read out of a ticket, and a spoofed one fails
* — a nonce Meta issued to user A does not validate as user B. The id is therefore
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
* account. (It's an app-scoped id: it identifies the player within this app only.)
*
* The `AppId` comes from the payload rather than config because it must be the app the
* nonce was issued for — a different one simply fails, since the access token below
* pairs it with our secret. `Source` is informational and ignored.
*
* Shape and retry policy follow the reference Go server's utils/oculus.go.
*/
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
/**
* Graph error codes worth retrying — 1 (unknown) and 2 (service temporarily
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
* real answer and retrying it just delays a login that is going to fail anyway.
*/
const TRANSIENT_ERROR_CODES = new Set([1, 2])
/**
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
* the client on a headset loading screen.
*/
const MAX_ATTEMPTS = 3
/** The trustworthy identity proven by a validated nonce. */
export interface VerifiedMetaIdentity {
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
userId: string
/** The Meta app the nonce was issued for. */
appId: string
}
/**
* The outcome of a verification. Failures carry a `reason` for the server log: the
* client is told only that its platform_auth was rejected (it can't act on more), but
* an operator debugging a headset that won't log in needs to know whether Meta said
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
*/
export type MetaVerification =
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
export interface MetaPlatformAuth {
nonce: string
appId: string
}
/**
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
* numeric — it is interpolated into the access token below, and this is what keeps a
* client-supplied string out of that credential.
*/
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
let parsed: { Nonce?: unknown; AppId?: unknown }
try {
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
} catch {
return null
}
const { Nonce: nonce, AppId: appId } = parsed
if (typeof nonce !== 'string' || nonce === '') return null
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
return { nonce, appId }
}
/** The graph response we care about; everything else in the body is ignored. */
interface NonceValidateResponse {
is_valid?: boolean
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
}
/** One validation round-trip. `retryable` says whether another attempt could differ. */
async function validateOnce(
form: URLSearchParams,
fetcher: typeof fetch
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
let res: Response
try {
res = await fetcher(NONCE_VALIDATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
})
} catch (err) {
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
}
let body: NonceValidateResponse
try {
body = (await res.json()) as NonceValidateResponse
} catch {
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
// verdict — treat it the way a dropped connection is treated.
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
}
if (body.error) {
const { code, message, is_transient } = body.error
return {
ok: false,
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
}
}
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
return { ok: true, retryable: false, reason: '' }
}
/**
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
* was issued to that user for that app.
*
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
* Meta login can be verified, so callers must treat an unset secret as a server
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
* run the retry and response handling without reaching the network.
*/
export async function verifyMetaNonce(
platformAuth: string,
userId: string,
appSecret: string,
fetcher?: typeof fetch
): Promise<MetaVerification> {
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
// The user id is what the nonce is checked against, so an absent or non-numeric one
// can't be verified — reject before spending a round-trip on it.
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
const auth = parseMetaPlatformAuth(platformAuth)
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
// *app*, which is why the secret never leaves the server.
const form = new URLSearchParams({
nonce: auth.nonce,
user_id: userId,
access_token: `OC|${auth.appId}|${appSecret}`,
})
// Resolved per call, not at module load, so a test's stubbed global is honoured.
const doFetch = fetcher ?? globalThis.fetch
let last = { ok: false, retryable: false, reason: 'not attempted' }
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
last = await validateOnce(form, doFetch)
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
if (!last.retryable || attempt === MAX_ATTEMPTS) break
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
}
return { ok: false, reason: last.reason }
}
+15 -26
View File
@@ -69,33 +69,21 @@ export const PlatformType = {
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
* see the platform-auth notes on `POST /connect/token`.
* A PlatformType by value. Only Steam can actually be verified — see the
* platform-auth notes on `POST /connect/token`.
*/
export const PlatformTypeSchema = z
.union([
z.literal(-1),
z
.int()
.min(0)
.max(Math.max(...Object.values(PlatformType))),
])
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
.describe(
Object.entries(PlatformType)
.map(([name, value]) => `${value} ${name}`)
.join(', ')
)
/**
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
* identity LINK, not an account. An account linked to two platforms yields one entry in
* each of their pickers, each reporting the identity that picker was asked about.
*/
/** One entry on the client's login screen, from `toCachedLogin`. */
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z
.string()
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z
@@ -103,6 +91,14 @@ export const CachedLogin = z.object({
.describe('Always false — platform ownership is the credential for a cached login'),
})
/**
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
* is true — nothing proves platform ownership, so the client has to prompt.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
})
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
@@ -143,18 +139,11 @@ export const TokenRequest = z.object({
platform_id: z
.string()
.optional()
.describe(
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
'the id the nonce is validated against, so it must be the real (numeric) user id'
),
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
platform_auth: z
.string()
.optional()
.describe(
'Platform proof, required for cached_login and platform create_account, and used to ' +
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
.describe('Steam session ticket. Required for cached_login and platform create_account'),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
-198
View File
@@ -1,198 +0,0 @@
/**
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0007). One row per (platform, platform id, account): the Steam
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
* 42 as well, and both let that player into that account without a password.
*
* This table replaced the single `platformId`/`platform` pair on the account blob as
* the thing logins are decided from, because that pair could only hold ONE identity —
* a player with a PC and a headset had to pick which device got a cached login. The
* blob fields are kept as the account's *primary* identity (the first one linked) for
* the account DTO and the refresh grant's claims; nothing authorizes off them.
*
* It is deliberately the ONE source of truth for both halves of a cached login: the
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
* identity, and the `cached_login` grant asks this table whether the account it was
* handed is linked to the identity that was proven. When those two disagreed the
* client was offered an account it could never log into — see the regression test.
*
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
* account with no password", so an unproven `platform_id` must never reach it.
*/
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
// primary key's leading columns, so no separate index is needed for it.
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
// resolves ids it has no platform for.
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
]
/**
* The one-time backfill 0007 ran after creating the table: every identity already bound
* to an account became a link, so nobody lost their cached login at deploy. It has run;
* this exists so a test can still exercise it, which is the only coverage that legacy
* blob-bound accounts get a link at all.
*
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
* can carry a platformId with no platform recorded, and back when Steam was the only
* verifiable platform an unset one *was* Steam.
*
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
* against the head schema the tests build. This selects the blob directly instead —
* the same values, since the dropped column was DEFINED as
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
*/
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
json_extract(data, '$.platformId'),
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
platform: number
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
}
interface LinkRow {
accountId: number
platform: number
platformId: string
linkedAt: string
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt FROM platform_account`
/**
* Link a verified platform identity to an account. Idempotent — re-logging in on the
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
* link. Returns true when this created a new link.
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, platform, platformId, new Date().toISOString())
.run()
return res.meta.changes > 0
}
/**
* The accounts a platform identity opens — what the login-screen picker lists.
* Ordered oldest link first so the list is stable between launches (D1 row order
* isn't). Empty id yields nothing rather than matching every link.
*/
export async function getLinksForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results
}
/**
* Links for a bare platform id, whatever platform it belongs to. For the bulk
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
* for the single-id route when the client sends a non-numeric platform.
*/
export async function getLinksForPlatformId(
db: D1Database,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.all<LinkRow>()
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
accountId: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results
}
/**
* Whether this account is linked to this platform identity — the single check the
* `cached_login` grant authorizes on. An account with no link for the presented
* identity cannot be cached-logged-into and must use a password.
*/
export async function isPlatformIdentityLinked(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
)
.bind(accountId, platform, platformId)
.first<{ ok: number }>()
return row !== null
}
/**
* How many accounts one platform identity already opens — the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by
* creating them under the cap and then linking more in.
*/
export async function countAccountsForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
.bind(platform, platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
+57 -384
View File
@@ -8,18 +8,12 @@ import {
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { isLinkedToPlatformIdentity } from '../../auth.app'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -37,28 +31,14 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -69,9 +49,12 @@ beforeAll(async () => {
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`
).run()
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await seedRoomWithSubRooms(env.DB, {
@@ -118,51 +101,6 @@ async function postToken(
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -184,25 +122,32 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -210,11 +155,11 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam and Meta')
expect(res.json.error_description).toContain('only Steam')
}
)
test.each([2, 3, 4, 5, 6, 7, 8])(
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -222,7 +167,7 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam and Meta')
expect(res.json.error_description).toContain('only Steam')
}
)
@@ -246,109 +191,6 @@ describe('auth worker routes', () => {
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const linked = await cachedLogins(1, META_USER_ID)
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account is actually linked to.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
@@ -364,7 +206,6 @@ describe('auth worker routes', () => {
})
)
.run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
@@ -378,69 +219,32 @@ describe('auth worker routes', () => {
])
})
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// The point of the link table. The same account is reachable from the PC and from
// the headset, and each picker reports the identity IT was asked about — that's
// what the client posts back on the cached_login grant.
const steamId = '76561197962463777'
const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
// Steam-linked account can carry a platformId with no platform. The picker offered
// such an account (it treats a missing platform as Steam) while the cached_login
// grant rejected it — "no linked account for this platform identity" forever.
// Both now run the same check.
const steamId = '76561197962463211'
const account = { platformId: steamId } // no `platform` field
// The grant now accepts it — this is what was returning invalid_grant.
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
// The identity is still the credential: another SteamID, an account with no
// platform identity, and an account bound to a different platform are all refused.
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
// And the picker offers exactly the accounts the grant accepts.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run()
// No link row yet: not offered.
const before = await cachedLogins(0, steamId)
expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
expect(offered.map((a) => a.accountId)).toContain(8)
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -777,137 +581,6 @@ describe('auth worker routes', () => {
expect(payload.platform_id).toBe('steam-123')
})
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
@@ -1,155 +0,0 @@
import { describe, expect, test } from 'vitest'
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
// The payload shape a real Meta login posts, captured from a live client. `Source`
// is informational and ignored; the AppId is Rec Room's Meta app.
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
const APP_ID = '1232175103309633'
const USER_ID = '27061366730207360'
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
const APP_SECRET = 'test-app-secret'
/**
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
*/
function stubFetch(bodies: unknown, status = 200) {
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
const calls: Array<{ url: string; form: URLSearchParams }> = []
const fetcher = (async (url: string, init?: { body?: string }) => {
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
const body = queue.length > 1 ? queue.shift() : queue[0]
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
return { fetcher, calls }
}
describe('meta-nonce', () => {
test('parses the platform_auth payload the client posts', () => {
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
})
test.each([
['not json', 'nonsense'],
['no nonce', JSON.stringify({ AppId: APP_ID })],
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
['no app id', JSON.stringify({ Nonce: NONCE })],
// The app id is interpolated into the graph access token, so a non-numeric one
// is refused rather than sent.
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
])('rejects a malformed payload (%s)', (_label, payload) => {
expect(parseMetaPlatformAuth(payload)).toBeNull()
})
test('validates the nonce against the posted user id and returns the identity', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
// The request Meta actually sees: the nonce is bound to THIS user id, and the
// app authenticates itself with `OC|<app id>|<secret>`.
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
expect(calls[0].form.get('nonce')).toBe(NONCE)
expect(calls[0].form.get('user_id')).toBe(USER_ID)
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
})
test('rejects a nonce Meta does not vouch for', async () => {
const { fetcher } = stubFetch({ is_valid: false })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
ok: false,
reason: 'nonce rejected',
})
})
// The whole point of validating against the posted id: a nonce genuinely issued to
// one user does not authenticate another. Meta answers is_valid:false for the
// mismatch, so nobody can log in by naming someone else's Meta user id.
test('a nonce presented for the wrong user id fails', async () => {
const { fetcher, calls } = stubFetch({ is_valid: false })
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
})
test.each([
['missing', ''],
['non-numeric', 'not-an-id'],
])('refuses a %s user id without calling Meta', async (_label, userId) => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(0)
})
test('refuses to attempt verification with no app secret', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
ok: false,
reason: 'no app secret configured',
})
expect(calls).toHaveLength(0)
})
test('surfaces a graph error with its code, for the server log', async () => {
const { fetcher } = stubFetch({
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
})
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({
ok: false,
reason: 'graph error 100: Invalid OAuth access token',
})
})
test('a non-retryable graph error is not retried', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(calls).toHaveLength(1)
})
test('retries a transient graph error and succeeds on a later attempt', async () => {
const { fetcher, calls } = stubFetch([
{ error: { code: 2, message: 'service temporarily unavailable' } },
{ is_valid: true },
])
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(true)
expect(calls).toHaveLength(2)
})
test('gives up after three attempts when Meta stays unavailable', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(3)
})
test('treats a network failure as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
throw new Error('connection reset')
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(attempts).toBe(3)
})
test('treats a non-JSON body (an edge error page) as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
return new Response('<html>502</html>', { status: 502 })
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
expect(attempts).toBe(3)
})
})
-12
View File
@@ -22,23 +22,11 @@
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
//
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
// logins are verified by asking Meta to validate the login nonce, which requires
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
// store or the deploy fails — an operator with no Meta app still has to create
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
}
],
"upload_source_maps": true,
+5 -11
View File
@@ -3,7 +3,6 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import '../../chat.app'
import { NotificationType } from '../../../../notify/src/notification-types'
import {
ChatModerationState,
getMessage,
@@ -722,7 +721,7 @@ describe('ChatMessageReceived push', () => {
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
interface SentNotification {
playerId: number
notificationType: NotificationType
notificationType: number
data: Record<string, unknown>
}
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
@@ -760,9 +759,8 @@ describe('ChatMessageReceived push', () => {
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
// NotificationType.ChatMessageReceived
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
@@ -984,9 +982,7 @@ describe('POST /thread/:id', () => {
it('pushes ChatMessageReceived to every member', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): {
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
takeSent(): Promise<Array<{ playerId: number; notificationType: number }>>
}
}
const caller = 889005
@@ -996,9 +992,7 @@ describe('POST /thread/:id', () => {
await send(caller, `/thread/${chatThreadId}`)
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+9 -4
View File
@@ -15,7 +15,6 @@ import {
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
@@ -84,9 +83,15 @@ const TEST_ROOMS = [
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
)`
).run()
// Subrooms live in their own table now; seed each room and split its subrooms into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
-3
View File
@@ -12,9 +12,6 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
export type Env = SharedHonoEnv & {
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
JWT_SECRET: SecretsStoreSecret
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
// headset login's nonce with Meta (see apps/auth/src/meta-nonce.ts).
META_APP_SECRET: SecretsStoreSecret
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
DB: D1Database
// Image storage bucket (api, img).
+2 -9
View File
@@ -50,20 +50,13 @@
"crons": ["*/5 * * * *"]
},
"logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
// the mounted `auth` app needs to verify Oculus logins. "local" store_id replaced
// with RECFLARE_SECRETS_STORE at deploy. Both must exist in the store or the deploy
// fails — see DEPLOYING.md.
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
// with RECFLARE_SECRETS_STORE at deploy.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
}
],
"upload_source_maps": true,
+1 -14
View File
@@ -64,12 +64,6 @@ export const FORBIDDEN_RESPONSE = {
description: 'A valid token, but not the rooms creator or a co-owner (empty body)',
}
/** The 403 the friends-only routes return (empty body). */
export const NOT_FRIENDS_RESPONSE = {
description:
'A valid token, but the caller is not that player (nor a friend of theirs) (empty body)',
}
// ---- Parameters ------------------------------------------------------------
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
@@ -89,9 +83,6 @@ export const roomIdParam = idParam('roomId', 'Room id')
/** The `:subRoomId` path parameter. */
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
/** The `:playerId` path parameter (an account id). */
export const playerIdParam = idParam('playerId', 'The account whose list to read')
/** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
@@ -132,11 +123,7 @@ export const RoomTagDto = z.object({
Type: z.int().describe('0 = owner-set, 2 = auto'),
})
/**
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
* per-player `interaction` rows on every read; nothing records visits yet, so
* `VisitorCount`/`VisitCount` stay at 0.
*/
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
export const RoomStatsDto = z.object({
CheerCount: z.int(),
FavoriteCount: z.int(),
+6 -56
View File
@@ -4,7 +4,6 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
areFriends,
canManageRoom,
cloneRoom,
cloneSubRoom,
@@ -66,12 +65,10 @@ import {
MissingLookupParam,
ModifySubRoomRequest,
NameRequest,
NOT_FRIENDS_RESPONSE,
PagedRooms,
pageParams,
PhotonAccessTokenDto,
PlayerDataDto,
playerIdParam,
PublishSaveRequest,
RestrictionsRequest,
RoleRequest,
@@ -504,27 +501,19 @@ const app = new Hono<App>()
}
)
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
// instances' presence), then stored engagement, optionally filtered to a single
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
// to 100). Returns `{ Results, TotalResults }` like search.
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }` like search.
.get(
'/rooms/hot',
describeRoute({
tags: ['Discovery'],
summary: 'The “hot” rooms feed',
description: [
'Public, non-dorm rooms ordered by how many players are in them right now — live',
'presence summed across each rooms instances — falling back to stored engagement',
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screens',
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
'(the browse screens filter chips post one, e.g. `rro`).',
].join(' '),
parameters: [
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
...pageParams(100),
],
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
responses: { 200: json(PagedRooms, 'The feed page') },
}),
async (c) => {
@@ -774,45 +763,6 @@ const app = new Hono<App>()
}
)
// Another player's visited rooms — what the client shows on a friend's profile.
// Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that
// player and isn't a mutual friend of theirs is a 403, since where a player has
// been is not public. Registered after `visitedby/me` so the literal path wins.
// Paginated via skip/take (take defaults to 100) and, like `visitedby/me`, a bare
// array — the client's room-source loaders expect a plain list, not a page.
.get(
'/rooms/visitedby/:playerId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'A friends visited rooms',
description: [
'The rooms another player has visited, as a bare array. Friends only: the caller must',
'be that player or a mutual friend of theirs (403 otherwise) — visit history is not',
'public.',
].join(' '),
security: AUTHED,
parameters: [playerIdParam, ...pageParams(100)],
responses: {
200: json(RoomDto.array(), 'That players visited rooms'),
401: UNAUTHORIZED_RESPONSE,
403: NOT_FRIENDS_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
// Your own history is always readable (the client sometimes sends the id
// rather than `me`); anyone else's needs a mutual friendship.
if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
return c.body(null, 403)
}
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, playerId, skip, take))
}
)
// The current player's interaction state with a room (cheered/favorited/last
// visited), read from the `interaction` table. Auth-gated.
.get(
-228
View File
@@ -58,25 +58,6 @@ beforeAll(async () => {
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
// check the caller is a friend of the player whose history they're asking for.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0
)`
).run()
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertRel.bind(791, 790, 3), // friends — the caller (791) is the requester
insertRel.bind(790, 792, 3), // friends — the caller (792) is the target
insertRel.bind(793, 790, 1), // request out, not accepted — 793 is NOT a friend
])
})
describe('rooms endpoints', () => {
@@ -279,62 +260,6 @@ describe('rooms endpoints', () => {
expect(other).toEqual([])
})
it('GET /rooms/visitedby/:playerId serves a friends visited rooms and 403s everyone else', async () => {
// Give 790 a visit history (cheering/favoriting stamps a last-visit).
const subject = await bearer('790')
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
method: 'PUT',
headers: subject,
})
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, {
method: 'PUT',
headers: subject,
})
// A mutual friend reads it — a bare array, regardless of which side of the
// relationship row the caller sits on.
for (const friend of ['791', '792']) {
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
headers: await bearer(friend),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number }>
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
}
// Paginated via skip/take.
const page = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790?skip=0&take=1`, {
headers: await bearer('791'),
})
).json()) as unknown[]
expect(page.length).toBe(1)
// Your own history is readable by id, not just via `me`.
const own = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { headers: subject })
).json()) as unknown[]
expect(own.length).toBe(2)
// A pending request is not a friendship, and a stranger is not either → 403.
for (const outsider of ['793', '794']) {
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
headers: await bearer(outsider),
})
expect(res.status).toBe(403)
}
// No token at all → 401, never a fallback account.
const anon = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`)
expect(anon.status).toBe(401)
// `visitedby/me` still routes to the literal handler, not the id pattern.
const me = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: subject })
).json()) as unknown[]
expect(me.length).toBe(2)
})
it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
expect(res.status).toBe(200)
@@ -368,49 +293,6 @@ describe('rooms endpoints', () => {
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
})
it('GET /rooms/hot ranks rooms by the live presence in their instances', async () => {
const feed = async (): Promise<number[]> =>
(
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
Results: Array<{ RoomId: number }>
}
).Results.map((r) => r.RoomId)
// Two rooms from the tail of the engagement-ordered feed, so any move to the
// front can only come from presence.
const before = await feed()
const busiest = before[before.length - 1]
const quieter = before[before.length - 2]
// Two players in two different instances of `busiest`, one in `quieter`, plus a
// lobby presence (no instance) that must not count for anyone.
const expiresAt = Math.floor(Date.now() / 1000) + 900
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch(
[
{ accountId: 90001, roomInstance: { roomInstanceId: 1000901, roomId: busiest } },
{ accountId: 90002, roomInstance: { roomInstanceId: 1000902, roomId: busiest } },
{ accountId: 90003, roomInstance: { roomInstanceId: 1000903, roomId: quieter } },
{ accountId: 90004, roomInstance: null },
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
)
expect((await feed()).slice(0, 2)).toEqual([busiest, quieter])
// Expired presence doesn't count — the feed falls back to engagement order.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id IN (90001, 90002, 90003, 90004)`
)
.bind(Math.floor(Date.now() / 1000) - 1)
.run()
expect(await feed()).toEqual(before)
await env.DB.prepare(
'DELETE FROM presence WHERE account_id IN (90001, 90002, 90003, 90004)'
).run()
})
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
const aliased = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
@@ -422,66 +304,6 @@ describe('rooms endpoints', () => {
expect(aliased.TotalResults).toBeGreaterThan(0)
})
it('GET /rooms/hot?tag=new serves player-made rooms newest-first (pseudo-tag)', async () => {
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
const feed = async (): Promise<Feed> =>
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
// No room carries a `new` tag, and every seeded room is a Rec Room Original — so
// the feed is empty until a player makes something.
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
const seeded: number[] = []
const seed = async (room: Record<string, unknown>) => {
seeded.push(Number(room.RoomId))
await seedRoomWithSubRooms(env.DB, { Accessibility: 1, IsDorm: false, IsRRO: false, ...room })
}
// Two player-made public rooms and one that isn't public.
await seed({ RoomId: 9001, Name: 'OlderPlayerRoom', CreatedAt: '2026-07-01T00:00:00Z' })
await seed({ RoomId: 9002, Name: 'NewerPlayerRoom', CreatedAt: '2026-07-02T00:00:00Z' })
await seed({
RoomId: 9003,
Name: 'UnlistedPlayerRoom',
CreatedAt: '2026-07-03T00:00:00Z',
Accessibility: 2,
})
// Newest first, and the non-public room is excluded as it is everywhere else.
expect(await feed()).toMatchObject({
Results: [{ Name: 'NewerPlayerRoom' }, { Name: 'OlderPlayerRoom' }],
TotalResults: 2,
})
// An RRO stays out even when it's the newest room in the database — by the flag,
// or by the auto-derived `rro` tag alone.
await seed({
RoomId: 9004,
Name: 'BrandNewRRO',
CreatedAt: '2026-07-04T00:00:00Z',
IsRRO: true,
})
await seed({
RoomId: 9005,
Name: 'TaggedRRO',
CreatedAt: '2026-07-05T00:00:00Z',
Tags: [{ Tag: 'rro', Type: 2 }],
})
expect(await names()).toEqual(['NewerPlayerRoom', 'OlderPlayerRoom'])
// Paging comes off the same order.
const page = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=1&take=1`)
).json()) as Feed
expect(page).toMatchObject({ Results: [{ Name: 'OlderPlayerRoom' }], TotalResults: 2 })
// Leave the shared feeds as they were for the tests that follow.
const ids = seeded.join(',')
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
})
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
expect(res.status).toBe(200)
@@ -1698,55 +1520,6 @@ describe('rooms endpoints', () => {
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
})
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
type Stats = {
CheerCount: number
FavoriteCount: number
VisitorCount: number
VisitCount: number
}
// Room 15 (CrimsonCauldron) is untouched by the other interaction tests.
const searched = async (): Promise<Stats> => {
const body = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=crimsoncauldron`)
).json()) as { Results: Array<{ Stats: Stats }> }
return body.Results[0]!.Stats
}
const direct = async (): Promise<Stats> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/15`)).json()) as { Stats: Stats }).Stats
const interact = async (player: string, action: string, method: string) =>
SELF.fetch(`${ORIGIN}/rooms/15/interactionby/me/${action}`, {
method,
headers: await bearer(player),
})
// Nobody has interacted with it yet.
expect(await searched()).toEqual({
CheerCount: 0,
FavoriteCount: 0,
VisitorCount: 0,
VisitCount: 0,
})
// Two players cheer it; one of them also favorites it.
await interact('561', 'cheer', 'PUT')
await interact('562', 'cheer', 'PUT')
await interact('561', 'favorite', 'PUT')
// Both the search results and the room itself report the aggregate.
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
// Clearing a cheer decrements it. Nothing records visits, so those stay 0.
await interact('562', 'cheer', 'DELETE')
expect(await direct()).toEqual({
CheerCount: 1,
FavoriteCount: 1,
VisitorCount: 0,
VisitCount: 0,
})
})
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
type Interaction = { Cheered: boolean; Favorited: boolean }
const headers = await bearer('557')
@@ -2408,7 +2181,6 @@ describe('rooms endpoints', () => {
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
+1 -47
View File
@@ -25,9 +25,8 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
| 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/login` | auth `POST /connect/token` (account id + password) |
| POST | `/api/logout` | clears the session cookie |
| GET | `/api/me` | accounts `GET /account/me` |
| POST | `/api/email` | accounts `POST /account/me/email` |
@@ -36,51 +35,6 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
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`.
## Development
### Run in dev mode
+57 -379
View File
@@ -1,12 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
DISCORD_INVITE,
DOWNLOAD_URL,
LICENSE_URL,
QUEST_DOWNLOAD_URL,
SOURCE_REPO,
} from '../links'
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
import type { ReactNode } from 'react'
@@ -20,16 +14,6 @@ interface SelfAccount {
isAdmin?: boolean
}
/**
* Site config from the BFF (`/api/config`). `signupEnabled` is false when the operator
* has no Turnstile keypair configured — web signup runs behind that bot check, so
* without it the endpoint is closed and the UI must not offer the form.
*/
interface SiteConfig {
signupEnabled: boolean
turnstileSiteKey: string | null
}
/**
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
* the upstream error message (auth uses `error`/`error_description`, the account
@@ -101,18 +85,12 @@ function Link({
export function App() {
// undefined = still checking the session; null = signed out.
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
// undefined until the config lands. Signup is treated as closed until told otherwise,
// so a slow (or failed) config fetch can't flash a form the server would refuse.
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
const { path, navigate } = useRouter()
useEffect(() => {
api<SelfAccount>('/api/me')
.then((me) => setAccount(me))
.catch(() => setAccount(null))
api<SiteConfig>('/api/config')
.then((c) => setConfig(c))
.catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null }))
}, [])
const logout = useCallback(async () => {
@@ -124,22 +102,12 @@ export function App() {
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' || path === '/signup' ? (
// One page, two doors. `/signup` exists so the homepage's create-account link
// lands on that tab instead of dropping people on sign-in to find it — and so
// the URL is linkable. Unknown paths fall back to index.html (see the assets
// config in wrangler.jsonc), so a cold load of /signup reaches the SPA.
<LoginPage
account={account}
config={config}
initialTab={path === '/signup' ? 'signup' : 'login'}
navigate={navigate}
onAuthed={setAccount}
/>
{path === '/login' ? (
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
) : path === '/account' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
) : (
<HomePage account={account} config={config} navigate={navigate} />
<HomePage />
)}
<SiteFooter />
</>
@@ -237,25 +205,12 @@ function useSlideshow() {
* on top of them. Everything about how the thing is built sits below, for whoever
* scrolls looking for it.
*/
function HomePage({
account,
config,
navigate,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate
}) {
function HomePage() {
const feed = useSlideshow()
// The signup offer only makes sense to a signed-out visitor when the server would
// actually take one. `account === undefined` is still-checking, so it shows nothing
// rather than offering an account to someone who already has one.
const offerSignup = account === null && config?.signupEnabled === true
return (
<main>
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
<Stage slides={feed.slides} />
<div className="shell home">
<About slides={feed.slides} error={feed.error} />
</div>
@@ -264,36 +219,31 @@ function HomePage({
}
/**
* The hero: the headline and the way in on the left, a rotating in-game photo on the
* right. The photo is proof, never the payload — when the feed is slow or down the
* frame holds its space and the left half reads the same, so "Play now!" is reachable
* either way.
* The hero: a rotating in-game photo with the headline and the way in over it. The
* photo is the backdrop, never the payload — when the feed is slow or down the stage
* still renders, so "Play now!" is reachable either way.
*/
function Stage({
slides,
offerSignup,
navigate,
}: {
slides: Slide[] | null
offerSignup: boolean
navigate: Navigate
}) {
function Stage({ slides }: { slides: Slide[] | null }) {
const [idx, setIdx] = useState(0)
const count = slides?.length ?? 0
// A timeout keyed on the current slide rather than one long-lived interval: steering
// by hand re-arms it, so a photo you just picked gets its full six seconds.
useEffect(() => {
if (count < 2) return
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
return () => clearTimeout(t)
}, [count, idx])
if (!slides || slides.length < 2) return
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
return () => clearInterval(t)
}, [slides])
const slide = slides && slides.length > 0 ? slides[idx] : null
const step = (by: number) => setIdx((i) => (i + by + count) % count)
return (
<section className="stage">
{slide && (
<img
className="stage-photo"
key={slide.url}
src={slide.url}
alt={`Photo taken in game by ${slide.username}`}
/>
)}
<div className="stage-body">
{/* Deliberately doesn't name the game: this is a fan project, so the
trademark stays out of the headline and appears lower down, in
@@ -301,90 +251,40 @@ function Stage({
<h1 className="stage-title">
Play like it&apos;s <em>2023</em>.
</h1>
<p className="stage-lede">
The servers you remember, rebuilt and running free, open source, and up right now.
</p>
<div className="stage-actions">
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download for PC
</a>
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download for Quest
</a>
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Join the Discord
</a>
</div>
{/* A line rather than a fourth button: the download is the point of this page,
and launching the game makes an account by itself — signing up here is the
way in for someone who wants one first. Hidden entirely when signup is
closed, matching /login, which hides its create-account tab the same way. */}
{offerSignup && (
<p className="stage-alt">
New here?{' '}
<Link to="/signup" navigate={navigate}>
Create an account
</Link>
</p>
)}
</div>
<div className="stage-show">
<div className="stage-frame">
{slide && (
<img
className="stage-photo"
key={slide.url}
src={slide.url}
alt={`Photo taken in game by ${slide.username}`}
/>
)}
</div>
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
{slide && (
<div className="stage-foot">
{slide && (
<span className="credit">
Photo by @{slide.username}
{slide.roomName && ` in ${slide.roomName}`}
</span>
)}
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT
(130) images, and a dot each is both unusable and wide enough to shove
the headline's half of the split off the page. */}
{count > 1 && (
<span className="steer">
<button onClick={() => step(-1)} aria-label="Previous photo">
<Chevron />
</button>
<span className="count">
{idx + 1} / {count}
</span>
<button onClick={() => step(1)} aria-label="Next photo">
<Chevron next />
</button>
<span className="credit">
Photo by @{slide.username}
{slide.roomName && ` in ${slide.roomName}`}
</span>
{slides && slides.length > 1 && (
<span className="dots">
{slides.map((s, i) => (
<button
key={s.url}
className={i === idx ? 'on' : ''}
onClick={() => setIdx(i)}
aria-label={`Show photo ${i + 1} of ${slides.length}`}
aria-current={i === idx}
/>
))}
</span>
)}
</div>
</div>
)}
</section>
)
}
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
function Chevron({ next }: { next?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** What RecFlare is, under the fold, for whoever wants it. */
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
// The feed answering is proof the server replied, so the indicator can't claim
@@ -397,8 +297,8 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
<p className="about-lede">
A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable {' '}
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
<strong>feature-complete</strong> and infinitely scalable no gatekeeping, no basement
server.
</p>
</div>
<div className="about-side">
@@ -424,85 +324,34 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
)
}
/**
* The sign-in page — sign in, plus create-account when the server says signup is open
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
* session exists, however it was obtained.
*/
/** The sign-in page. Redirects to the account page once a session exists. */
function LoginPage({
account,
config,
initialTab,
navigate,
onAuthed,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
initialTab: 'signup' | 'login'
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
// The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can
// never disagree — switching tabs pushes history, and back goes back to the other one.
const tab = initialTab
useEffect(() => {
if (account) navigate('/account')
}, [account, navigate])
const authed = (a: SelfAccount) => {
onAuthed(a)
navigate('/account')
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
return (
<main className="shell">
<section className="card">
{siteKey && (
<div className="tabs">
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
Sign in
</button>
<button
className={tab === 'signup' ? 'active' : ''}
onClick={() => navigate('/signup')}
>
Create account
</button>
</div>
)}
{siteKey && tab === 'signup' ? (
<>
<h2>Create account</h2>
<p className="muted">
A username is assigned for you you&apos;ll see it on your account page. Choose a
password, and the two together sign you in here and in the game.
</p>
<SignupForm siteKey={siteKey} onAuthed={authed} />
</>
) : (
<>
<h2>Sign in</h2>
<p className="muted">
Use your username and password. Launching the game also creates an account, linked to
your Steam ID set a password on it and it signs in here too.
</p>
<LoginForm onAuthed={authed} />
{/* The tabs above already offer this; the line under the button is where
someone who just found out they have no account is actually looking.
Gated on the same key, so it can't point at a door that isn't there. */}
{siteKey && (
<p className="muted swap">
Don&apos;t have an account?{' '}
<Link to="/signup" navigate={navigate}>
Create one
</Link>
</p>
)}
</>
)}
<h2>Sign in</h2>
<p className="muted">
Launch the game first that creates an account linked to your Steam ID. Once you set a
password, use your username and that password to sign in here.
</p>
<LoginForm
onAuthed={(a) => {
onAuthed(a)
navigate('/account')
}}
/>
</section>
</main>
)
@@ -560,180 +409,9 @@ function useAction() {
return { pending, error, done, run }
}
/**
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
* node_modules and has no types of its own.
*/
interface TurnstileApi {
render: (
el: HTMLElement,
opts: {
sitekey: string
action?: string
callback?: (token: string) => void
'expired-callback'?: () => void
}
) => string | undefined
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
/**
* Load Turnstile's script, once per page, resolving when `window.turnstile` is ready.
* `render=explicit` stops it scanning the document for widgets: this is a SPA, so the
* container mounts and unmounts with the form and we render into it ourselves.
*
* The promise is cached at module scope, so switching tabs back and forth reuses the
* loaded script instead of appending another tag. A rejection is cached too — the retry
* is a page reload, which is what the error message asks for.
*/
let turnstileScript: Promise<void> | null = null
function loadTurnstile(): Promise<void> {
turnstileScript ??= new Promise<void>((resolve, reject) => {
const el = document.createElement('script')
el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
el.async = true
el.defer = true
el.onload = () => resolve()
el.onerror = () => reject(new Error('load failed'))
document.head.appendChild(el)
})
return turnstileScript
}
/**
* Mount a Turnstile widget and hand back the token it produces. No token means no
* submit: the BFF refuses a signup without one, so the form gates its button on it
* rather than letting the request fail.
*
* `reset` re-arms the widget for another attempt — a token is single-use, so a rejected
* signup can't be retried with the same one.
*/
function useTurnstile(siteKey: string) {
const container = useRef<HTMLDivElement | null>(null)
const widgetId = useRef<string | undefined>(undefined)
const [token, setToken] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let live = true
loadTurnstile()
.then(() => {
// StrictMode mounts twice, and the cleanup below removes the first widget; bail
// if this effect is the stale one so we don't render into a detached container.
if (!live || !container.current || !window.turnstile) return
widgetId.current = window.turnstile.render(container.current, {
sitekey: siteKey,
// Marker Cloudflare uses to segment Turnstile integrations; carries no user data.
action: 'turnstile-spin-v1',
callback: (t) => setToken(t),
// Tokens expire after a few minutes; drop ours so the button locks again and
// Turnstile can hand us a fresh one.
'expired-callback': () => setToken(''),
})
})
.catch(() => {
if (live) setError("Couldn't load the bot check — reload the page to try again.")
})
return () => {
live = false
if (widgetId.current) window.turnstile?.remove(widgetId.current)
widgetId.current = undefined
}
}, [siteKey])
const reset = useCallback(() => {
setToken('')
if (widgetId.current) window.turnstile?.reset(widgetId.current)
}, [])
return { container, token, error, reset }
}
/**
* Create an account from the website: a password, plus a Turnstile token proving a human
* filled the form. The username comes back auto-assigned from `auth` (players don't pick
* one), and the session is live on success — so this lands on the account page, where the
* username is shown.
*/
function SignupForm({
siteKey,
onAuthed,
}: {
siteKey: string
onAuthed: (a: SelfAccount) => void
}) {
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const { container, token, error: widgetError, reset } = useTurnstile(siteKey)
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
try {
const { account } = await api<{ account: SelfAccount }>('/api/signup', {
password,
email,
turnstileToken: token,
})
onAuthed(account)
return ''
} catch (err) {
// The token is spent either way, so re-arm the widget before they retry.
reset()
throw err
}
})
}}
>
<label>
Password
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{/* Optional, and the button doesn't wait on it — but it's the only contact detail
an account has, so the hint says plainly what it's for rather than leaving it
to be guessed. `type="email"` gets the right keyboard on mobile and a free
format check; the worker re-checks it before the account is created. */}
<label>
Email <span className="optional">optional</span>
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
/>
<span className="hint">
How you get back in if you forget your password there&apos;s no other way to reach you.
You can add it later on your account page.
</span>
</label>
<div className="turnstile" ref={container} />
{widgetError && <p className="error">{widgetError}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending || token === ''}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
)
}
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
// created via the game/platform, not the website). To bring signups back, restore a
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
+70 -186
View File
@@ -84,7 +84,7 @@ body {
max-width: 880px;
}
/* The homepage: the stage above brings its own top space and shares this width. */
/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */
.shell.home {
max-width: 1040px;
padding-top: 0;
@@ -153,42 +153,19 @@ body {
/* ---- The stage (hero) --------------------------------------------------- */
/*
* Split down the middle: what this is and the way in on the left, a photo somebody
* actually took in game on the right. The photo is proof and never the payload with
* no photo the frame holds its space and the left half carries the same words.
* Full-bleed, edge to edge under the nav: a photo somebody actually took in game,
* with the headline and the way in over it. The photo is the backdrop and never the
* payload with no photo the stage is still a solid panel carrying the same words.
*/
.stage {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 48px;
max-width: 1040px;
margin: 0 auto;
padding: 56px 20px 16px;
}
/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic
minimum is its content's min-content width, so a wide child (the slideshow controls,
a long unbroken credit) grows its column past 50% and takes the space out of the
other one which is how this last read as 30/70 with the buttons crushed. */
.stage-show {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a
frame that resized per photo would jog the headline beside it on every rotation. The
ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside
it equal columns still read unequal when one is half again as tall. */
.stage-frame {
position: relative;
aspect-ratio: 3 / 2;
justify-content: flex-end;
min-height: min(40vh, 320px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-hi);
isolation: isolate;
}
.stage-photo {
@@ -197,6 +174,11 @@ body {
width: 100%;
height: 100%;
object-fit: cover;
z-index: -2;
/* Softened so the headline reads over any screenshot. Scaled up past the frame
because blur samples past the edges and would otherwise feather them. */
filter: blur(5px) saturate(1.08);
transform: scale(1.06);
animation: photo-in 0.7s ease;
}
@@ -206,20 +188,40 @@ body {
}
}
/* Scrim: enough weight at the bottom to hold white text over any screenshot. */
.stage::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(
to top,
rgb(10 7 4 / 90%) 0%,
rgb(10 7 4 / 74%) 40%,
rgb(10 7 4 / 44%) 100%
);
}
.stage-body {
min-width: 0;
max-width: 1040px;
width: 100%;
margin: 0 auto;
padding: 0 20px 28px;
}
/* No width cap here, unlike the headings below: this one shares a row with the photo,
and a headline that stops short of its column makes the split read as 30/70. */
.stage-title {
font-family: var(--display);
font-weight: 800;
font-size: clamp(2rem, 5vw, 3.5rem);
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 1;
letter-spacing: -0.03em;
margin: 0 0 16px;
color: #fff;
margin: 0 0 20px;
max-width: 15ch;
text-wrap: balance;
/* Bloom: a wide, soft shadow rather than a hard one, so it separates the type from
a bright screenshot without reading as a drop shadow. */
text-shadow: 0 2px 30px rgb(8 5 2 / 55%);
}
/* The one place the orange carries meaning in the headline: the year it restores. */
@@ -228,84 +230,55 @@ body {
color: var(--accent);
}
.stage-lede {
font-size: 1.05rem;
color: var(--muted);
margin: 0 0 28px;
}
/* The signup offer under the hero buttons. Deliberately quieter than a CTA it sits
below the downloads without competing with them but the link itself carries the
accent so it reads as the action it is. */
.stage-alt {
font-size: 0.925rem;
color: var(--muted);
margin: 18px 0 0;
}
.stage-alt a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.stage-alt a:hover {
text-decoration: underline;
}
/* Credit line and slideshow controls, under the photo rather than on it. */
/* Credit line and slide dots, sitting under the headline on the photo itself. */
.stage-foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px 20px;
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
min-height: 28px;
gap: 12px 20px;
max-width: 1040px;
width: 100%;
margin: 0 auto;
padding: 0 20px 22px;
font-size: 0.8rem;
color: var(--muted);
color: rgb(255 255 255 / 72%);
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
}
/* Long usernames and room names wrap instead of widening the column. */
.credit {
min-width: 0;
overflow-wrap: anywhere;
}
/* Back / forward, with the position between them. Fixed width whatever the feed
length is see the note on .stage-show for what a per-photo control did here. */
.steer {
/* The dots are the only way to steer the stage, so each one gets a 24px target
even though the mark itself is 7px. */
.dots {
display: flex;
align-items: center;
gap: 4px;
flex: none;
margin: -8px -6px;
}
.steer button {
.dots button {
display: grid;
place-content: center;
width: 28px;
height: 28px;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
border: none;
background: none;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.steer button:hover {
color: var(--text);
border-color: var(--muted);
.dots button::after {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: rgb(255 255 255 / 34%);
transition: background 0.2s ease;
}
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
.count {
font-variant-numeric: tabular-nums;
padding: 0 6px;
.dots button:hover::after {
background: rgb(255 255 255 / 65%);
}
.dots button.on::after {
background: var(--accent);
}
/* ---- What it is (below the stage) --------------------------------------- */
@@ -571,40 +544,6 @@ h2 {
color: var(--muted);
}
/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as
one control rather than as two buttons competing with the orange submit below. */
.tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
}
.tabs button {
flex: 1;
background: transparent;
border: none;
color: var(--muted);
padding: 8px;
border-radius: 6px;
cursor: pointer;
font-family: var(--body);
font-size: 0.9rem;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
background: var(--surface-hi);
color: var(--text);
font-weight: 600;
}
/* ---- Forms -------------------------------------------------------------- */
label {
@@ -633,27 +572,6 @@ textarea {
min-height: 76px;
}
/* Marks a field the form will submit without. Quiet, but next to the label rather than
inside the input, so it survives the field being filled in. */
.optional {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
opacity: 0.7;
margin-left: 4px;
}
/* Why a field is worth filling in, under the input it belongs to. Sits inside the label,
so it's read out with the field rather than as loose text after it. */
.hint {
display: block;
margin-top: 6px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--muted);
}
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
@@ -661,14 +579,6 @@ textarea:focus {
border-color: transparent;
}
/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the
space (the widget is 300×65 at normal size) otherwise the submit button jumps down
the moment the check finishes loading. */
.turnstile {
min-height: 65px;
margin-bottom: 14px;
}
button[type='submit'] {
border: none;
border-radius: 8px;
@@ -692,22 +602,6 @@ button[type='submit']:disabled {
cursor: default;
}
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
the submit button, which hugs its label, so it needs its own separation from it. */
.swap {
margin: 16px 0 0;
}
.swap a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.swap a:hover {
text-decoration: underline;
}
/* ---- Utilities ---------------------------------------------------------- */
.big {
@@ -739,15 +633,6 @@ button[type='submit']:disabled {
/* ---- Responsive --------------------------------------------------------- */
@media (max-width: 860px) {
/* One column: the words lead, the photo follows. */
.stage {
grid-template-columns: 1fr;
gap: 32px;
padding: 40px 20px 8px;
}
}
@media (max-width: 760px) {
/* One column: the copy first, then the links and the status under it. */
.about {
@@ -758,9 +643,8 @@ button[type='submit']:disabled {
}
@media (max-width: 620px) {
.stage-actions .cta {
flex: 1 1 auto;
text-align: center;
.stage {
min-height: min(38vh, 300px);
}
.about-links .cta {
-17
View File
@@ -6,23 +6,6 @@ export type Env = SharedHonoEnv & {
DOMAIN: string
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
ASSETS: Fetcher
/**
* The Turnstile widget's public site key. Public by design it ships to the browser so
* the widget can render but it lives in the Secrets Store beside its secret, so one
* place configures signup and there's a single place to look.
*
* Resolve the value with `await env.TURNSTILE_SITE_KEY.get()`.
*/
TURNSTILE_SITE_KEY: SecretsStoreSecret
/**
* The Turnstile widget's secret key the one that turns a widget token into a verdict.
* Same shared account-level store as JWT_SECRET; the store id is spliced into
* wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
*
* Store values survive a deploy, so both are created once and left alone. Either one
* failing to resolve closes web signup see src/turnstile.ts.
*/
TURNSTILE_SECRET_KEY: SecretsStoreSecret
}
/** Variables can be extended */
-3
View File
@@ -13,9 +13,6 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
/** Where the stage's "Download for PC" button goes: the client's release listing. */
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6w1HPL3j2'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
+5 -8
View File
@@ -23,14 +23,11 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
* something new.
*
* "How you sign in" describes Meta SSO (PlatformType.Oculus), now implemented in
* apps/auth/src/meta-nonce.ts. What that integration actually sends Meta is the login
* nonce plus the user id it is claimed for, and all it gets back is valid/not valid
* so the disclosure's claim that Meta "learns that a sign-in happened" is right, but it
* over-discloses on two points that should be squared with the Data Use Checkup filed
* for the app: we do NOT retrieve a display name (only the user id is stored, see
* accounts-db.ts), and nonce validation does not check app entitlement. Privacy.2 asks
* for extra detail about platform features specifically, so keep this exact.
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
* still stubs see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
* When that lands, check the text still matches what the integration actually requests
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
* the same disclosure has to agree with the Data Use Checkup filed for the app.
*/
/** Last substantive revision, shown in the header. Bump when the text changes. */
+5 -95
View File
@@ -1,27 +1,8 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store
// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It
// stands in for the two account-level secrets a deployed www reads, and it's what OPENS
// signup (see src/turnstile.ts): without it every signup test would test the closed door.
const TEST_SITE_KEY = '1x00000000000000000000AA'
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
beforeAll(async () => {
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
})
it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me')
@@ -29,85 +10,14 @@ it('rejects unauthenticated account reads', async () => {
expect(await res.json()).toEqual({ error: 'not signed in' })
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
it('advertises signup with the Turnstile site key the widget needs', async () => {
const res = await SELF.fetch('https://example.com/api/config')
expect(res.status).toBe(200)
// Read through the Secrets Store binding, from the value seeded above.
expect(await res.json()).toEqual({
signupEnabled: true,
turnstileSiteKey: TEST_SITE_KEY,
})
})
// The keypair is the on/off switch for signup, so a www whose keys don't resolve must
// report it closed — that's the state a fresh deploy starts in, before the operator
// creates the two secrets. Checked directly because the real bindings are seeded for the
// fetch tests above.
//
// A store read that THROWS (secret absent, store unreachable) has to close the door the
// same way rather than surface as an error: /api/config is on the homepage's critical
// path, and a 500 there costs the whole page, not just the signup form.
it('treats an unresolvable or half-configured keypair as signup being off', async () => {
const stub = (value: string | null): SecretsStoreSecret =>
({ get: async () => value ?? '' }) as SecretsStoreSecret
const throws = (): SecretsStoreSecret =>
({
get: async () => {
throw new Error('secret not found')
},
}) as unknown as SecretsStoreSecret
const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) =>
({
ENVIRONMENT: 'development',
TURNSTILE_SITE_KEY: site,
TURNSTILE_SECRET_KEY: secret,
}) as Env
await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull()
await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull()
await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull()
await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull()
await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({
siteKey: '0xsite',
secretKey: '0xsecret',
})
})
it('refuses a signup with no Turnstile token', async () => {
it('refuses manual signups (disabled)', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'whatever' }),
})
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
})
// The email is optional, but a malformed one is rejected BEFORE the account is created —
// the accounts worker would refuse to store it, and by then the account exists and the
// player would be left with an account whose email silently didn't save.
it('refuses a signup whose email could not be stored', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'whatever', email: 'not-an-address', turnstileToken: 'x' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'That email address looks wrong.' })
})
it('refuses a signup with no password', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ turnstileToken: 'dummy' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'A password is required.' })
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
})
it('requires credentials to log in', async () => {
-129
View File
@@ -1,129 +0,0 @@
import { logger } from '@repo/hono-helpers'
import type { Env } from './context'
/**
* Cloudflare Turnstile, the bot check in front of web signup. Two keys make a widget:
* the SITE key, which is public (it ships in the page markup so the browser can render
* the widget), and the SECRET key, which stays on the worker and is the only thing that
* can turn a widget token into a verdict. Both are held in the shared Secrets Store.
*
* The verdict is fetched server-side, here in the BFF never from the browser, which
* would hand the secret to anyone who viewed source. The browser's only job is to carry
* the widget's token to `POST /api/signup`.
*
* Turnstile is what makes web signup safe to leave open: `auth`'s per-IP cap is the only
* other thing standing in front of the password/anonymous account path (it has no
* platform identity to count), and that cap is coarse enough that it can't be the whole
* defence. So the keypair IS the switch no keypair, no signup (see `turnstileKeys`).
* Nothing is ever inferred from the environment, so a worker can't end up with signup
* open and no bot check behind it.
*/
/** Turnstile's verdict endpoint. Called from the worker only; the secret never leaves it. */
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
/**
* The keypair web signup runs on, or null when there isn't one which is what closes
* signup (`/api/config` reports it, `/api/signup` refuses). Both keys come from the
* account-level Secrets Store the whole monorepo shares (see context.ts), so they're
* resolved per request rather than read off `env` as strings.
*
* Both must resolve. Half a configuration (a site key whose secret is missing from the
* store) counts as unconfigured rather than as a widget whose token nobody can check, and
* says so in the log it's otherwise indistinguishable from signup being deliberately
* off. A `.get()` that throws (secret absent from the store, binding not deployed, store
* unreachable) is treated the same way, so a Worker that can't read its keys closes the
* door instead of 500ing on the homepage.
*
* `.get()` caches per isolate, so changing a value in the store needs a `www` redeploy to
* take effect on a warm worker the same caveat the shared JWT_SECRET carries.
*
* For local dev, seed the two names into the LOCAL store (miniflare's, not your account's)
* with Turnstile's documented always-passes test keypair see apps/www/README.md. That
* pair belongs to no account and passes without a human. Deliberately not a built-in
* fallback: the same code path then runs everywhere.
*/
export async function turnstileKeys(
env: Env
): Promise<{ siteKey: string; secretKey: string } | null> {
const [siteKey, secretKey] = await Promise.all([
readSecret(env.TURNSTILE_SITE_KEY, 'TURNSTILE_SITE_KEY'),
readSecret(env.TURNSTILE_SECRET_KEY, 'TURNSTILE_SECRET_KEY'),
])
if (siteKey !== '' && secretKey !== '') return { siteKey, secretKey }
if (siteKey !== '' || secretKey !== '') {
logger.error('turnstile is half-configured, so web signup is closed', {
hasSiteKey: siteKey !== '',
hasSecretKey: secretKey !== '',
})
}
return null
}
/**
* One Secrets Store value as a string, or '' when it can't be read. The binding is
* declared in wrangler.jsonc, so it's always present on `env`; what varies is whether the
* store actually holds the secret a missing one throws here rather than resolving empty.
*/
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
try {
return (await secret.get()) ?? ''
} catch (err) {
logger.error('failed to read a turnstile key from the secrets store', {
secret: name,
error: String(err),
})
return ''
}
}
/** Turnstile's siteverify response, narrowed to the fields we act on. */
interface SiteVerifyResponse {
success?: boolean
'error-codes'?: string[]
}
/**
* Ask Turnstile whether a widget token is good. `remoteIp` is the client's real IP per
* Cloudflare (`CF-Connecting-IP`), which Turnstile cross-checks against the one that
* solved the challenge; it's omitted when absent rather than sent empty.
*
* A token is single-use, so a failed verdict means the widget has to be reset before the
* player can retry the client does that (see the signup form).
*
* Any failure to reach Turnstile is a rejection, not a pass: this is the only bot check
* in front of signup, so a broken verdict path must not open the door.
*/
export async function verifyTurnstile(
secretKey: string,
token: string,
remoteIp?: string
): Promise<boolean> {
const fields: Record<string, string> = { secret: secretKey, response: token }
if (remoteIp) fields.remoteip = remoteIp
try {
const res = await fetch(SITEVERIFY_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
if (!res.ok) {
logger.error('turnstile siteverify failed', { status: res.status })
return false
}
const verdict = (await res.json()) as SiteVerifyResponse
if (verdict.success !== true) {
// The codes name the reason (`invalid-input-response`, `timeout-or-duplicate`,
// `invalid-input-secret`, …) — the last of those is a misconfiguration, not a bot,
// and this log line is the only place it shows up.
logger.info('turnstile rejected a signup', { codes: verdict['error-codes'] ?? [] })
return false
}
return true
} catch (err) {
logger.error('turnstile siteverify threw', { error: String(err) })
return false
}
}
+8 -83
View File
@@ -2,12 +2,11 @@ import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withOnError } from '@repo/hono-helpers'
import { withOnError } from '@repo/hono-helpers'
import { NotificationType } from '../../notify/src/notification-types'
import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { turnstileKeys, verifyTurnstile } from './turnstile'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import type { Context } from 'hono'
@@ -88,12 +87,8 @@ async function relay(c: Context<App>, res: Response) {
* Exchange an auth `/connect/token` response for a session: persist the returned
* access token in the httpOnly cookie, then return the caller's self account
* (fetched from the accounts worker with the fresh token).
*
* `email`, when given, is saved onto the new account before that fetch, so the account
* comes back already carrying it. `create_account` takes no email the accounts worker
* owns that field which is why this is a second call rather than another grant field.
*/
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
async function establishSession(c: Context<App>, tokenResponse: Response) {
if (!tokenResponse.ok) return relay(c, tokenResponse)
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
@@ -108,25 +103,6 @@ async function establishSession(c: Context<App>, tokenResponse: Response, email?
sessionCookieOptions(c, token.expires_in ?? 3600)
)
// Deliberately not fatal: the account exists and the session is live by now, so failing
// the request would leave the player holding an account they think they don't have —
// and a retry would burn another slot against auth's per-IP signup cap. They land on
// the account page instead, where the email field is the same one call away. The
// address is validated before signup starts, so reaching here means something upstream
// went wrong, not that the input was bad.
if (email) {
const res = await postForm(
`${accountsBase(c.env)}/account/me/email`,
{ email },
token.access_token
)
if (!res.ok) {
logger.error('failed to save the signup email; the account was still created', {
status: res.status,
})
}
}
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
headers: { authorization: `Bearer ${token.access_token}` },
})
@@ -150,63 +126,12 @@ const app = new Hono<App>()
// ---- BFF API ------------------------------------------------------------
// What the SPA has to know before it can render the sign-in page: whether web signup
// is open, and the Turnstile site key to mount its widget with. The site key is public
// (it ships in the widget markup either way); the secret never leaves the worker.
// Served rather than baked into the client build so one build works for any operator.
.get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env)
return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null })
})
// Create an account from the website, behind a Turnstile bot check. The check is what
// makes this safe to leave open: `auth` binds no platform identity to a web account, so
// its per-IP cap is the only other thing in front of this path.
//
// Deliberately passes NO `platform`: create_account treats an asserted platform as one
// to verify against Steam and would reject RecNet (see WEB_PLATFORM), so this is the
// platform-less password-account path. The username is auto-assigned by auth — players
// don't pick one — and the new session is established from the token response.
.post('/api/signup', async (c) => {
// No usable keypair means signup is closed rather than unprotected (see turnstile.ts).
const keys = await turnstileKeys(c.env)
if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403)
type SignupBody = { password?: string; email?: string; turnstileToken?: string }
const { password, email, turnstileToken } = await c.req
.json<SignupBody>()
.catch(() => ({}) as SignupBody)
if (!password) return c.json({ error: 'A password is required.' }, 400)
if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400)
// Optional — an account works without one; it's the address a locked-out player
// would be reached at. Checked HERE, before anything is created, because the
// accounts worker rejects an address with no `@` and by then the account exists:
// better to fail the form than to hand back an account whose email silently didn't
// save. Same rule the accounts worker applies, deliberately no stricter — this is
// a contact address, not an identity, and nothing is sent to it to prove it.
const signupEmail = typeof email === 'string' ? email.trim() : ''
if (signupEmail !== '' && !signupEmail.includes('@')) {
return c.json({ error: 'That email address looks wrong.' }, 400)
}
// The IP Turnstile cross-checks the token against — set by the edge, so the client
// can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the
// account's signup IP.
const verified = await verifyTurnstile(
keys.secretKey,
turnstileToken,
c.req.header('cf-connecting-ip')
)
// A token is single-use, so the client resets its widget before letting them retry.
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
const res = await postForm(`${authBase(c.env)}/connect/token`, {
grant_type: 'create_account',
password,
})
return establishSession(c, res, signupEmail || undefined)
})
// Manual web signups are disabled for now — accounts are created via the game /
// platform, not the website. Kept as an explicit closed endpoint (rather than
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
// forward a platform-less `grant_type=create_account` to auth and start a session
// (see git history), and restore the SignupForm in the client.
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
// Log in with a username + password, then start a session. The auth password grant
// resolves the account by `username` (case-insensitive) — web players sign in with
-4
View File
@@ -8,10 +8,6 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// The Turnstile keypair is NOT bound here: both keys come from the Secrets
// Store now, and the tests seed the local store with the test pair (see
// src/test/integration/api.test.ts). A plain binding of the same name would
// shadow the store binding with a string.
},
},
}),
-26
View File
@@ -28,32 +28,6 @@
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
},
// 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 <store-id> --name TURNSTILE_SITE_KEY \
// --scopes workers --remote
// wrangler secrets-store secret create <store-id> --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"
}
],
"upload_source_maps": true,
"observability": {
"logs": {
+51 -22
View File
@@ -11,20 +11,18 @@
* it from `@repo/domain` (each uses the subset it needs).
*/
/**
* Schema DDL the head schema, i.e. what the table looks like after every migration
* (0001_accounts + 0002_avatar, sans seed INSERTs; 0004 added a `platform_id` generated
* column and 0008 dropped it again, so it appears here in neither form).
*/
/** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
avatar TEXT,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`,
`CREATE INDEX IF NOT EXISTS idx_accounts_platform_id ON account (platform_id)`,
]
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
@@ -39,14 +37,12 @@ export interface Account {
identityFlags: number
createdAt: string
/**
* The account's PRIMARY platform identity the first one linked (e.g. a SteamID64
* for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and
* would lose precision as a JS number.
*
* An account can be reachable from SEVERAL platform identities (a PC and a headset),
* and those live in the `auth` worker's `platform_account` table NOT here. Logins
* are authorized against that table alone; this pair is what the account DTO and a
* refreshed token's claims report, and it never gains a second value.
* The platform-native identity linked to this account (e.g. a SteamID64 for
* platform 0). Stored as a STRING on purpose a SteamID64 exceeds 2^53 and
* would lose precision as a JS number. Set at account creation from the login's
* `platform_id`. A cached login is authorized ONLY to the account whose stored
* `platformId` matches the (platform_auth-ticket-proven) platform id presented,
* so no one but that platform user can log into the account.
*/
platformId?: string
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
@@ -220,6 +216,23 @@ export async function searchAccounts(
return parseAll(results)
}
/**
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
* account picker. Backed by the indexed `platform_id` generated column. Empty id
* yields no matches (avoids matching every account whose `platformId` is null).
*/
export async function getAccountsByPlatformId(
db: D1Database,
platformId: string
): Promise<Account[]> {
if (platformId === '') return []
const { results } = await db
.prepare('SELECT data FROM account WHERE platform_id = ?1')
.bind(platformId)
.all<AccountRow>()
return parseAll(results)
}
/**
* Accounts last seen on a given device (the client-supplied `device_id` auth records
* at login). An empty id yields no matches (avoids matching every account with no
@@ -227,9 +240,8 @@ export async function searchAccounts(
*
* Reads `deviceId` straight out of the JSON blob, so this is a table scan no
* generated column, no migration. Fine at our account count and for the occasional
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an indexed
* generated column (migration 0004 did that for `platformId`, and 0008 undid it once
* nothing queried it that pair is the recipe both ways).
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an
* indexed generated column the way `platformId` is (see the 0004 migration).
*
* The device id is unverified client input, so treat a match as a *hint* (these
* accounts share a device) and never as proof of identity.
@@ -246,9 +258,7 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
/** Record the account's most recent successful login time (ISO-8601). */
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
await db
.prepare(
"UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1"
)
.prepare("UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1")
.bind(id, time)
.run()
}
@@ -285,7 +295,9 @@ export async function setLoginContext(
}
if (sets.length === 0) return
await db
.prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`)
.prepare(
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
)
.bind(id, ...binds)
.run()
}
@@ -312,6 +324,21 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
return row?.n ?? 0
}
/**
* How many accounts are linked to a platform-native id (e.g. one SteamID64) the
* count a per-platform signup cap is enforced against. Backed by the indexed
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
* identity aren't attributable to a platform user).
*/
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
.bind(platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return []
@@ -384,7 +411,9 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
/** Persist the account's password hash. Returns false when no such account exists. */
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
const { meta } = await db
.prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1")
.prepare(
"UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
)
.bind(id, hash)
.run()
return meta.changes > 0
-22
View File
@@ -152,28 +152,6 @@ export async function countPlayersInInstance(
return row?.n ?? 0
}
/**
* Live head-count per ROOM, keyed by room id the players standing in any of a
* room's instances right now. One grouped query rather than a count per room, so
* feeds that rank by "who's playing" (the hot feed) stay a single read. Counts
* only unexpired presence; rooms nobody is in are simply absent from the map, and
* lobby (null-instance) presence is excluded.
*/
export async function countPlayersByRoom(
db: D1Database,
now = nowSeconds()
): Promise<Map<number, number>> {
const { results } = await db
.prepare(
`SELECT room_id AS roomId, COUNT(*) AS n FROM presence
WHERE expires_at > ?1 AND room_instance_id IS NOT NULL AND room_id IS NOT NULL
GROUP BY room_id`
)
.bind(now)
.all<{ roomId: number; n: number }>()
return new Map(results.map((r) => [r.roomId, r.n]))
}
/**
* The room instances that expired presence rows still point at the instances a
* player was in when they stopped heartbeating (a crash or a hard quit, where no
+29 -328
View File
@@ -18,7 +18,6 @@
*/
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
@@ -83,26 +82,6 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
data TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
// owner's permission table for one subroom, keyed by (permission, role) — that pair is
// what the client's PUT addresses, and re-sending it overwrites the stored row rather
// than appending a second one.
//
// A row IS an override, which is why the client's `Override` flag is not a column: it's
// the checkbox next to the permission, so clearing it deletes the row and the pair falls
// back to its default. `value` is the client's string, stored verbatim.
//
// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
// verbatim as part of the room, and these overrides are read on one path only
// (`GET /photon_access_token`, where they overwrite the matching default entries).
`CREATE TABLE IF NOT EXISTS subroom_permission (
sub_room_id INTEGER NOT NULL,
permission TEXT NOT NULL,
role INTEGER NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
value TEXT NOT NULL,
PRIMARY KEY (sub_room_id, permission, role)
)`,
]
/** A stored room — the parsed JSON blob (full client-facing room response). */
@@ -178,9 +157,6 @@ export async function cloneRoom(
// client renders a virtual "RRO" tag on the clone.
IsRRO: false,
Roles: roles,
// A fresh room has no engagement of its own — don't inherit the source's counters
// (the derived ones are recomputed per read, but the clone is returned as-is here).
Stats: storedStats(source.Stats),
CreatedAt: new Date().toISOString(),
}
@@ -719,9 +695,6 @@ export async function deleteSubRoom(
// The saves go with it — nothing can reference them once the subroom is gone.
// The blobs they point at are left in R2, like a deleted room's images.
db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
// So do its permission overrides — subroom ids are minted from one global
// sequence, but leaving orphans would still be dead rows nothing can reach.
db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId),
])
const room = await getRoomById(db, roomId)
@@ -787,13 +760,11 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
/**
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job) and
* zeroing the derived engagement counters (those are the `interaction` table's job see
* {@link attachStats}), so a write can't bake a snapshot of them into the blob.
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job).
*/
const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
const { SubRooms: _subRooms, ...rest } = room
return JSON.stringify(rest)
}
/**
@@ -860,108 +831,15 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
}
// ---- Room stats -----------------------------------------------------------
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
// stored: they're recomputed on every read, so a cheer shows up immediately and the
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
// (see {@link serializeRoom}). `VisitorCount`/`VisitCount` are left as the blob has them
// — nothing records a visit yet, and `interaction.last_visited_at` is only stamped by the
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
export interface RoomStats {
CheerCount: number
FavoriteCount: number
}
interface RoomStatsRow {
room_id: number
cheers: number
favorites: number
}
/** The counters a room starts life with (and the shape the client expects). */
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
const STATS_ID_LIMIT = 90
/** A room's RoomId, or 0 for a blob without one. */
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
/**
* The `Stats` object to persist: whatever the room carried, with the derived counters
* back at 0 so the blob never holds a stale copy of them.
*/
function storedStats(stats: unknown): Record<string, unknown> {
const stored =
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0 }
}
/**
* Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
* Restricted to `roomIds` when given (a feed page), otherwise covering every room
* which is also what a page too large to bind gets, since scanning the whole table is
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
*/
export async function getRoomStats(
db: D1Database,
roomIds?: number[]
): Promise<Map<number, RoomStats>> {
const byRoom = new Map<number, RoomStats>()
if (roomIds && roomIds.length === 0) return byRoom
const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
const where =
ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
const { results } = await db
.prepare(
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
FROM interaction ${where} GROUP BY room_id`
)
.bind(...ids)
.all<RoomStatsRow>()
for (const r of results) {
byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
}
return byRoom
}
/**
* Overwrite each room's derived counters from the interaction table, in one query for
* the whole batch. Callers that already aggregated (the feeds rank by these counts, so
* they need them before paging) pass their map in rather than paying for a second query.
*/
async function attachStats(
db: D1Database,
rooms: Room[],
stats?: Map<number, RoomStats>
): Promise<void> {
if (rooms.length === 0) return
const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
for (const room of rooms) {
const counts = byRoom.get(roomIdOf(room))
room.Stats = {
...storedStats(room.Stats),
CheerCount: counts?.CheerCount ?? 0,
FavoriteCount: counts?.FavoriteCount ?? 0,
}
}
}
/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
/** Hydrate a single room's `SubRooms` (no-op for null). */
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
if (room) await hydrateRooms(db, [room])
if (room) await attachSubRooms(db, [room])
return room
}
/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */
async function hydrateRooms(
db: D1Database,
rooms: Room[],
stats?: Map<number, RoomStats>
): Promise<Room[]> {
await Promise.all([attachSubRooms(db, rooms), attachStats(db, rooms, stats)])
/** Hydrate many rooms' `SubRooms` in one batched query. */
async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
await attachSubRooms(db, rooms)
return rooms
}
@@ -1064,119 +942,6 @@ export async function getSubRoomSaveById(
return row ? parseSubRoomSaveRow(row) : null
}
// ---- Subroom permissions --------------------------------------------------
/**
* One entry of a subroom's permission table, in the client's own shape. `Value` is a
* STRING, not a boolean usually `"True"`/`"False"`, but a permission whose UI isn't a
* True/False picker carries something else, so it is stored and served verbatim. `Role`
* is the tier the entry applies to (0 = everyone, 30 = co-owner, ). `Permission` + `Role`
* identify an entry: the client PUTs the pair it wants changed, and the same pair
* overwrites the matching default in the photon access token's table.
*
* `Override` is the row's own existence, not data: the client's UI is a checkbox ("is
* this permission overridden in this subroom?") plus a True/False picker for the value.
* Unchecking it means "fall back to the default", so an entry arriving with
* `Override: false` DELETES the stored row rather than storing anything. Every stored
* entry is therefore an override, and reads always serve `Override: true`.
*/
export interface RoomPermission {
Permission: string
Role: number
Override: boolean
Type: number
Value: string
}
interface RoomPermissionRow {
permission: string
role: number
type: number
value: string
}
const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({
// A stored row IS the override — the table holds nothing else (see RoomPermission).
Override: true,
Permission: row.permission,
Role: row.role,
Type: row.type,
Value: row.value,
})
/** The permission columns, in the order the read/copy statements use. */
const PERMISSION_COLUMNS = 'permission, role, type, value'
/**
* A subroom's stored permission overrides, in the order they were first set. Empty for a
* subroom whose owner has never overridden a permission the photon access token then
* serves its defaults untouched.
*/
export async function getSubRoomPermissions(
db: D1Database,
subRoomId: number
): Promise<RoomPermission[]> {
const { results } = await db
.prepare(
`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
)
.bind(subRoomId)
.all<RoomPermissionRow>()
return results.map(toRoomPermission)
}
/**
* Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs
* supplied are touched; every other stored entry is left alone.
*
* `Override` decides which way an entry goes, mirroring the checkbox the client draws
* next to each permission: true STORES the `Value` for that pair (overwriting whatever
* was there), false CLEARS it, so the pair falls back to the photon access token's
* default. Clearing a pair that was never overridden is a no-op.
*/
export async function setSubRoomPermissions(
db: D1Database,
subRoomId: number,
permissions: RoomPermission[]
): Promise<void> {
if (permissions.length === 0) return
const upsert = db.prepare(
`INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (sub_room_id, permission, role)
DO UPDATE SET type = excluded.type, value = excluded.value`
)
const clear = db.prepare(
'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3'
)
await db.batch(
permissions.map((p) =>
p.Override
? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value)
: clear.bind(subRoomId, p.Permission, p.Role)
)
)
}
/**
* Copy a subroom's permission overrides onto another subroom a clone inherits the
* source's permission table along with its scene and settings. Replaces any entry the
* destination already holds for the same (permission, role).
*/
async function copySubRoomPermissions(
db: D1Database,
fromSubRoomId: number,
toSubRoomId: number
): Promise<void> {
await db
.prepare(
`INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1`
)
.bind(fromSubRoomId, toSubRoomId)
.run()
}
/**
* Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the
* table's autoincrement sequence. Returns the created subroom (with its new id).
@@ -1198,12 +963,6 @@ export async function insertSubRoom(
CurrentSave: null,
StagedSubRoomDataSaveId: null,
}
// The permission overrides follow the copy too — they live in their own table (keyed by
// the id the caller is cloning FROM), so unlike the rest of the settings they aren't
// carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing.
if (typeof sub.SubRoomId === 'number') {
await copySubRoomPermissions(db, sub.SubRoomId, subRoomId)
}
// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
// make the copy's content follow the source's future saves.
@@ -1278,18 +1037,12 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
await db.batch([
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
// Saves and permission overrides first — both are keyed by subroom, so they'd be
// unreachable once the subrooms themselves are gone.
// Saves first — they're keyed by subroom, so they'd be unreachable afterwards.
db
.prepare(
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
)
.bind(roomId),
db
.prepare(
'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
)
.bind(roomId),
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
])
}
@@ -1571,51 +1324,20 @@ export async function searchRooms(
}
}
/**
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map ranking
* happens before hydration, so the room blob's copies are still zero at this point.
*/
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
const counts = stats.get(roomIdOf(room))
const stored = room.Stats as Record<string, unknown> | null | undefined
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
}
/**
* The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
* PSEUDO-tag: no room carries it. It means "recently created by a player", so it
* selects the non-RRO rooms and orders them newest-first instead of by population.
*/
const NEW_TAG = 'new'
/**
* True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
* virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
* carries the tag isn't mistaken for player-made.
*/
function isRRO(room: Room): boolean {
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
}
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
function createdAt(room: Room): number {
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
return Number.isNaN(ts) ? 0 : ts
/** Engagement score used to order the hot feed (cheers weigh most, then favorites). */
function hotScore(room: Room): number {
const stats = room.Stats as Record<string, unknown> | null | undefined
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount)
}
/**
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
* by how many players are in them RIGHT NOW (live presence summed across the
* room's instances), and optionally filtered to a single `tag` (with the same
* aliases as search). "Hot" is a live-population feed, so current players lead;
* rooms nobody is in and the all-zero seed data fall back to the stored
* engagement score, then to RoomId order so paging stays stable. Paginated via
* skip/take; returns `{ Results, TotalResults }` like search. The dataset is
* small, so this filters/sorts in memory rather than in SQL.
*
* `tag=new` is the one filter that isn't a tag lookup see {@link NEW_TAG}.
* by engagement and optionally filtered to a single `tag` (with the same aliases
* as search). Paginated via skip/take; returns `{ Results, TotalResults }` like
* search. Ties (and the all-zero seed data) fall back to RoomId order so paging
* is stable. The dataset is small, so this filters/sorts in memory rather than
* in SQL.
*/
export async function getHotRooms(
db: D1Database,
@@ -1629,34 +1351,15 @@ export async function getHotRooms(
)
const t = tag.trim().toLowerCase()
if (t === NEW_TAG) {
// Newest player-made rooms first; RoomId (which is minted in creation order)
// breaks ties so rooms created in the same instant still page stably.
const fresh = rooms
.filter((r) => !isRRO(r))
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
return {
Results: await hydrateRooms(db, fresh.slice(skip, skip + take)),
TotalResults: fresh.length,
}
}
if (t !== '') {
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
}
const players = await countPlayersByRoom(db)
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
const stats = await getRoomStats(db)
rooms.sort(
(a, b) =>
playerCount(b) - playerCount(a) ||
hotScore(b, stats) - hotScore(a, stats) ||
roomIdOf(a) - roomIdOf(b)
)
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
TotalResults: rooms.length,
}
}
@@ -1675,14 +1378,13 @@ export async function getRecommendedRooms(
take: number
): Promise<Room[]> {
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const stats = await getRoomStats(db)
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
return hydrateRooms(
db,
parseAll(results)
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
.slice(skip, skip + take),
stats
.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
.slice(skip, skip + take)
)
}
@@ -1761,7 +1463,7 @@ export async function getSimilarRooms(
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
const stats = await getRoomStats(db)
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
const scored = parseAll(results)
.filter(
@@ -1777,12 +1479,12 @@ export async function getSimilarRooms(
scored.sort(
(a, b) =>
b.shared - a.shared ||
hotScore(b.room, stats) - hotScore(a.room, stats) ||
hotScore(b.room) - hotScore(a.room) ||
roomIdOf(a.room) - roomIdOf(b.room)
)
const rooms = scored.map((x) => x.room)
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
TotalResults: rooms.length,
}
}
@@ -1797,6 +1499,7 @@ export async function getSimilarRooms(
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const base = new Set(['base'])
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
return hydrateRooms(
db,
parseAll(results)
@@ -1871,8 +1574,6 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
Roles: [
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
],
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
Stats: storedStats(template?.Stats),
CreatedAt: new Date().toISOString(),
}
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
+6 -53
View File
@@ -48,61 +48,16 @@ CONFIG="wrangler.jsonc"
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
# so deploying the source config fails with "assets ... missing the required
# directory property". Prefer the generated config when it exists.
#
# The plugin copies the bindings across verbatim, placeholders and all, so these
# configs need the same id-splicing as the rest — www binds the Secrets Store for its
# Turnstile keys. It gets its own branch below because the emitted file is minified
# single-line JSON, which the line-oriented sed/awk passes can't edit correctly.
# directory property". Prefer the generated config when it exists. These workers
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
VITE_CONFIG="dist/$DIR/wrangler.json"
IS_VITE=""
if [ -f "$VITE_CONFIG" ]; then
CONFIG="$VITE_CONFIG"
IS_VITE=1
fi
NEEDS_D1=$(grep -q '"database_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
NEEDS_KV=$(grep -q '"id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
NEEDS_STORE=$(grep -q '"store_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
# Vite-built config: plain JSON, so jq does the splicing structurally (by binding
# name for KV) rather than by line. Written beside the original so its relative
# paths (main, assets.directory) still resolve. Gitignored; removed on exit.
if [ "$CONFIG" = "$VITE_CONFIG" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
GENERATED="dist/$DIR/wrangler.generated.json"
trap 'rm -f "$GENERATED"' EXIT
if [ -n "$NEEDS_D1" ] && [ -z "${RECFLARE_D1:-}" ]; then
echo "error: RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)" >&2
exit 1
fi
if [ -n "$NEEDS_STORE" ] && [ -z "${RECFLARE_SECRETS_STORE:-}" ]; then
echo "error: RECFLARE_SECRETS_STORE is not set — add the secrets store id to .env (see .env.example)" >&2
exit 1
fi
KV_JSON=${RECFLARE_KV:-}
[ -n "$KV_JSON" ] || KV_JSON='{}'
jq \
--arg db "${RECFLARE_D1:-}" \
--arg store "${RECFLARE_SECRETS_STORE:-}" \
--argjson kv "$KV_JSON" '
(.d1_databases // []) |= map(
if .database_id == "local" then .database_id = $db else . end
)
| (.kv_namespaces // []) |= map(
if .id == "local" then
.id = ($kv[.binding] //
error("no KV id for binding [" + .binding + "] in RECFLARE_KV — add it to .env (see .env.example)"))
else . end
)
| (.secrets_store_secrets // []) |= map(
if .store_id == "local" then .store_id = $store else . end
)
' "$VITE_CONFIG" >"$GENERATED" || exit 1
CONFIG="$GENERATED"
fi
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
CONFIG="wrangler.generated.jsonc"
@@ -176,10 +131,8 @@ EXTRA_VARS=$(recflare_vars)
# Vite-built configs set no_bundle (vite already bundled and minified), which is
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
# Keyed on IS_VITE, not on $CONFIG: the splicing above may have swapped $CONFIG for
# the generated copy, which is just as no_bundle as the file it came from.
MINIFY="--minify"
[ -n "$IS_VITE" ] && MINIFY=""
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
# Deploy with wrangler using the extracted values as binding variables
echo "Deploying worker $NAME version $VERSION to $HOST"