43 Commits

Author SHA1 Message Date
Devin Zuczek cc43d57172 Reapply "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 6b7acc9435.
2026-08-05 17:58:11 -04:00
Devin Zuczek 8b804eaa33 add CDN /data/ endpoint 2026-08-05 17:54:07 -04:00
Devin Zuczek 6b7acc9435 Revert "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 93a46871de.
2026-08-05 17:53:42 -04:00
Devin Zuczek 93a46871de the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems) 2026-08-05 17:27:27 -04:00
Devin Zuczek 4111bc49aa limit slideshow to 10 by default 2026-08-05 17:01:45 -04:00
Devin Zuczek f6561f1ec9 adjust "top" window 2026-08-05 15:49:59 -04:00
Devin Zuczek bc96a6245b hot inventions 2026-08-05 15:45:48 -04:00
Devin Zuczek a73dec7c13 extract published field for convenience 2026-08-05 15:39:25 -04:00
Devin Zuczek ae3bef4cc4 real featured inventions 2026-08-05 15:30:50 -04:00
Devin Zuczek 1f615bab4f fix incorrect balance push notification 2026-08-05 15:27:13 -04:00
Devin Zuczek a986d012f5 invention purchase, at least, how I think they should work 2026-08-05 14:58:55 -04:00
Devin Zuczek b82a5e1dc0 add some basic validation 2026-08-05 14:00:31 -04:00
Devin Zuczek 6bfd4d9e50 validation in some areas, maybe move this to schema later 2026-08-05 12:08:04 -04:00
Devin Zuczek 079c889ccb remove proxies and use native services w/cors 2026-08-05 11:13:15 -04:00
Devin Zuczek 9f4ce07aca track owned inventions 2026-08-04 22:33:47 -04:00
Devin Zuczek dfb1e9ab21 put back stubbed meta auth for sideloaded apks 2026-08-04 21:13:33 -04:00
Devin Zuczek 1d08ed8296 fix too many accounts error because the worker was behind a Cloudflare call, add username change 2026-08-04 21:04:00 -04:00
Devin Zuczek f3e2ab422c add friendly message 2026-08-04 20:10:16 -04:00
Devin Zuczek aa304dbede (wip) attendees 2026-08-04 18:54:40 -04:00
Devin Zuczek 10eb89ac12 (wip) events 2026-08-04 18:44:11 -04:00
Devin Zuczek 65611c15d8 fix custom loading screens, add private endpoint for match into instance 2026-08-04 17:50:21 -04:00
Devin Zuczek d6a0e3e6a6 add a few admin endpoints for matching into instances 2026-08-04 17:49:53 -04:00
Devin Zuczek 7d300fa836 update shape of room instance browser 2026-08-04 17:19:03 -04:00
Devin Zuczek 73bb7c4609 implement room ban 2026-08-04 15:41:49 -04:00
Devin Zuczek dbc6d15ef5 fix issue where polaroids come in with a different URL, pull from other bucket 2026-08-04 14:08:39 -04:00
Devin Zuczek db003d54ef add a non working banlist 2026-08-04 13:26:12 -04:00
Devin Zuczek 05b56e698e add room invites 2026-08-04 12:45:11 -04:00
Devin Zuczek dee7497fe5 add mutual friends endpoint 2026-08-04 12:26:44 -04:00
Devin Zuczek 12f6d7ab61 add warning table (maybe this is just a notification, later) 2026-08-04 12:06:09 -04:00
Devin Zuczek 8d1539de03 add player reports 2026-08-04 11:42:53 -04:00
Devin Zuczek 7e0c26a100 update quest invite 2026-08-04 09:47:16 -04:00
Devin Zuczek f94877347c free invention purchase 2026-08-04 02:01:51 -04:00
Devin Zuczek 23bc159c28 apps
fix birthday which causes various issues
2026-08-04 01:19:51 -04:00
devin 70df3cb6cb Add build status badge to README
Added a badge for the main branch build status.
2026-08-04 00:01:47 -04:00
Devin Zuczek 03b1c59f0d fix 2 test failures due to chat type notification 2026-08-03 22:56:04 -04:00
Devin Zuczek d298977790 drop old platform ID column 2026-08-03 22:48:57 -04:00
devin 821bf54b9b Update deployment requirements and instructions
Emphasize the importance of prerequisites for RecFlare deployment and clarify usage of `just` for installation.
2026-08-03 21:56:42 -04:00
Devin Zuczek 108b061019 increase cardinality of platform accounts 2026-08-03 20:32:23 -04:00
Devin Zuczek af2a2a0683 fix a couple room routes 2026-08-03 19:43:38 -04:00
devin 339a91735b Add account signup and turnstile, subroom perms (#24)
* turnstile

* require turnstile

* homepage refresh

* implemented rooms visited endpoint for friends

* update default profile pic

* add a meta download button

* add subroom permissions

* enable signup
2026-08-03 15:20:10 -04:00
Devin Zuczek a46f6db9d7 meta platform login 2026-08-02 01:57:01 -04:00
Devin Zuczek b3f1d04823 add invention blob hash which does not seem to help 2026-08-01 12:15:56 -04:00
Devin Zuczek 55cb769de9 add github workflow to test 2026-07-31 14:03:35 -04:00
98 changed files with 10657 additions and 1220 deletions
+13
View File
@@ -64,3 +64,16 @@ 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.
+43
View File
@@ -0,0 +1,43 @@
name: Test
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Regression tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Install just
uses: extractions/setup-just@v3
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: just install
- name: Test
run: just test
+5
View File
@@ -70,6 +70,11 @@ inconsistency here without checking the client first.
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
image names — a bare string array fails the client's parser ("expected '{'"). The list
is packed: removing an image shifts the rest up, never leaving a blank slot.
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
client's parser wants one — but the client renders only the FIRST entry and only ever
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
sits unreachable behind the old one and setting a load screen looks like it did
nothing. Keep the array shape for eventual multi-screen support.
- Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope.
+83 -6
View File
@@ -43,10 +43,12 @@ services but would require small code changes.
## Prerequisites
- node (modern)
- pnpm
- bun
- jq/awk/sed
**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.)
- 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
@@ -65,6 +67,8 @@ 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:
@@ -77,11 +81,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.
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
**Create the storage resources:**
The workers bind Cloudflare storage primitive. Create them once against your
The workers bind Cloudflare storage primitives. 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
@@ -104,6 +108,24 @@ 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
@@ -199,6 +221,61 @@ 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.
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
`auth` would then record one shared `signupIp` for every web account and cap the whole
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
(the binding refuses to resolve otherwise), and web accounts created before this change
carry that shared address as their permanent `signupIp` — harmless, but they are not
counted against any real network.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+2
View File
@@ -2,6 +2,8 @@
<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 —
+57 -28
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -11,7 +11,7 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
@@ -69,12 +69,18 @@ function unauthorized(c: Context<App>) {
const DEFAULT_USERNAME_CHANGES = 1
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
* and `value` is an empty string.
* Username-change result envelope: `{ success, error, value }`. On success `value` is
* the updated account; on a refusal `error` carries the message and `value` is an empty
* string.
*
* A refusal is a 400. The body shape is unchanged — anything reading `error` still
* works — but it used to come back at HTTP 200, which meant a caller keying off the
* status read every refusal as a success. That envelope-at-200 was the reference's
* (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a
* status a client can actually branch on.
*/
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
return c.json({ success: error === '', error, value })
return c.json({ success: error === '', error, value }, error === '' ? 200 : 400)
}
/** Read a single string field from a form-urlencoded / multipart body. */
@@ -112,7 +118,9 @@ function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
birthday: 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',
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
@@ -159,6 +167,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -409,18 +425,20 @@ const app = new Hono<App>()
summary: 'Set display name',
description: 'Persisted and broadcast via an AccountUpdate notification.',
security: AUTHED,
requestBody: form(DisplayNameRequest, 'The new display name'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty display name (empty body)' },
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// An EMPTY 400, which is what this route already answered for an empty name: it
// acks with a bare SuccessResponse and has never sent the client a body on
// failure, so enforcing the schema doesn't change what a refusal looks like.
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400)
const { displayName } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
@@ -436,23 +454,34 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Rejects a name taken by another account and requires a remaining change; on',
'success the name is persisted and the counter decremented. Always HTTP 200 —',
'failures carry a message in `error` (see the UsernameResult envelope).',
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
].join(' '),
security: AUTHED,
requestBody: form(UsernameRequest, 'The desired username'),
responses: {
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
200: json(UsernameResult, 'The updated account, in the result envelope'),
400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'),
401: UNAUTHORIZED_RESPONSE,
},
}),
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
// — the part that matters — can never spend one of the account's rationed changes.
// The message is relayed rather than zod's issue array: `nameRejection` writes the
// sentence the player reads, and nothing can render an array of issues.
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
// Standard Schema's flat issue list rather than a zod error object.
validator('form', UsernameRequest, (r, c: Context<App>) =>
r.success
? undefined
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const username = (await formField(c, 'username')).trim()
if (username === '') return usernameResult(c, 'You must enter a username.')
const { username } = c.req.valid('form')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
@@ -484,18 +513,17 @@ const app = new Hono<App>()
summary: 'Set email',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
security: AUTHED,
requestBody: form(EmailRequest, 'The new email'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Email without an “@” (empty body)' },
400: { description: 'Not a syntactically valid address (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const email = (await formField(c, 'email')).trim()
if (!email.includes('@')) return c.body(null, 400)
const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
@@ -509,18 +537,17 @@ const app = new Hono<App>()
summary: 'Set phone number',
description: 'Persisted on the account row. Not broadcast.',
security: AUTHED,
requestBody: form(PhoneRequest, 'The new phone number'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty phone (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const phone = (await formField(c, 'phone')).trim()
if (phone === '') return c.body(null, 400)
const { phone } = c.req.valid('form')
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
}
@@ -595,18 +622,20 @@ const app = new Hono<App>()
describeRoute({
tags: ['Profile'],
summary: 'Set bio',
description: 'Free text; empty is allowed. Persisted and broadcast.',
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
security: AUTHED,
requestBody: form(BioRequest, 'The new bio'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Bio over 255 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// Refused rather than truncated: silently storing half a sentence reads as data loss.
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio')
const { bio } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
+65 -14
View File
@@ -1,20 +1,35 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
MAX_USERNAME_LENGTH,
nameRejection,
} from '@repo/domain'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the accounts worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
* missing or malformed input falls through to a graceful path (or a synthesized default
* account) rather than a hard error. A schema that rejected what the client actually
* sends would break the game, not protect it.
*
* As with the auth worker, this is deliberate. The Rec Room client is the only real
* consumer and the handlers are intentionally lenient — form fields are read as
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
* to a graceful path (or a synthesized default account) rather than a hard error.
* These schemas record what the client is observed to send and what we send back; to
* enforce one, do it per-route and land a test with it.
* The EXCEPTION is the profile mutations a player types into a box — displayName,
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
* version of this note prescribed. Wiring one up means the schema both validates the
* request and generates the spec, so a limit can't be changed in one and not the other —
* which is precisely how the documented email limit came to disagree with the real one.
*
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
* validator registers the body itself, and declaring it twice would emit it twice.
*/
/** Emit a zod schema as an `application/json` response body. */
@@ -122,21 +137,54 @@ export const CreateAccountRequest = z.object({
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
})
/** Single-string form bodies, one per profile mutation. */
/**
* Single-string form bodies, one per profile mutation.
*
* These are ENFORCED, not just described: each is handed to hono-openapi's `validator`,
* so the same schema both validates the request and generates the spec. Before this they
* were documentation only, and the real rule lived in the handler — which meant every
* limit had to be edited in two places and nothing caught them disagreeing.
*
* The rules themselves come from `@repo/domain` so `rooms` and `clubs` can't drift from
* `accounts`; `superRefine` is used where the message matters, because `nameRejection`
* writes the player-facing sentence and there's no reason to write it twice.
*/
/** Zod check that defers to the shared name rule, message and all. */
const nameCheck = (label: string, max: number) =>
z.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
})
export const DisplayNameRequest = z.object({
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
})
export const UsernameRequest = z.object({
username: z.string().describe('Trimmed; must be unique and changes must remain'),
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
email: z.string().describe('Must contain "@"; otherwise 400'),
email: z
.string()
.trim()
.refine(isValidEmail, 'That email address looks wrong.')
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
})
export const PhoneRequest = z.object({
phone: z.string().describe('Trimmed; empty is rejected (400)'),
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
// rule above would reject outright by eating the leading `+`.
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
})
export const IdentityFlagsRequest = z.object({
@@ -147,7 +195,10 @@ export const PronounsRequest = z.object({
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
export const BioRequest = z.object({
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
+178 -3
View File
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
...form({ username: 'Coach' }),
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
// Business errors are HTTP 200 with the { success, error, value } envelope.
expect(res.status).toBe(200)
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
// to be HTTP 200, which read as a success to anything branching on the status.
expect(res.status).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
expect(body.error).toMatch(/already taken/i)
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
...form({ username: 'coachy' }),
headers,
})
expect(blocked.status).toBe(200)
expect(blocked.status).toBe(400)
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
expect(blockedBody.success).toBe(false)
expect(blockedBody.error).toMatch(/no username changes/i)
@@ -459,4 +460,178 @@ describe('auth-gated endpoints', () => {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
// hono-openapi registers a validated form body under `multipart/form-data` only, and
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
// multipart would tell an integrator to send the one thing nothing here sends.
test('GET /openapi.json documents both form content types on validated routes', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const spec = (await res.json()) as {
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
}
for (const [path, method] of [
['/account/me/email', 'post'],
['/account/me/username', 'put'],
['/account/me/displayname', 'put'],
['/account/me/bio', 'put'],
['/account/me/phone', 'post'],
] as const) {
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
expect(Object.keys(content).sort(), path).toEqual([
'application/x-www-form-urlencoded',
'multipart/form-data',
])
}
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
// Server-generated names go around this deliberately — the seeded "Rec Room" account
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
// check belongs at the request handler, not in the db helpers.
describe('name, email and bio validation', () => {
const authed = async (sub: string) => ({
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
})
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
const headers = await authed('8801')
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
// runs — but still in this route's envelope, because the hook puts it there.
expect(res.status, username).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success, username).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,
// and burning it on a typo would leave the player stuck with a name they never had.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
).json()) as { availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(1)
// 50 is the client's own cap, so a name that long has to be accepted.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'a'.repeat(50) }),
headers,
})
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
// regex only buys more edge cases to get wrong. It enforces the RFC's own
// 254-character maximum, which is why there's no separate length check.
test('POST /account/me/email requires a syntactically valid address', async () => {
const headers = await authed('8803')
const bad = [
'nope', // no @ at all — what this route used to be the only check for
'@example.com', // nothing to deliver to
'someone@', // no domain
'someone@example.', // empty last label
'two words@example.com', // whitespace
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
]
for (const email of bad) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(400)
}
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
// and an undeliverable address costs nothing here.
for (const email of [
'someone@example.com',
'first.last+tag@mail.example.co.uk',
'someone@localhost',
]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(200)
}
})
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
const headers = await authed('8804')
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(255) }),
headers,
})
expect(ok.status).toBe(200)
// Refused rather than truncated — storing half a sentence reads as data loss.
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(256) }),
headers,
})
expect(tooLong.status).toBe(400)
// The refusal changed nothing: the 255-character bio is still what's stored.
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
})
})
// Phone is deliberately NOT held to the name rule above: the client sends E.164
// (`+15552223333`), so a letters-and-digits check would reject every real number by
// eating the leading `+`. Pinned here because this route sits between two that DID just
// get stricter, and the obvious next "cleanup" is to make it match them.
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
...form({ phone: '+15552223333' }),
method: 'POST',
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
// endpoint to check it through.
const row = await env.DB.prepare(
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
).first<{ phone: string }>()
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
+23
View File
@@ -0,0 +1,23 @@
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
-- tables in this shared database), a report is genuinely columnar, so it gets a
-- normal relational table. Owned by the `api` worker; generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One row per submitted report; nothing updates or dedupes them, so the table is
-- an append-only log of what players sent. `reporter_player_id` comes from the
-- caller's bearer token, everything else from the form body.
CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
+21
View File
@@ -0,0 +1,21 @@
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
-- reports are what players submit, warnings are what a moderator hands down. Also
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
-- token (the endpoint is gated on the `moderator` role); everything else comes
-- from the form body. `display_reason` is what the warned player is shown,
-- `moderator_note` is internal.
CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
+25
View File
@@ -0,0 +1,25 @@
-- Player-event storage (scheduled events: a room, a window of time, and the
-- settings the event runs under). Like the image/invention/rooms/accounts tables
-- in this shared database, an event is a single JSON blob in the `data` column,
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
-- browse query filters finished events in SQL on that.
CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
@@ -0,0 +1,23 @@
-- Player-event RSVPs: one row per player per event, recording how they answered
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
-- one is genuinely columnar — like the relationship/report tables — so it's a
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
-- every response. A decline is recorded rather than deleted, so the client can show
-- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace).
--
-- An event's creator gets a Going row at create time — that's why a fresh event's
-- AttendeeCount is 1.
CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
@@ -0,0 +1,21 @@
-- Break the two visibility flags out of the invention JSON blob into queryable
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
-- the per-room list ask for "published and not hidden" — so they move together.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
--
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
-- would need rewriting existing rows), which is what we want anyway: the value stays
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
-- replaces did. This is a rename, not a behaviour change.
--
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
-- invention is published and not hidden), so an index on them would be read past rather
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
ALTER TABLE invention
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
ALTER TABLE invention
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
+14 -4
View File
@@ -2,10 +2,11 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
@@ -39,6 +40,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -48,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', roomRoutes)
@@ -68,9 +78,9 @@ app.get(
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'reputation and the assorted sinks the client hits while loading. Relationships,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'route.',
'player events, reputation and the assorted sinks the client hits while loading.',
'Relationships, inventions, images and player events are D1-backed; several',
'endpoints are still stubs, noted per route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
+3
View File
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here.
IMAGES: R2Bucket
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+614
View File
@@ -0,0 +1,614 @@
/**
* Player-event storage on the shared `recflare` D1 database. Each event is a single
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/invention/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
* clash with the other workers' migrations on the shared database).
*
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
* field set and casing here are exactly what the client parses. Timestamps are
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
*
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
* genuinely columnar (like the relationship/report tables), so it's a normal
* relational table rather than a JSON blob.
*/
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
* seed rows).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
`CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
]
/**
* How a player answered an event invitation — the `Type` on
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
*
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
* declining is recorded rather than deleted so the client can show the player their own
* answer (and so changing your mind is an update, not an insert).
*/
export const EVENT_RESPONSE = {
going: 0,
interested: 1,
cantGo: 2,
} as const
/** The response types, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
/** Whether a number is one of the three response types. */
export function isEventResponseType(value: number): boolean {
return EVENT_RESPONSE_VALUES.includes(value)
}
/** One player's answer to one event. */
export interface EventAttendeeRow {
event_id: number
player_id: number
status: number
responded_at: string
}
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
* the settings the event runs under. Served verbatim by every read endpoint.
*
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
* without pinning a subroom, needn't belong to a club, and has no banner until one is
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
* client sends them — their enums aren't reversed yet, so nothing here interprets
* them beyond the defaults below.
*/
export interface PlayerEvent {
PlayerEventId: number
CreatorPlayerId: number
ImageName: string | null
RoomId: number
SubRoomId: number | null
ClubId: number | null
Name: string
Description: string
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
StartTime: string
EndTime: string
AttendeeCount: number
State: number
Accessibility: number
IsMultiInstance: boolean
SupportMultiInstanceRoomChat: boolean
DefaultBroadcastPermissions: number
CanRequestBroadcastPermissions: number
}
interface EventRow {
data: string
}
/**
* The envelope the create/update writes answer with — the event nested under a status,
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
*
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
* that rides along with the write, and we store no event tags (see the tag-filter
* chips, which are static). The field stays present because the client's parser
* expects it.
*/
export interface PlayerEventResult {
Result: number
TagModifyResult: null
PlayerEvent: PlayerEvent
}
/** Wrap a stored event in the write envelope. */
export function toEventResult(event: PlayerEvent): PlayerEventResult {
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
}
/**
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
* them:
*
* - it is camelCase, where the record and every read endpoint are PascalCase;
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
* empty/null), and drops `State`;
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
* record stores them bare. That asymmetry is the reference server's: its notification
* frames carry the padded form and its event reads don't.
*/
export interface PlayerEventNotification {
tags: Array<{ tag: string; type: number }>
playerEventId: number
creatorPlayerId: number
roomId: number
subRoomId: number | null
clubId: number | null
name: string
description: string
imageName: string
startTime: string
endTime: string
attendeeCount: number
accessibility: number
isMultiInstance: boolean
supportMultiInstanceRoomChat: boolean
defaultBroadcastPermissions: number
canRequestBroadcastPermissions: number
broadcastingRoomInstanceId: number | null
}
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
function toTickPrecision(iso: string): string {
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
if (match === null) return iso
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
}
/**
* Project a stored event into its notification frame. `imageName` becomes an empty
* string rather than null when the event has no banner: the frame carries `""`, and a
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
*/
export function toEventNotification(event: PlayerEvent): PlayerEventNotification {
return {
tags: [],
playerEventId: event.PlayerEventId,
creatorPlayerId: event.CreatorPlayerId,
roomId: event.RoomId,
subRoomId: event.SubRoomId,
clubId: event.ClubId,
name: event.Name,
description: event.Description,
imageName: event.ImageName ?? '',
startTime: toTickPrecision(event.StartTime),
endTime: toTickPrecision(event.EndTime),
attendeeCount: event.AttendeeCount,
accessibility: event.Accessibility,
isMultiInstance: event.IsMultiInstance,
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
broadcastingRoomInstanceId: null,
}
}
/**
* Normalize a timestamp to the form the client sends and reads back —
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
* milliseconds, which the samples never carry, so they're trimmed.
*/
function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/**
* Fields a create or update supplies, camelCased. Every one is optional: create
* defaults what's missing, and update leaves anything absent at its stored value —
* which is why the nullable ids are `number | null` rather than merely absent, so a
* posted `"ClubId": null` can genuinely clear a club.
*/
export interface EventInput {
imageName?: string | null
roomId?: number
subRoomId?: number | null
clubId?: number | null
name?: string
description?: string
startTime?: string
endTime?: string
state?: number
accessibility?: number
isMultiInstance?: boolean
supportMultiInstanceRoomChat?: boolean
defaultBroadcastPermissions?: number
canRequestBroadcastPermissions?: number
}
/** Read a value as an integer, or undefined when absent / not a number. */
function asInt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string') {
const n = Number.parseInt(value, 10)
if (!Number.isNaN(n)) return n
}
return undefined
}
/**
* Parse a posted event body into an {@link EventInput}.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
*/
/**
* Why a parsed event body can't be stored, or `null` when it's fine.
*
* Length only. An event name is a title, not an identifier — "Building a Better Room
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
* names carry would be wrong here. Absent fields are skipped: an update posts only what
* it changes, and create defaults a missing name rather than refusing it.
*
* The name is measured AFTER trimming, matching what create/update actually store.
*/
export function eventInputRejection(input: EventInput): string | null {
const name = input.name?.trim()
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
}
if (
input.description !== undefined &&
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
) {
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
}
return null
}
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
string,
unknown
>
const has = (key: string): boolean => Object.hasOwn(obj, key)
// A nullable id: absent leaves it alone, an explicit null clears it.
const nullableInt = (key: string): number | null | undefined => {
if (!has(key)) return undefined
return obj[key] === null ? null : asInt(obj[key])
}
const time = (key: string): string | undefined => {
const raw = obj[key]
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
const bool = (key: string): boolean | undefined => {
const raw = obj[key]
if (typeof raw === 'boolean') return raw
if (raw === 'true') return true
if (raw === 'false') return false
return undefined
}
// The banner name: same absent/null distinction as the nullable ids.
const nullableString = (key: string): string | null | undefined => {
if (!has(key)) return undefined
if (obj[key] === null) return null
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
}
return {
imageName: nullableString('ImageName'),
roomId: asInt(obj.RoomId),
subRoomId: nullableInt('SubRoomId'),
clubId: nullableInt('ClubId'),
name: typeof obj.Name === 'string' ? obj.Name : undefined,
description: typeof obj.Description === 'string' ? obj.Description : undefined,
startTime: time('StartTime'),
endTime: time('EndTime'),
state: asInt(obj.State),
accessibility: asInt(obj.Accessibility),
isMultiInstance: bool('IsMultiInstance'),
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
}
}
/** How long an event runs when the body names a start but no end. */
const DEFAULT_DURATION_MS = 60 * 60 * 1000
/**
* Insert a new event, returning the stored record.
*
* Lenient about what the body carries, like the other writes here: an event with no
* name or no time window is defaulted rather than rejected, because a rejection the
* client can't render is worse than a placeholder the creator can edit. `State` starts
* at 0 (scheduled). The creator comes from the bearer token, never the body.
*
* The creator is recorded as Going in `event_attendee`, which is what makes
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
* needs a row there for the number to stay right once other players respond.
*/
export async function createEvent(
db: D1Database,
creatorPlayerId: number,
input: EventInput
): Promise<PlayerEvent> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
.first<{ next: number }>()
const now = Date.now()
const startTime = input.startTime ?? eventTime(now)
const event: PlayerEvent = {
PlayerEventId: row?.next ?? 1,
CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0,
SubRoomId: input.subRoomId ?? null,
ClubId: input.clubId ?? null,
Name: input.name?.trim() || 'Untitled Event',
Description: input.description ?? '',
StartTime: startTime,
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
AttendeeCount: 1,
State: input.state ?? 0,
Accessibility: input.accessibility ?? 1,
IsMultiInstance: input.isMultiInstance ?? false,
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
}
await db.batch([
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
])
return event
}
/**
* Record a player's answer to an event, replacing whatever they said before — one row
* per player per event, so changing your mind is an update rather than a second RSVP.
* The event's `AttendeeCount` is recomputed from the table afterwards.
*
* Returns the updated event, or null when there's no such event. Anyone who can see an
* event may respond to it, the creator included (they're already Going from create, and
* nothing stops them declining their own event).
*/
export async function setEventResponse(
db: D1Database,
eventId: number,
playerId: number,
status: number
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
await db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
)
.bind(eventId, playerId, status, eventTime(Date.now()))
.run()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return updated
}
/** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
const row = await db
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
.bind(eventId, EVENT_RESPONSE.going)
.first<{ going: number }>()
return row?.going ?? 0
}
/** One player's answer to one event, or null when they haven't responded. */
export async function getEventResponse(
db: D1Database,
eventId: number,
playerId: number
): Promise<EventAttendeeRow | null> {
return db
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
.bind(eventId, playerId)
.first<EventAttendeeRow>()
}
/** Everyone who answered an event, in the order they responded. Backs a future guest list. */
export async function getEventAttendees(
db: D1Database,
eventId: number
): Promise<EventAttendeeRow[]> {
const { results } = await db
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
.bind(eventId)
.all<EventAttendeeRow>()
return results
}
/** Overwrite an event's stored blob in place. */
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
await db
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(event), event.PlayerEventId)
.run()
}
/**
* Apply an edit to an event. Only the fields the body carried change; everything else
* keeps its stored value, so a partial post can't blank out the rest of the event.
* The id, the creator and the attendee count are not editable — ownership doesn't
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
* there's no such row.
*/
export async function updateEvent(
db: D1Database,
eventId: number,
input: EventInput
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
const updated: PlayerEvent = {
...event,
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
RoomId: input.roomId ?? event.RoomId,
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
Name: input.name?.trim() || event.Name,
Description: input.description ?? event.Description,
StartTime: input.startTime ?? event.StartTime,
EndTime: input.endTime ?? event.EndTime,
State: input.state ?? event.State,
Accessibility: input.accessibility ?? event.Accessibility,
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
SupportMultiInstanceRoomChat:
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions:
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions:
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
}
await writeEvent(db, updated)
return updated
}
/** One event by id, or null when there's no such row. */
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
const row = await db
.prepare('SELECT data FROM event WHERE id = ?1')
.bind(eventId)
.first<EventRow>()
return row ? (JSON.parse(row.data) as PlayerEvent) : null
}
/**
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
* (the client renders them in the order it requested), skipping ids with no row rather
* than leaving a hole. Duplicated ids resolve to the same event.
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
.bind(...ids)
.all<EventRow>()
const byId = new Map<number, PlayerEvent>()
for (const r of results) {
const event = JSON.parse(r.data) as PlayerEvent
byId.set(event.PlayerEventId, event)
}
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
}
/**
* The events a player created — their "my events" list, soonest first. Uses the
* creator_player_id index; the per-player set is small, so ordering is done in memory.
*/
export async function getEventsByCreator(
db: D1Database,
creatorPlayerId: number
): Promise<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
.bind(creatorPlayerId)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
* rather than every event.
*/
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
if (clubIds.length === 0) return []
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
.bind(...clubIds)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events happening right now — started and not yet finished. Backs the "happening
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
* columns, so the whole filter stays in SQL.
*/
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
const at = eventTime(now)
const { results } = await db
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
.bind(at)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/** Soonest start first; ties broken by id so paging is stable. */
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Event search — the browse query on the player-events screen. `query` is matched
* case-insensitively against the name and description, term by term; an empty query
* browses everything upcoming. Paginated via skip/take, soonest first.
*
* Events that have already finished are excluded: this backs a browse screen, where a
* name match on something that ended last month is noise. The per-event history a
* creator wants comes from `getEventsByCreator`, which keeps them.
*/
export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number
): Promise<PlayerEvent[]> {
// end_time is a generated column of an ISO-8601 UTC string, so it compares
// lexicographically — the filter stays in SQL.
const { results } = await db
.prepare('SELECT data FROM event WHERE end_time >= ?1')
.bind(eventTime(Date.now()))
.all<EventRow>()
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
for (const term of terms) {
events = events.filter(
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
)
}
return events.sort(bySoonest).slice(skip, skip + take)
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
* `null` when the request carries no valid token, which callers treat as a 401; an
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
*/
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
+9 -2
View File
@@ -299,8 +299,15 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer {
}
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
/** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 10
/**
* The most a caller can ask the slideshow feed for. The endpoint is public and
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
* scan of the whole image table plus the two batched joins behind it.
*/
export const SLIDESHOW_MAX_LIMIT = 100
/** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage {
+145 -36
View File
@@ -1,8 +1,8 @@
/**
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/rooms/accounts tables use.
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
* the same JSON-blob pattern the image/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
* applied under its own `migrations_table`). The invention's data file itself is
@@ -12,19 +12,29 @@
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
* the nested `CurrentVersion` that carries the blob name and per-version costs —
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
* of a JSON `true` is 1, so the column is 1/0.
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
@@ -130,6 +140,37 @@ function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
}
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
function toBase64(bytes: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
}
/**
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
* upload time, so this is normally a HEAD with no body transfer; a blob stored
* before that (or by anything else) is downloaded and digested instead.
*
* Null when the blob isn't in the bucket — a metadata-only save names a file that
* was never uploaded, and a hash of nothing would be worse than the absent hash the
* field already allows for.
*/
export async function inventionBlobHash(
bucket: R2Bucket,
blobName: string
): Promise<string | null> {
const key = `invention/${inventionBlobName(blobName)}`
const head = await bucket.head(key)
if (head === null) return null
const recorded = head.checksums.sha256
if (recorded !== undefined) return toBase64(recorded)
const object = await bucket.get(key)
return object === null
? null
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
}
/**
* Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no
@@ -163,6 +204,7 @@ export interface NewInvention {
*/
export async function createInvention(
db: D1Database,
bucket: R2Bucket,
input: NewInvention
): Promise<SavedInvention> {
// Sequential id: one past the current max (the table starts empty).
@@ -171,6 +213,7 @@ export async function createInvention(
.first<{ next: number }>()
const inventionId = row?.next ?? 1
const now = new Date().toISOString()
const blobName = inventionBlobName(input.inventionDataFilename)
const invention: SavedInvention = {
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
@@ -183,8 +226,8 @@ export async function createInvention(
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
VersionNumber: 1,
BlobName: inventionBlobName(input.inventionDataFilename),
BlobHash: null,
BlobName: blobName,
BlobHash: await inventionBlobHash(bucket, blobName),
InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0,
ChipsCost: input.chipsCost ?? 0,
@@ -232,6 +275,32 @@ export async function getInventionsByCreator(
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
}
/**
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
* everything they BOUGHT. Ownership of a bought invention lives in the
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
* disjoint in practice and merged by id anyway.
*
* Bought inventions are returned whatever their state: unpublished or hidden since the
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
])
const bought = await getInventionsByIds(db, ownedIds)
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
@@ -271,50 +340,73 @@ export async function searchInventions(
* ones via the indexed `is_featured` column.
*/
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
// All three are generated columns off the JSON blob, so the filter stays in SQL.
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0
WHERE is_published = 1
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
/** Length of the "today" window — a trailing day, not the calendar one. */
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
function startOfWindow(): string {
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
* The "top today" feed — the inventions other players picked up in the last 24 hours,
* most first.
*
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
* purchase time, grouped by invention, rather than from the lifetime counters on the
* invention itself: those never reset, so "top today" used to mean "top ever" and the
* shelf only changed when something overtook a total built up over months.
*
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
* itself at midnight UTC and slowly refill through the small hours — it always covers a
* full day's worth of activity. It is still genuinely a window: an invention nobody has
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
* nothing while nothing is curated.
*
* An acquired invention that has since been unpublished or hidden drops out: this is a
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
* that filtering, so a hidden invention doesn't leave a hole in a page.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
if (counts.length === 0) return []
// getInventionsByIds answers in the order it is asked, so the ranking survives the
// load; ids with no invention row left (deleted) simply drop out.
const ranked = await getInventionsByIds(
db,
counts.map((c) => c.inventionId)
)
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
}
/**
* The featured feed — published inventions flagged `IsFeatured`, newest first.
* Selected on the indexed `is_featured` column rather than by parsing every public
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
* than handing the client an empty shelf; once inventions are curated it serves them.
* invention.
*
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
* standing in the top feed. It used to fall back, from when no invention could be
* featured at all, but a fallback makes the shelf lie — the client labels these as
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
* has picked anything.
*/
export async function getFeaturedInventions(
db: D1Database,
@@ -322,7 +414,6 @@ export async function getFeaturedInventions(
take: number
): Promise<SavedInvention[]> {
const featured = await publicInventions(db, true)
if (featured.length === 0) return getTopInventions(db, skip, take)
return featured
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
@@ -546,8 +637,8 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0`
AND is_published = 1
AND hide_from_player = 0`
)
.bind(roomId)
.all<InventionRow>()
@@ -568,20 +659,38 @@ export async function getInventionsByRoom(
*/
export async function getInventionVersion(
db: D1Database,
bucket: R2Bucket,
inventionId: number,
versionNumber: number
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
if (invention.CurrentVersionNumber !== versionNumber) return null
// A version saved before its blob finished uploading (or before we hashed on
// save at all) carries no hash. Hash it now and keep the result, so the other
// invention endpoints serve it too and this stays a one-time cost per blob.
// ModifiedAt is deliberately left alone: reading a version is not an edit.
if (invention.CurrentVersion.BlobHash === null) {
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
if (hash !== null) {
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
await storeInvention(db, invention)
}
}
return invention.CurrentVersion
}
/** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
}
/** Write a record back as it stands — for changes that aren't edits (see above). */
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId)
.bind(JSON.stringify(invention), invention.InventionId)
.run()
}
+157 -7
View File
@@ -97,6 +97,16 @@ export const BareString = z.string()
/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })
/**
* The `{ success, error }` envelope the report / warning writes and the message send
* answer with — `error` is an empty string on success, never null, and the rejected
* branches use the same shape so there is only one thing to parse.
*/
export const SuccessErrorEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty string when the call succeeded'),
})
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
@@ -160,9 +170,34 @@ export const RelationshipDto = z.object({
Muted: z.int().describe('0/1 — the callers own flag'),
})
/**
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
* taken from the bearer token.
*/
export const SendMessageRequest = z.object({
ToPlayerId: z.string().describe('Account id of the recipient'),
Type: z
.string()
.optional()
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
/**
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
* A trimmed account card, not a relationship: no relationship type or flags.
*/
export const MutualFriendDto = z.object({
AccountId: z.int(),
Username: z.string(),
DisplayName: z.string(),
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})
// ---- Progression -----------------------------------------------------------
/**
@@ -206,7 +241,10 @@ export const InventionVersionDto = z.object({
ReplicationId: z.string(),
VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage workers bucket'),
BlobHash: z.string().nullable(),
BlobHash: z
.string()
.nullable()
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
@@ -282,8 +320,14 @@ export const InventionPersonalDetails = z.object({
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
InventionId: z.int(),
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
AutoTags: z
.array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
@@ -303,8 +347,14 @@ export const SaveInventionRequest = z.object({
inventionDataFilename: z
.string()
.describe('The blob uploaded through the storage worker; the one required field'),
name: z.string().optional().describe('Defaults to “Untitled”'),
description: z.string().optional(),
name: z
.string()
.optional()
.describe('324 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(),
instantiationCost: z.int().optional(),
lightsCost: z.int().optional(),
@@ -370,10 +420,68 @@ export const KeepsakeConfig = z.object({
SocialXpBoostEnabled: z.boolean(),
})
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
* echoed as the client sends them; their enums aren't reversed yet.
*/
export const PlayerEventDto = z.object({
PlayerEventId: z.int(),
CreatorPlayerId: z.int(),
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
RoomId: z.int(),
SubRoomId: z.int().nullable().describe('Null when the event doesnt pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isnt a clubs'),
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
IsMultiInstance: z.boolean(),
SupportMultiInstanceRoomChat: z.boolean(),
DefaultBroadcastPermissions: z.int(),
CanRequestBroadcastPermissions: z.int(),
})
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z
.null()
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
PlayerEvent: PlayerEventDto,
})
/**
* The JSON body of an event create / update. Every field is optional: create defaults
* what's missing, update leaves anything absent at its stored value. The fields may be
* posted at the top level or nested under `PlayerEvent` — the client posts back the
* same envelope it read — and both forms are accepted. `PlayerEventId`,
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
*/
export const PlayerEventRequest = PlayerEventDto.partial().extend({
PlayerEvent: z
.unknown()
.optional()
.describe('The events fields, if nested rather than posted at the top level'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
PlayerEventId: z.int(),
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
Created: JsonArray,
Responses: JsonArray,
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
Responses: JsonArray.describe(
'Events the caller RSVPd to — always empty; RSVPs are stored, but this fields ' +
'entry shape has not been observed yet'
),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
@@ -408,6 +516,48 @@ export const ModerationBlockDetails = z.object({
TimeoutStartedAt: z.string().nullable(),
})
/**
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
* reporter is NOT in the body — it's taken from the bearer token.
*/
export const CreateReportRequest = z.object({
PlayerIdReported: z.string().describe('Account id of the player being reported'),
ReportCategory: z
.string()
.optional()
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
HeightReporter: z
.string()
.optional()
.describe('Reporters player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported players height in metres'),
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
RoomInstanceType: z
.string()
.optional()
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
* moderator is NOT in the body — it's taken from the bearer token.
*/
export const CreateWarningRequest = z.object({
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
ReportCategory: z
.string()
.optional()
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
DisplayReason: z
.string()
.optional()
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
+42
View File
@@ -155,6 +155,48 @@ export async function getRelationshipsForPlayer(
return results.map((row) => toResponse(row, playerId))
}
/**
* The ids of everyone a player is actually friends with — `Friend` rows only, from
* either side of the pair (the row records one direction, the friendship is mutual).
* Pending requests and `None` rows are excluded, unlike
* {@link getRelationshipsForPlayer}, which reports the whole graph.
*/
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
const { results } = await db
.prepare(
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
FROM relationship
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
)
.bind(playerId, RelationshipType.Friend)
.all<{ id: number }>()
return results.map((r) => r.id)
}
/** How many mutual friends the mutual-friends lookup will return at most. */
export const MUTUAL_FRIENDS_LIMIT = 100
/**
* The ids two players are both friends with — the intersection of their friend lists,
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
*/
export async function getMutualFriendIds(
db: D1Database,
playerId: number,
otherId: number
): Promise<number[]> {
const [mine, theirs] = await Promise.all([
getFriendIds(db, playerId),
getFriendIds(db, otherId),
])
const ours = new Set(theirs)
return mine
.filter((id) => ours.has(id))
.sort((a, b) => a - b)
.slice(0, MUTUAL_FRIENDS_LIMIT)
}
/**
* Persist `type` for the pair, with `requesterId` recorded as the row's
* requester. Inserts a new row or, if one already exists for the pair (either
+103
View File
@@ -0,0 +1,103 @@
/**
* Player-report storage on the shared `recflare` D1 database.
*
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
* accounts / image / invention), a report is genuinely columnar, so it gets a
* normal relational table. Rows are append-only: nothing updates or dedupes a
* report, so the table is a log of exactly what players submitted.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
* still answers "not blocked" unconditionally; this is the record that a future
* moderation flow would read.
*/
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
]
/** A stored report row (snake_case columns, one row per submission). */
export interface ReportRow {
id: number
reporter_player_id: number
reported_player_id: number
report_category: number
details: string | null
/** Player height in metres, as the client measured it at report time. */
height_reporter: number | null
height_reported: number | null
room_id: number | null
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
room_instance_type: string | null
created_at: string
}
/**
* A report as submitted — everything but the reporter (which comes from the bearer
* token) and the timestamp. Only the reported player is required; the client omits
* fields it has no value for (a report raised outside a room carries no `RoomId`),
* so the rest are optional and stored as NULL when absent.
*/
export interface NewReport {
reporterPlayerId: number
reportedPlayerId: number
reportCategory?: number
details?: string | null
heightReporter?: number | null
heightReported?: number | null
roomId?: number | null
roomInstanceType?: string | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
const row = await db
.prepare(
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
RETURNING *`
)
.bind(
input.reporterPlayerId,
input.reportedPlayerId,
input.reportCategory ?? 0,
input.details ?? null,
input.heightReporter ?? null,
input.heightReported ?? null,
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString()
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every report filed against a player, newest first. Backs a future moderation view. */
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<ReportRow>()
return results
}
+111 -35
View File
@@ -1,17 +1,23 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getMyInventions,
getTopInventions,
parsePermissionLevel,
publishInvention,
@@ -330,18 +336,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public.
// Only the current version exists (nothing writes version history yet), so any
// other version number 404s rather than naming a blob that isn't there.
// RRInventionVersion, which carries the blob name the client downloads and the
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
// version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get(
'/api/inventions/v1/version',
describeRoute({
tags: ['Inventions'],
summary: 'One version of an invention',
description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
'Only the current version exists — nothing writes version history yet — so any ' +
'other version number 404s rather than naming a blob that is not there.',
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'),
@@ -358,7 +367,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
const version = await getInventionVersion(
c.env.DB,
c.env.CDN_ASSETS,
inventionId,
versionNumber
)
return version === null ? c.notFound() : c.json(version)
}
)
@@ -379,18 +393,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'A GET that writes — that is what the client sends, with the fields to change as ' +
'query params. Absent params keep their stored value. An empty `description` ' +
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
'invention. Publishing and pricing are separate endpoints.',
'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
'enforces. Publishing and pricing are separate endpoints.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('name', 'New name; empty is ignored'),
stringQuery('description', 'New description; present-but-empty clears it'),
stringQuery('name', '324 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
@@ -408,10 +424,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
name,
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
description,
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
@@ -515,13 +544,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' +
'Every tag in either list must be at most 15 letters (az once lowercased); one ' +
'that isnt fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: {
200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body'),
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
@@ -538,11 +569,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const autoTags = strings(body.AutoTags)
const customTags = strings(body.CustomTags)
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
// than being dropped — a silently missing tag looks to the creator like a tag that
// saved. Checked against the normalized form `setInventionTags` will store, so the
// rejection quotes the tag as it would have been stored, not as it was typed.
// Blanks are skipped, not rejected: the store already drops them, and the client
// pads its list with empties.
for (const raw of [...autoTags, ...customTags]) {
const tag = raw.trim().toLowerCase()
if (tag === '') continue
const rejection = inventionTagRejection(tag)
if (rejection !== null) {
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
}
}
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
autoTags,
customTags
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
}
@@ -573,17 +622,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
'daily counters, so “today” is a label, not a window.',
'Published inventions ranked by how many players acquired them in the last 24 ' +
'hours, counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a window: an invention nobody has picked up ' +
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
'empty list. It trails the clock rather than resetting at midnight.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
@@ -594,16 +647,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
.get(
'/api/inventions/v1/featured',
describeRoute({
tags: ['Inventions'],
summary: 'The featured feed',
description:
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
'curated — so this is never empty just because no one has picked favourites.',
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
'Serves an empty list while nothing is flagged rather than standing in the top ' +
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
}),
@@ -640,16 +694,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
// afterwards: unpublished or hidden since, the buyer paid for it.
// Auth-gated; returns a bare array (empty when the player has neither).
.get(
'/api/inventions/v2/mine',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own inventions',
description:
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
'see. Not paginated.',
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The callers inventions'),
@@ -659,7 +717,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getInventionsByCreator(c.env.DB, id))
return c.json(await getMyInventions(c.env.DB, id))
}
)
@@ -678,14 +736,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Records an inventions metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected.\n\n' +
'name/description is defaulted rather than rejected; a supplied one must be 324 ' +
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
'characters (description).\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -704,11 +767,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
const invention = await createInvention(c.env.DB, {
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
+413
View File
@@ -0,0 +1,413 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
getEventById,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getLiveEvents,
isEventResponseType,
eventInputRejection,
parseEventBody,
searchEvents,
setEventResponse,
toEventNotification,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventDto,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
import type { PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
* creator — what makes the event appear on their own screen without a refetch.
*
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
* must not fail the create. Note the frame carries the camelCase
* {@link toEventNotification} projection, not the PascalCase record the response does.
*/
async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Player events — scheduled events players and clubs host in a room.
*
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
* blob IS the DTO, so every read here serves it verbatim; only the create/update
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
*
* Watch the response shapes: the two club feeds deliberately differ (bare array for
* the multi-club form, paged envelope for the single-club one) and the client chokes
* if they're unified.
*/
export const eventRoutes = new Hono<App>({ strict: false })
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd to. `Created` is served ' +
'from the event table, soonest first.\n\n' +
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isnt known ' +
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
'or the event plus the answer, which is the useful thing to render. Serving the ' +
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
'response is observed.',
security: AUTHED,
responses: {
200: json(PlayerEventsAll, 'The callers created events, and an empty RSVP list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
}
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
// recent-activity data we don't keep, and the client renders no trending row for null.
.get(
'/api/playerevents/v1/tagfilters',
describeRoute({
tags: ['Events'],
summary: 'Player-event filter chips',
description:
'The filter chips on the player-events browse screen — the event categories the ' +
'client offers. Static: the same set regardless of what is stored. ' +
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
'and the client renders no trending row for null.',
security: AUTHED,
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
}
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses.
.get(
'/api/playerevents/v1/clubs',
describeRoute({
tags: ['Events'],
summary: 'Player events across several clubs',
description:
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
'means an empty shelf, not every event.',
parameters: [intQuery('id', 'Repeatable club id')],
responses: { 200: json(PlayerEventDto.array(), 'The clubs events') },
}),
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
)
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get(
'/api/playerevents/v1/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events for one club',
description:
'The same feed for a single club — and this form DOES wrap the events with a ' +
'paging cursor, matching the reference. The cursor is always empty: a clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs events, in a single page') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const events = await getEventsByClubs(c.env.DB, [clubId])
return c.json({ ContinuationToken: '', Events: events })
}
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
'/api/playerevents/v1/searchlive',
describeRoute({
tags: ['Events'],
summary: 'Live player events',
description:
'The "happening now" row on the player-events browse screen: events that have ' +
'started and not yet ended, soonest first. A bare array.',
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
}),
async (c) => c.json(await getLiveEvents(c.env.DB))
)
// Event search — the browse query. Text is matched term by term against name and
// description; finished events are left out (this backs a browse screen).
.get(
'/api/playerevents/v1/search',
describeRoute({
tags: ['Events'],
summary: 'Search player events',
description:
'The browse query on the player-events screen. `query` is matched ' +
'case-insensitively against the event name and description, term by term; an empty ' +
'query browses everything upcoming. Events that have already finished are left ' +
'out — a name match on something that ended last month is noise on a browse ' +
'screen. Soonest first, paginated via skip/take. A bare array.',
parameters: [
stringQuery('query', 'Search text; every term must match the name or description'),
...pageParams(50),
],
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
}
)
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
// holds. Answers in the order asked for; ids with no event are skipped.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id',
description:
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
'in the order the ids were asked for — the client renders them in request order — ' +
'and skips ids with no event rather than leaving a hole, so the result may be ' +
'shorter than the request. A bare array.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
)
// RSVP. One row per player per event, so responding again replaces the previous
// answer rather than stacking up. Note this is the v1 path while create/update are
// v2 — that's how the client calls them.
.post(
'/api/playerevents/v1/respond',
describeRoute({
tags: ['Events'],
summary: 'Answer a player event',
description:
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
'2 Cant go. Responding again replaces the previous answer; there is one row per ' +
'player per event, and a decline is recorded rather than deleted so the client can ' +
'show a player what they said.\n\n' +
'Only Going counts toward the events `AttendeeCount`, which is recomputed from ' +
'the RSVP table on every response. Anyone may respond, the creator included — ' +
'they are already Going from create, and nothing stops them declining their own ' +
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 02, is a 400; an ' +
'unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
const eventId = Number(body.PlayerEventId)
const type = Number(body.Type)
// Both are rejected rather than defaulted: an unrecognized answer stored as
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
}
)
// Create. The creator comes from the bearer token, never the body — posting someone
// else's `CreatorPlayerId` doesn't make it theirs.
.post(
'/api/playerevents/v2',
describeRoute({
tags: ['Events'],
summary: 'Create a player event',
description:
'Schedules a new event. The creator is taken from the bearer token, never the ' +
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
'an hour from now, rather than an error the client cant render.\n\n' +
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
'the bare event the read endpoints serve.\n\n' +
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
'the event in its camelCase notification projection. A hub failure is logged and ' +
'swallowed — the event is already stored by then.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
// The one thing this route isn't lenient about. Everything else here defaults a
// missing or unusable field, but a name or description past the stored length
// can't be defaulted into something sensible — and truncating a player's event
// description silently is worse than refusing it.
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event)
return c.json(toEventResult(event))
}
)
// Update. Creator-only, and a partial body only changes what it carries.
.post(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Update a player event',
description:
'Edits an event the caller created. Only the fields the body carries change; ' +
'everything else keeps its stored value, so a partial post cant blank out the ' +
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
'clear it.\n\n' +
'The id, the creator and the attendee count are not editable: ownership doesnt ' +
'transfer and RSVPs arent set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
if (eventInputRejection(input) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!))
}
)
// A single event. Registered last so the literal `/bulk` and `/search` paths above
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'One player event',
description:
'A single event by id, served as the bare record — no envelope, unlike the ' +
'create/update writes. 404 when there is no such event.',
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventDto, 'The event'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
return event === null ? c.body(null, 404) : c.json(event)
}
)
+2 -84
View File
@@ -6,19 +6,15 @@ import communityBoard from '../../static/community-board.json'
import {
BareString,
idParam,
intQuery,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeConfig,
PlayerEventsAll,
PlayerEventsPage,
SanitizeRequest,
stringParam,
SubscriptionResponse,
TagFilters,
} from '../openapi'
import type { App } from '../context'
@@ -128,86 +124,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Gameplay'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd to. No player-event ' +
'storage yet, so both lists are empty.',
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
}),
(c) => c.json({ Created: [], Responses: [] })
)
// The tag filter chips on the player-events browse screen. Derived from the tags in
// use across events — we store no events, so there are no chips to offer.
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
.get(
'/api/playerevents/v1/tagfilters',
describeRoute({
tags: ['Gameplay'],
summary: 'Player-event filter chips',
description:
'The filter chips on the player-events browse screen, derived from the tags in use ' +
'across events. We store no events, so there are no chips to offer. ' +
'`TrendingFilters` is null even in the reference — it needs recent-activity data.',
responses: { 200: json(TagFilters, 'Empty chip lists') },
}),
(c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
// player-event storage yet, so the feed is empty.
.get(
'/api/playerevents/v1/clubs',
describeRoute({
tags: ['Gameplay'],
summary: 'Player events across several clubs',
description:
'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' +
'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' +
'single-club form below uses. Do not unify the two. No player-event storage yet, ' +
'so the feed is empty.',
parameters: [intQuery('id', 'Repeatable club id')],
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get(
'/api/playerevents/v1/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Gameplay'],
summary: 'Player events for one club',
description:
'The same feed for a single club — and this form DOES wrap the events with a ' +
'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' +
'page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'An empty page') },
}),
(c) => c.json({ ContinuationToken: '', Events: [] })
)
// Live player-event search (the "happening now" browse query). No player-event
// storage yet, so there's nothing live to return — a bare empty array.
.get(
'/api/playerevents/v1/searchlive',
describeRoute({
tags: ['Gameplay'],
summary: 'Search live player events',
description:
'The "happening now" search on the player-events browse screen. No player-event ' +
'storage yet, so there are no live events — returns an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
+18 -1
View File
@@ -13,6 +13,8 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '../images-db'
import {
@@ -312,6 +314,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
// table — and the callers that rotate one photo at a time (the website's hero) don't
// want a long feed anyway.
.get(
'/api/images/v1/slideshow',
describeRoute({
@@ -323,10 +329,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.',
parameters: [
intQuery(
'take',
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
),
],
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}),
async (c) => {
const Images = await getSlideshowImages(c.env.DB)
// Junk, zero and negative takes fall back to the default rather than 400ing or
// serving an empty stage — the caller is a homepage, and no photos reads as the
// server being down.
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
const Images = await getSlideshowImages(c.env.DB, take)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
}
+158
View File
@@ -1,17 +1,62 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
json,
JsonArray,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
* their admin surfaces on: a warning is a moderation action, but staff hold both.
*/
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
/**
* Read one field of a submitted form. The client posts these form-encoded, but the
* same names also arrive as a query string on some builds, so both are accepted.
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
const asFloat = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseFloat(v)
return Number.isNaN(n) ? null : n
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
@@ -69,6 +114,119 @@ export const moderationRoutes = new Hono<App>({ strict: false })
(c) => c.json(false)
)
// The report the client actually submits. Auth-gated: the reporter is taken from
// the bearer token rather than the body, so a report can't be filed as someone else.
.post(
'/api/PlayerReporting/v3/create',
describeRoute({
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table — an append-only log; nothing ' +
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
'“not blocked” unconditionally.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
'Answers the real services `{ success, error }` envelope, where `error` is an ' +
'empty string rather than null. The rejected branch uses the same envelope so ' +
'the client only ever parses one shape.',
security: AUTHED,
requestBody: form(CreateReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
if (reportedPlayerId === null) {
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
}
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
const roomId = asInt(formField(body, c, 'RoomId'))
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
details: formField(body, c, 'Details') ?? null,
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
heightReported: asFloat(formField(body, c, 'HeightReported')),
roomId: roomId !== null && roomId > 0 ? roomId : null,
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
})
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
'/api/playerwarnings',
describeRoute({
tags: ['Moderation'],
summary: 'Issue a player warning',
description:
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
'yet.\n\n' +
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
'per account by the operator, see the admin CLIs `grant-moderator` / ' +
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
'is the caller, NOT a body field.\n\n' +
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
'internal and never surfaced to them.\n\n' +
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
'an empty string rather than null — including on the rejected branches, so there ' +
'is only one shape to parse.',
security: AUTHED,
requestBody: form(CreateWarningRequest, 'The warning'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
if (warnedPlayerId === null) {
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
}
await createWarning(c.env.DB, {
moderatorPlayerId: moderatorId,
warnedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
displayReason: formField(body, c, 'DisplayReason') ?? null,
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
})
return c.json({ success: true, error: '' })
}
)
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
// bearer token and fires before account creation, so there is no caller to attribute
+133 -4
View File
@@ -1,23 +1,33 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getAccountsByIds } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, unauthorized } from '../http'
import {
AckResponse,
AUTHED,
ErrorResponse,
form,
intQuery,
json,
JsonArray,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
acceptFriendRequest,
addFriend,
getMutualFriendIds,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
@@ -34,9 +44,6 @@ import type {
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
const RELATIONSHIP_CHANGED = 1
/**
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
@@ -50,7 +57,7 @@ async function notifyRelationship(
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
RELATIONSHIP_CHANGED,
NotificationType.RelationshipChanged,
{ ...rel }
)
} catch (err) {
@@ -199,6 +206,128 @@ export const socialRoutes = new Hono<App>({ strict: false })
}
)
// The friends the caller and another player have in common. Unlike the other
// relationship routes this answers account cards, not relationships — it's what the
// client shows on someone else's profile.
.get(
'/api/relationships/mutualfriends',
describeRoute({
tags: ['Social'],
summary: 'Friends in common with another player',
description:
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
'pending requests on either side are ignored.\n\n' +
'Answers an empty array rather than an error for the degenerate cases: no target ' +
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
'intersection.\n\n' +
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
'null, when the account has no image.',
security: AUTHED,
parameters: [intQuery('id', 'The other player')],
responses: {
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const raw = c.req.query('id')
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
// themselves. An empty list, not an error — this feeds a profile panel.
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
return c.json(
accounts
.map((a) => ({
AccountId: a.accountId,
Username: a.username,
DisplayName: a.displayName,
ProfileImage: a.profileImage ?? '',
}))
// getAccountsByIds doesn't promise an order; keep the ascending one.
.sort((a, b) => a.AccountId - b.AccountId)
)
}
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. Nothing is stored: the message IS the notification, pushed to the
// recipient's hub connection (and queued by the hub if they're offline).
.post(
'/api/messages/v2/send',
describeRoute({
tags: ['Social'],
summary: 'Send a message to another player',
description:
'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' +
'the same frame the Coach broadcast sends (see the `notify` workers ' +
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
'empty.\n\n' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' +
'silently dropped message.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const fromPlayerId = await authedId(c)
if (fromPlayerId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
if (Number.isNaN(toPlayerId)) {
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const message = {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
Data: str(body.Data) ?? '',
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
message
)
} catch (err) {
logger.error('failed to push MessageReceived notification', {
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// Send a friend request to another player (the target arrives as `?id=`). The
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
+1101 -56
View File
@@ -2,15 +2,31 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import {
GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import '../../api.app'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { SavedImage } from '../../images-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
@@ -44,14 +60,9 @@ 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')
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()
// 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()
// 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()
@@ -81,6 +92,18 @@ beforeAll(async () => {
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -94,10 +117,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -110,6 +136,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
@@ -188,17 +220,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
// No player-event storage → no tags in use → no chips. Trending is null.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
PinnedFilters: [],
PopularFilters: [],
TrendingFilters: null,
})
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
@@ -208,26 +229,6 @@ describe('public endpoints', () => {
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
// The client deserializes this as a bare array — an envelope here fails with
// "expected:'[', actual:'{'". No player-event storage yet → empty.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The single-club form does wrap its events with a paging cursor.
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
})
test('GET /api/playerevents/v1/searchlive returns an empty list', async () => {
// No player-event storage yet → nothing live to return.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/searchlive`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
@@ -425,7 +426,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
@@ -457,6 +458,49 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -488,6 +532,51 @@ describe('public endpoints', () => {
})
})
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 324 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
@@ -567,6 +656,39 @@ describe('public endpoints', () => {
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
@@ -766,6 +888,12 @@ describe('public endpoints', () => {
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -777,7 +905,8 @@ describe('public endpoints', () => {
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads.
// The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
@@ -786,6 +915,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42,
})
@@ -808,6 +938,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -849,6 +1017,16 @@ describe('public endpoints', () => {
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
@@ -960,12 +1138,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
@@ -1003,19 +1184,42 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
// the feed really does forget, rather than accumulating every acquisition ever.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
// Featured: only the flagged, visible inventions — newest first.
// Top: most acquisitions in the window first. Download counts no longer rank anything —
// 202 has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
expect(await ids(featuredPage)).toEqual([202])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
@@ -1047,6 +1251,179 @@ describe('auth-gated endpoints', () => {
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/PlayerReporting/v3/create records the report', async () => {
const res = await submit(
{
PlayerIdReported: '205',
ReportCategory: '100',
Details: 'ya know',
HeightReporter: '1.64',
HeightReported: '1.65',
RoomId: '58',
RoomInstanceType: 'Public',
},
await bearer()
)
expect(res.status).toBe(200)
// `error` is an empty string, not null — the real service's envelope.
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getReportsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The reporter is the token's subject, not a body field.
reporter_player_id: 42,
reported_player_id: 205,
report_category: 100,
details: 'ya know',
height_reporter: 1.64,
height_reported: 1.65,
room_id: 58,
room_instance_type: 'Public',
})
expect(row?.created_at).toBeTruthy()
})
// Everything but the reported player is optional — a report raised outside a room
// carries no RoomId, and 0 means "no room" rather than room zero.
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
expect(res.status).toBe(200)
const [row] = await getReportsAgainst(env.DB, 206)
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 206,
report_category: 0,
details: null,
height_reporter: null,
height_reported: null,
room_id: null,
room_instance_type: null,
})
})
// Append-only: a second report against the same player is a second row.
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
const rows = await getReportsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
})
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
const res = await submit({ PlayerIdReported: '205' })
expect(res.status).toBe(401)
})
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
const res = await submit({ Details: 'ya know' }, await bearer())
expect(res.status).toBe(400)
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
@@ -1154,6 +1531,29 @@ describe('images', () => {
})
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
// 120 public ShareCamera photos — more than both the default and the cap.
for (let i = 0; i < 120; i++) {
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
}
const feed = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
expect(res.status).toBe(200)
return ((await res.json()) as { Images: unknown[] }).Images.length
}
expect(await feed('')).toBe(10)
expect(await feed('?take=25')).toBe(25)
expect(await feed('?take=500')).toBe(100)
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
expect(await feed('?take=0')).toBe(10)
expect(await feed('?take=-5')).toBe(10)
expect(await feed('?take=lots')).toBe(10)
})
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
// Its own player id: 700's photos are asserted on exactly in the player-list test.
@@ -1867,6 +2267,641 @@ describe('relationships', () => {
})
})
describe('messages', () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
playerId: number
notificationType: number
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
await hub().fetch('http://do/all', { method: 'DELETE' })
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
}
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([
{
// Delivered to the recipient, not the sender.
playerId: 2,
notificationType: MESSAGE_RECEIVED,
// FromPlayerId is the token's subject, not a body field.
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
},
])
})
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
Data: '',
})
})
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
const res = await send({ Type: '10' }, await bearer('42'))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v2/send is auth-gated', async () => {
const res = await send({ ToPlayerId: '2' })
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
})
describe('mutual friends', () => {
// High, distinct ids so the friendships seeded here don't collide with the
// relationship tests above.
const CALLER = 800
const OTHER = 801
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
headers: await bearer(sub),
})
beforeAll(async () => {
const rel = (a: number, b: number, type = 3) =>
env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
).bind(a, b, type)
// 804 has no profileImage key at all — the projection must still answer a
// string. 806 is deliberately given no account row.
const account = (id: number, extra: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
)
await env.DB.batch([
account(CALLER, { profileImage: 'p800.jpg' }),
account(OTHER, { profileImage: 'p801.jpg' }),
account(802, { profileImage: 'p802.jpg' }),
account(803, { profileImage: 'p803.jpg' }),
account(804, {}),
// Seeded 804-first so the ascending order of the answer is the code's doing,
// not the insertion order's.
rel(CALLER, 804),
rel(802, CALLER), // friendship recorded from the other direction
rel(CALLER, 803),
rel(CALLER, 806),
rel(OTHER, 804), // shared → in the answer
rel(OTHER, 802), // shared → in the answer
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
rel(OTHER, 806), // shared, but 806 has no account row → dropped
])
})
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
const res = await mutuals(`?id=${OTHER}`)
expect(res.status).toBe(200)
const cards = (await res.json()) as Card[]
// 803 is only a pending request on OTHER's side, and 806 has no account row.
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
expect(cards[0]).toEqual({
AccountId: 802,
Username: 'P802',
DisplayName: 'Player 802',
ProfileImage: 'p802.jpg',
})
// No stored image → an empty string, never null/undefined.
expect(cards[1]?.ProfileImage).toBe('')
})
// The degenerate cases answer an empty list rather than an error — this feeds a
// profile panel, which would otherwise have nothing to render.
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
const res = await mutuals(query)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual([])
}
})
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
// 800, so 800 is what they have in common.
test('GET /api/relationships/mutualfriends works between two other players', async () => {
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
})
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
// 809 has no relationships at all.
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
expect(cards).toEqual([])
})
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`)
expect(res.status).toBe(401)
})
})
describe('player events', () => {
const HOUR = 60 * 60 * 1000
/** Seconds precision, no milliseconds — the form the client sends and reads back. */
const at = (offsetMs: number): string =>
new Date(Date.now() + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
const post = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
const res = await post('/api/playerevents/v2', body, sub)
expect(res.status).toBe(200)
return ((await res.json()) as PlayerEventResult).PlayerEvent
}
const get = async (path: string, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
// The fixture set every test below reads. Times are relative to the run so the
// upcoming/live/finished distinction the browse queries make is real.
let upcoming: PlayerEvent
let clubEvent: PlayerEvent
let liveEvent: PlayerEvent
let pastEvent: PlayerEvent
beforeAll(async () => {
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
upcoming = await create({
PlayerEvent: {
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
},
})
// …and this one at the top level, the other form in circulation.
clubEvent = await create({
RoomId: 23570830,
ClubId: 7,
Name: 'DUNGEONS Escape ROOM',
Description: 'Try and escape the DUNGEONS with upto 4 players!',
StartTime: at(3 * HOUR),
EndTime: at(4 * HOUR),
CanRequestBroadcastPermissions: 2147483647,
})
liveEvent = await create(
{ RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) },
'43'
)
pastEvent = await create({
RoomId: 3,
Name: 'Trigonometry Retrospective',
StartTime: at(-3 * HOUR),
EndTime: at(-2 * HOUR),
})
})
test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => {
expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401)
const res = await get('/api/playerevents/v1/tagfilters', '42')
expect(res.status).toBe(200)
// Static — the categories the client offers, not derived from stored events.
// Trending is null even in the reference: it needs recent-activity data.
expect(await res.json()).toEqual({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
})
test('POST /api/playerevents/v2 creates an event, auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
method: 'POST',
body: '{}',
})
expect(res.status).toBe(401)
// The stored record carries exactly the client's field set — nothing more.
expect(upcoming).toEqual({
PlayerEventId: upcoming.PlayerEventId,
CreatorPlayerId: 42,
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// Timestamps come back at seconds precision, as the client sends them.
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
})
// The one thing the event writes are strict about. Everything else here defaults a
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
// description past the stored length can't be defaulted into anything sensible, and
// truncating a player's description silently is worse than refusing the write.
//
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
// rule that guards usernames and room names would be wrong here.
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
400
)
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
200
)
const withDescription = (description: string) =>
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
expect((await withDescription('d'.repeat(513))).status).toBe(400)
expect((await withDescription('d'.repeat(512))).status).toBe(200)
// Counted in code points, so an emoji costs one character rather than two.
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
// Spaces and punctuation stay fine — this is a title, not an identifier.
expect(
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
).toBe(200)
// The update path enforces the same limits, and a refusal leaves the event alone.
const event = await create({ Name: 'EditMe', RoomId: 3 })
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
Name: 'n'.repeat(65),
})
expect(tooLong.status).toBe(400)
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
})
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Always null: no event tags are stored, but the field has to be present.
expect(body.TagModifyResult).toBeNull()
expect(body.PlayerEvent.Name).toBe('Enveloped')
})
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
const event = await create({
RoomId: 58,
Name: 'Open Mic',
Description: 'come hang',
StartTime: at(HOUR),
EndTime: at(3 * HOUR),
})
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
const last = (await res.json()) as {
playerId: number
notificationType: number
data: Record<string, unknown>
}
expect(last.playerId).toBe(42) // the creator
expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated
// camelCase, unlike the PascalCase record the response carries; `tags` and
// `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped.
// The real hub strips the null values from the frame before it goes on the wire.
expect(last.data).toEqual({
tags: [],
playerEventId: event.PlayerEventId,
creatorPlayerId: 42,
roomId: 58,
subRoomId: null,
clubId: null,
name: 'Open Mic',
description: 'come hang',
imageName: '', // empty string, not the record's null
startTime: `${event.StartTime.slice(0, -1)}.0000000Z`,
endTime: `${event.EndTime.slice(0, -1)}.0000000Z`,
attendeeCount: 1,
accessibility: 1,
isMultiInstance: false,
supportMultiInstanceRoomChat: false,
defaultBroadcastPermissions: 0,
canRequestBroadcastPermissions: 0,
broadcastingRoomInstanceId: null,
})
// Tick precision on the frame; the stored record keeps its bare form.
expect(event.StartTime).toMatch(/:\d{2}Z$/)
})
test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => {
const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 })
expect(event.CreatorPlayerId).toBe(42)
})
test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => {
const event = await create({})
expect(event).toMatchObject({
Name: 'Untitled Event',
Description: '',
RoomId: 0,
SubRoomId: null,
ClubId: null,
ImageName: null,
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// A start with no end runs for an hour.
expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR)
})
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
expect(res.status).toBe(200)
// No envelope here — unlike the writes.
expect(await res.json()).toEqual(upcoming)
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
const res = await get(
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
)
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Request order, not id order — and the missing id leaves no hole.
expect(events.map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
})
test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
// Every term has to match, across name OR description.
expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// …matched case-insensitively, and against the description too.
expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// `pastEvent` matches on name but has already ended, so the browse query drops it.
const trig = await search('?query=trigonometry')
expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId])
expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId)
// Soonest first, and take/skip page through that order.
const all = await search('')
const starts = all.map((e) => e.StartTime)
expect([...starts].sort()).toEqual(starts)
expect(await search('?take=1')).toEqual([all[0]])
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
const res = await get('/api/playerevents/v1/searchlive')
expect(res.status).toBe(200)
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
expect(ids).toContain(liveEvent.PlayerEventId)
// Started in an hour / finished already — neither is live.
expect(ids).not.toContain(upcoming.PlayerEventId)
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
expect(many.status).toBe(200)
const events = (await many.json()) as PlayerEvent[]
expect(events.map((e) => e.PlayerEventId)).toEqual([
liveEvent.PlayerEventId, // started an hour ago — soonest first
clubEvent.PlayerEventId,
])
// The single-club form does wrap its events with a paging cursor.
const one = await get('/api/playerevents/v1/club/7')
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
// A club with no events, and the no-ids case.
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
ContinuationToken: '',
Events: [],
})
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
})
test('GET /api/playerevents/v1/all lists the callers own events, auth-gated', async () => {
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
Created: PlayerEvent[]
Responses: unknown[]
}
const ids = mine.Created.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
// 43 created that one, not 42.
expect(ids).not.toContain(liveEvent.PlayerEventId)
// Finished events stay in the creator's own list — only the browse queries drop them.
expect(ids).toContain(pastEvent.PlayerEventId)
// Nothing records an RSVP yet.
expect(mine.Responses).toEqual([])
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
Created: PlayerEvent[]
}
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// The creator is Going from create, which is where the initial 1 comes from.
expect(event.AttendeeCount).toBe(1)
expect(await countGoing(env.DB, id)).toBe(1)
// 43 says Going → 2 attendees, and the envelope carries the updated event.
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.AttendeeCount).toBe(2)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
event_id: id,
player_id: 43,
status: 0,
})
// Changing the answer REPLACES it — one row per player, not a second RSVP.
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
// Interested is a maybe — recorded, but not counted.
await respond({ PlayerEventId: id, Type: 1 }, '43')
expect(await countGoing(env.DB, id)).toBe(1)
// And the count sticks on the stored event, not just the response.
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
expect(fetched.AttendeeCount).toBe(1)
})
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
const event = await create({ RoomId: 3, Name: 'Guarded' })
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
})
).status
).toBe(401)
// An unrecognized Type is rejected rather than defaulted — stored as Going it
// would silently inflate the count.
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
400
)
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
).toBe(404)
})
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
const event = await create({
RoomId: 5,
SubRoomId: 6,
ClubId: 9,
Name: 'Original',
Description: 'Original description',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
expect(
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
).toBe(401)
// 43 didn't create it.
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
const res = await post(path, { Name: 'Renamed' })
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
)
})
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
// Nested form again, and an explicit null — absent leaves the value alone,
// null genuinely clears it.
PlayerEvent: { ClubId: null, ImageName: null },
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
PlayerEventId: 424242,
CreatorPlayerId: 43,
AttendeeCount: 500,
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
})
describe('openapi', () => {
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
@@ -1938,13 +2973,17 @@ describe('openapi', () => {
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
@@ -1964,6 +3003,7 @@ describe('openapi', () => {
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
@@ -1971,10 +3011,15 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
+87
View File
@@ -0,0 +1,87 @@
/**
* Moderator-issued player warnings on the shared `recflare` D1 database.
*
* The counterpart to the `report` table (see reports-db.ts): a report is what a
* player submits, a warning is what a moderator hands down. Same shape of storage —
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
* rows yet.
*
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
]
/** A stored warning row (snake_case columns, one row per warning issued). */
export interface WarningRow {
id: number
/** The moderator who issued it, from their bearer token. */
moderator_player_id: number
warned_player_id: number
report_category: number
/** What the warned player is shown, e.g. `Sexual gestures`. */
display_reason: string | null
/** Internal note — never surfaced to the warned player. */
moderator_note: string | null
created_at: string
}
/**
* A warning as issued — everything but the moderator (which comes from the bearer
* token) and the timestamp. Only the warned player is required; the rest are
* optional and stored as NULL when absent.
*/
export interface NewWarning {
moderatorPlayerId: number
warnedPlayerId: number
reportCategory?: number
displayReason?: string | null
moderatorNote?: string | null
}
/** Record an issued warning, returning the stored row (with its assigned id). */
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
const row = await db
.prepare(
`INSERT INTO warning (
moderator_player_id, warned_player_id, report_category,
display_reason, moderator_note, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.moderatorPlayerId,
input.warnedPlayerId,
input.reportCategory ?? 0,
input.displayReason ?? null,
input.moderatorNote ?? null,
new Date().toISOString()
)
.first<WarningRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every warning issued against a player, newest first. Backs a future moderation view. */
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
const { results } = await db
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<WarningRow>()
return results
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://github.com/djdevin/recflare"
"MoreInfoUrl": "https://recflare.net"
},
"InstagramImages": [
{
+7 -1
View File
@@ -19,11 +19,17 @@
}
],
// Image bucket shared with the `img` worker (which serves objects back by key).
// Saved-image uploads are written here.
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
// invention's uploaded data blob for its `BlobHash`.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
+69 -17
View File
@@ -42,35 +42,79 @@ 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 the Steam ticket proves.
identity `platform_auth` 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.
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).
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`.
### Steam is the only verifiable platform
### Verifiable platforms: Steam and Meta
`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`.
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`.
## Signup caps
`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
`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
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
@@ -79,9 +123,10 @@ 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` |
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `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` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
@@ -109,12 +154,19 @@ 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 a local value (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed local values (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 store value invalidates all existing tokens (clients re-authenticate).
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.
@@ -0,0 +1,35 @@
-- 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 <> '';
@@ -0,0 +1,23 @@
-- 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;
+378 -113
View File
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
@@ -19,11 +18,13 @@ import {
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
@@ -38,21 +39,44 @@ 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. */
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
* headset reports this same value. Two things follow, and both are enforced below:
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
* - it is therefore never LINKED to an account. A link is a password-free way in, so
* one link on a shared id would open that account to every sideloaded build.
* It exists only to get such a client onto the username/password login screen.
*/
const SIDELOAD_PLATFORM_ID = '1'
/**
* The canned entry served for the one Oculus cached-login lookup below — the sideloaded
* APK's way onto the password login screen. Not backed by a link, an account or a
* platform proof, hence `requirePassword: true`.
*/
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: '1',
platformId: SIDELOAD_PLATFORM_ID,
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
@@ -166,48 +190,169 @@ 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 Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
* 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.
*/
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 platform_auth ticket) is the credential for a cached login — no prompt.
* (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.
*/
function toCachedLogin(account: Account) {
function toCachedLogin(account: Account, link: PlatformLink) {
return {
platform: accountPlatform(account),
platformId: account.platformId ?? '',
platform: link.platform,
platformId: link.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> {
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
// password grant, which is the path such a client actually takes. Linking it would
// hand every sideloaded headset a password-free login into that account, since they
// all report this same id.
//
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
// password grant carries its own credential and only *links* on a verified proof), it
// just never gets a cached login, so they type their password each launch. That is
// the intended shape of the sideload flow.
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
}
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(
'*',
@@ -219,6 +364,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -250,33 +403,36 @@ 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.',
'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`.',
'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.',
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
'with `requirePassword: true`, sending the build to username/password login.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value disables the link filter.',
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
schema: { type: 'string' },
},
{
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam.',
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
schema: { type: 'string' },
},
],
responses: {
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
),
},
}),
@@ -284,21 +440,27 @@ const app = new Hono<App>()
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// 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)
)
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
// produce a real Meta identity or a nonce to prove one with — it asks about the
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
// login screen with nothing to do. Hand back one canned entry to push it onto the
// username/password login instead, which is the only flow such a build can finish.
// `requirePassword` is true for exactly that reason: there's no platform proof here,
// and the `cached_login` grant would (correctly) refuse this entry.
//
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
// real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers.
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN])
}
// 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))
}
)
@@ -312,8 +474,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`. Unlike the single-id route, results are NOT filtered to',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
'entrys own `platformId`. No platform accompanies these ids, so each matches on',
'any platform. 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') },
@@ -324,7 +486,8 @@ 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) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
// No platform accompanies these ids, so they match on any platform.
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
}
return c.json(out)
}
@@ -345,12 +508,13 @@ 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 Steam and `platform_auth` must verify.',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'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.',
'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.',
'',
'**`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.',
@@ -358,13 +522,27 @@ 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.',
'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.',
'',
'**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.',
'**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.',
'',
'The one platform id that is never verified and never linked is `1` on platform `1`',
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
'build reports it, so it identifies nobody. A password login that carries it still',
'succeeds; it simply links nothing, and the player types their password each launch.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
@@ -378,13 +556,17 @@ const app = new Hono<App>()
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ')
),
500: json(
OAuthError,
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
[
'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(' ')
),
},
}),
@@ -419,42 +601,90 @@ 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 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)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== PlatformType.Steam) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
// 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 : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
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') {
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
error_description: 'unsupported platform; only Steam and Meta can be verified',
},
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
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')
return c.json(
{
error: 'server_error',
error_description: 'Meta platform verification is not configured',
},
500
)
}
// 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
)
}
// 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
@@ -482,10 +712,16 @@ const app = new Hono<App>()
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
verifiedPlatformId !== null &&
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
return c.json(
{
error: 'invalid_grant',
@@ -509,14 +745,15 @@ const app = new Hono<App>()
)
}
// 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.
// 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.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
@@ -524,6 +761,14 @@ 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 !== '') {
@@ -547,18 +792,25 @@ 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 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.
// `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.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
// 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.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
return c.json(
{
error: 'invalid_grant',
@@ -600,6 +852,19 @@ 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,6 +12,13 @@ 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
@@ -0,0 +1,172 @@
/**
* 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 }
}
+29 -9
View File
@@ -69,21 +69,33 @@ export const PlatformType = {
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/**
* A PlatformType by value. Only Steam can actually be verified — see the
* platform-auth notes on `POST /connect/token`.
* A PlatformType by value. Only Steam and Oculus (Meta) 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`. */
/**
* 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.
*/
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
platformId: z
.string()
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
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
@@ -92,8 +104,9 @@ export const CachedLogin = z.object({
})
/**
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
* is true — nothing proves platform ownership, so the client has to prompt.
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
* but `requirePassword` is true — with no Meta SDK there is nothing to prove platform
* ownership with, so the client falls through to username/password.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
@@ -139,11 +152,18 @@ export const TokenRequest = z.object({
platform_id: z
.string()
.optional()
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
.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'
),
platform_auth: z
.string()
.optional()
.describe('Steam session ticket. Required for cached_login and platform create_account'),
.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":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
+198
View File
@@ -0,0 +1,198 @@
/**
* 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
}
+471 -43
View File
@@ -8,12 +8,18 @@ import {
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { isLinkedToPlatformIdentity } from '../../auth.app'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -31,14 +37,28 @@ 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()
@@ -49,12 +69,9 @@ beforeAll(async () => {
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.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()
// 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()
// 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, {
@@ -101,6 +118,51 @@ 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`, {
@@ -122,16 +184,25 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
// 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`)
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([])
})
}
)
// 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`)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
@@ -144,10 +215,11 @@ describe('auth worker routes', () => {
])
})
// 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])(
// 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])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -155,11 +227,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')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -167,7 +239,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')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
@@ -191,6 +263,109 @@ 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.
@@ -206,6 +381,7 @@ 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([
@@ -219,32 +395,69 @@ describe('auth worker routes', () => {
])
})
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.
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.
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()
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)
// 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)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -581,6 +794,157 @@ 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('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
// The sideload placeholder identifies nobody — every sideloaded headset reports
// `1`, so a link on it would be a password-free way into this account from any of
// them. The password login still stands; Meta is never even asked, since there is
// nothing there to validate.
await seedPasswordAccount(7105, 'sideloader')
const login = await metaLogin(
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=1` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true // even with Meta answering yes to everything
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
// And so the picker never offers this account off the placeholder — only the
// canned stub entry is there.
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
})
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
@@ -720,3 +1084,67 @@ describe('auth worker routes', () => {
}
})
})
// The website is a browser origin calling these endpoints directly — the same ones the
// game calls — instead of proxying them through `www`. That only works if the responses
// carry CORS headers: without them the browser discards a perfectly good token response
// and sign-in fails with nothing in any server log to explain it.
describe('CORS', () => {
test('answers the preflight the browser sends before a token grant', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'content-type'
)
})
// The header has to be on the REAL response too, not just the preflight — and on a
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
// network error rather than "that password is incorrect".
test('allows the origin on the response itself, refusals included', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
}),
env
)
expect(res.status).toBe(400)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
// The bearer header is what the SPA authenticates with, so it must be allowed by name
// — a preflight that omits it makes every signed-in call fail.
test('allows the Authorization header the SPA signs its calls with', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/account/me/changepassword`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
})
})
@@ -0,0 +1,155 @@
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,11 +22,23 @@
// 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,
+26 -3
View File
@@ -195,6 +195,28 @@ const app = new Hono<App>()
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// Generic client data by name. Anything the client uploads as FileType 2 lands
// under `data/` (a Holotar recording is the one seen in the wild) and the client
// fetches it back from this prefix. Date-foldered like the room and invention
// blobs, so the rest of the path is matched as-is.
.get(
'/data/:id{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a client data blob',
description: [
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
'observed. Like room and invention blobs the name is date-foldered by the upload,',
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
].join(' '),
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The data blob'),
}),
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -209,10 +231,11 @@ app.get(
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
'R2 bucket, plus the one bundled config file the loading screen reads.',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
'screen reads.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
+16
View File
@@ -101,6 +101,21 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /data/:id streams the data blob from R2', async () => {
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
})
test('GET /data/:id 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -124,6 +139,7 @@ describe('cdn endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
+11 -5
View File
@@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import '../../chat.app'
import { NotificationType } from '../../../../notify/src/notification-types'
import {
ChatModerationState,
getMessage,
@@ -721,7 +722,7 @@ describe('ChatMessageReceived push', () => {
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
interface SentNotification {
playerId: number
notificationType: number
notificationType: NotificationType
data: Record<string, unknown>
}
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
@@ -759,8 +760,9 @@ 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])
// NotificationType.ChatMessageReceived
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
@@ -982,7 +984,9 @@ 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: number }>>
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
}
}
const caller = 889005
@@ -992,7 +996,9 @@ 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 === 90)).toBe(true)
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
+24 -3
View File
@@ -2,6 +2,11 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
glyphLength,
MAX_CLUB_DESCRIPTION_LENGTH,
MAX_CLUB_NAME_LENGTH,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -101,8 +106,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
/** Longest a club name may be (the reference's MaxNameLength). */
const MAX_CLUB_NAME_LENGTH = 16
/**
* The tiers `members/invite` may grant the real member roles only. Creator (100) is
@@ -665,6 +668,14 @@ const app = new Hono<App>()
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
// Counted in code points like the name above, so an emoji-heavy description is
// measured the way a player sees it rather than by UTF-16 units.
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
// The per-account cap, checked after the cheap validations so a rejected name
// costs no extra D1 read.
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
@@ -778,9 +789,19 @@ const app = new Hono<App>()
}
}
// Same absent-means-unchanged rule as the name, so a club with no description
// isn't forced to grow one just to be edited.
const description = field('description') || undefined
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
const updated = await updateClub(c.env.DB, clubId, {
name,
description: field('description') || undefined,
description,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
+8 -5
View File
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
description: z.string().optional(),
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
description: z.string().optional().describe('At most 512 characters'),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('Empty means unchanged'),
name: z
.string()
.optional()
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
+10 -3
View File
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Names cap at 40 characters.
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
// refused at half the length a player can see (the description has no charset rule
// — only the name does).
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
@@ -0,0 +1,18 @@
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
-- inventions a player has bought from the invention store. Written at purchase time by
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
-- second purchase is a no-op rather than a duplicate row.
--
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
CREATE TABLE IF NOT EXISTS inventory_invention (
account_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
acquired_at TEXT NOT NULL,
PRIMARY KEY (account_id, invention_id)
);
+190 -14
View File
@@ -2,10 +2,21 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
import {
consumeGift,
createGift,
getGift,
getPendingGifts,
grantInvention,
ownsInvention,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
@@ -17,7 +28,10 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
creditCurrency,
CurrencyType,
DEFAULT_STARTING_TOKENS,
ensureStartingBalances,
getBalance,
isSpendable,
spendCurrency,
@@ -34,6 +48,7 @@ import {
AUTHED,
AvatarV2Dto,
BalanceEntry,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
@@ -69,7 +84,8 @@ import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). Balances,
* inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
* avatars and gift boxes are D1-backed;
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
* binding. Some routes are still empty-list stubs (room keys, wishlist, ).
*
@@ -187,23 +203,30 @@ async function pushConsumableAdded(
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
* reference's
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
* The client applies it to the shown balance so a purchase debit reflects immediately,
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
* currency (not the delta), `BalanceType` is -2 (account-wide, all platforms). Best-effort:
* a hub failure is logged and swallowed, since the balance change has already committed.
* The client applies it to the shown balance so a purchase reflects immediately, without
* waiting for a `GET /balance` re-fetch.
*
* `Balance` is the CHANGE negative for a debit, positive for a payout not the
* resulting total. The client ADDS what it receives to the balance it is already showing,
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
* once per change and never re-send it as a "refresh".
*
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
* and swallowed, since the balance change has already committed.
*/
async function pushBalanceUpdate(
c: Context<App>,
accountId: number,
currencyType: number,
balance: number
change: number
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalanceUpdate,
{
Balance: balance,
Balance: change,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
}
@@ -996,7 +1019,8 @@ const app = new Hono<App>({ strict: false })
'still matches, debits the buyer atomically, grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.',
'price), not the new total. Pushes a StorefrontBalanceUpdate socket frame carrying the',
'same change, which the client ADDS to the balance it is showing.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
@@ -1117,11 +1141,11 @@ const app = new Hono<App>({ strict: false })
)
)
// Push the buyer's new (reduced) balance over the socket so their client updates the
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
// spent. Best-effort; the HTTP response still carries the change either way.
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalanceUpdate(c, id, currencyType as number, newBalance)
// Push the debit over the socket so the buyer's client updates the shown total
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
// carries the same change either way.
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
@@ -1164,6 +1188,158 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
//
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
// bumped: that column lives on the `invention` table the `api` worker owns, and this
// worker only reads it.
.get(
'/api/storefronts/v2/buyInvention',
describeRoute({
tags: ['Storefront'],
summary: 'Buy an invention',
description: [
'Looks the invention up by id, confirms the clients `requestedPrice` still matches',
'its stored `Price`, debits the buyer and pays the creator that price in',
'RecCenterTokens (a free invention moves nothing), records ownership in',
'`inventory_invention`, and returns the invention alongside the buyers resulting',
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
'their CHANGE (the buyers negative, the creators positive), which the client adds to',
'the balance it is showing — unlike this response body, which replaces it.',
'A GET because that is how the client sends it.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'inventionId',
in: 'query',
required: true,
description: 'Invention id; missing or non-numeric is 400',
schema: { type: 'integer' },
},
{
name: 'requestedPrice',
in: 'query',
required: false,
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
schema: { type: 'integer' },
},
],
responses: {
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
// a priced one then fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player: push the payout so a sale
// lands on their shown balance without a re-fetch. Positive, because the frame
// carries the change. Best-effort, as everywhere.
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The socket frame below is the other way round: the HTTP
// body REPLACES the shown balance, the push ADDS to it.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no change to push for it.
if (price > 0) {
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
// Storefront ad-carousel items. Served from the bundled static JSON — one
// placeholder banner with no purchasable items until real promo data exists.
.get(
+28 -1
View File
@@ -130,7 +130,34 @@ export const BuyItemResponse = z.object({
BalanceType: z.int().describe('-2 = account-wide'),
})
/** buyItem error body (`{ error }`), returned on 400/404/409. */
/**
* `GET /api/storefronts/v2/buyInvention` the purchase result. Two envelopes side by
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
* and the invention envelope the invention endpoints already serve.
*/
export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
BalanceType: z.int().describe('-2 = account-wide'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
})
),
}),
InventionResponse: z
.object({
Status: z.int(),
Invention: JsonObject,
InventionVersion: JsonObject,
})
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
+232 -1
View File
@@ -4,8 +4,15 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app'
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
import {
getOwnedInventionIds,
INVENTORY_INVENTION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -39,11 +46,76 @@ beforeAll(async () => {
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
for (const invention of SEEDED_INVENTIONS) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(invention))
.run()
}
})
/**
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
* reads are meaningful id, creator, published flag and price but the record is
* shaped like a real stored `RRInvention` so the response envelope is realistic.
*/
function invention(
inventionId: number,
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
) {
return {
InventionId: inventionId,
ReplicationId: `replication-${inventionId}`,
CreatorPlayerId: 999,
Name: `Invention ${inventionId}`,
Description: 'A test invention',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: {
InventionId: inventionId,
ReplicationId: `version-${inventionId}`,
VersionNumber: 1,
BlobName: `invention-${inventionId}.inv`,
BlobHash: null,
InstantiationCost: 0,
LightsCost: 0,
ChipsCost: 0,
CloudVariablesCost: 0,
AICost: 0,
},
Accessibility: 0,
IsPublished: true,
IsFeatured: false,
ModifiedAt: '2026-01-01T00:00:00.000Z',
CreatedAt: '2026-01-01T00:00:00.000Z',
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
CreationRoomId: 0,
NumPlayersHaveUsedInRoom: 0,
NumDownloads: 0,
CheerCount: 0,
CreatorPermission: 100,
GeneralPermission: 20,
IsAGInvention: false,
IsCertifiedInvention: false,
Price: 0,
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: [],
...overrides,
}
}
const SEEDED_INVENTIONS = [
invention(8), // free, published, someone else's — the sellable one
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
]
/**
* A real outfit as the client posts it to /api/avatar/v3/saved/set kept verbatim
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
@@ -629,6 +701,7 @@ describe('econ endpoints', () => {
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
// Account 20: fresh, so its first balance touch grants the 10000 default.
await drainFrames()
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
@@ -656,6 +729,16 @@ describe('econ endpoints', () => {
expect(gift.AvatarItemDesc).not.toBe('')
expect(gift.Id).toBeGreaterThan(0)
// The socket frame carries the same change the response does — the client adds it to
// the balance it is showing, so the resulting total here would double-count the 9550.
expect(await drainFrames()).toEqual([
{
accountId: 20,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
},
])
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('20'),
@@ -920,6 +1003,153 @@ describe('econ endpoints', () => {
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
})
/**
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
* best-effort the worker logs and swallows a hub failure so this is the only way a
* test sees what was actually pushed.
*/
const drainFrames = async (): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
>
}
).drainFrames()
/** `NotificationType.StorefrontBalanceUpdate` in the notify worker's enum. */
const STOREFRONT_BALANCE_UPDATE = 61
// buyInvention is a GET with query params — that is how the client sends it.
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
{ headers: await bearer(sub) }
)
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
)
expect(res.status).toBe(401)
})
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
const res = await buyInvention('50', 8)
expect(res.status).toBe(200)
const body = (await res.json()) as {
BalanceUpdateResponse: {
Balance: number
BalanceType: number
CurrencyType: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
InventionResponse: {
Status: number
Invention: { InventionId: number; Name: string }
InventionVersion: { InventionId: number; VersionNumber: number }
}
}
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
// grant — not a change, unlike buyItem's.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(body.InventionResponse.Status).toBe(0)
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
// Owning an invention is boolean: buying it again is a conflict, not a second row.
expect((await buyInvention('50', 8)).status).toBe(409)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
})
test('GET /api/storefronts/v2/buyInvention pays the creator the buyers tokens', async () => {
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
await drainFrames()
const res = await buyInvention('51', 9, 250)
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
// The creator had never touched their balance: they keep their starting grant AND get
// paid, rather than the payout standing in for the grant.
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS + 250)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
// Both sides get a socket frame carrying their CHANGE, not their new total: the client
// ADDS what it receives to the balance it is showing, so a total would have the creator
// reading their own balance plus the payout. Equal and opposite, like the ledger.
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
},
{
accountId: 51,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
},
])
})
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
expect((await buyInvention('53', 9, 0)).status).toBe(409)
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
await spendCurrency(
env.DB,
54,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS,
DEFAULT_STARTING_TOKENS
)
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect((await buyInvention('54', 9, 250)).status).toBe(400)
expect(
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(0)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore)
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
})
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
// Unpublished — a draft is not on sale, free or not.
expect((await buyInvention('52', 10)).status).toBe(403)
// Account 60 created invention 11; a creator already owns it.
expect((await buyInvention('60', 11)).status).toBe(400)
expect((await buyInvention('52', 9999)).status).toBe(404)
// Missing/non-numeric inventionId.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
headers: await bearer('52'),
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
// Buy an item for account 24, then consume the box the way the client does: on the
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
@@ -1182,6 +1412,7 @@ describe('econ endpoints', () => {
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems',
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
'GET /econ/customAvatarItems/v1/owned',
+17 -1
View File
@@ -14,6 +14,12 @@ export default defineConfig({
// isolated test, so provide a minimal stub exposing the same NotificationsHub
// RPC surface — enough for the runtime to start and for notification sends to
// no-op.
//
// The stub RECORDS what it was sent (`drainFrames`) rather than discarding it.
// Pushes are best-effort and swallow their own errors, so a frame carrying the
// wrong payload is otherwise invisible here — which is exactly how
// StorefrontBalanceUpdate shipped with the resulting total in a field the
// client adds to what it is already showing.
workers: [
{
name: 'notify',
@@ -24,8 +30,18 @@ export default defineConfig({
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
async notifyPlayer() { return { delivered: 0, queued: true } }
frames = []
async notifyPlayer(accountId, notificationType, payload) {
this.frames.push({ accountId, notificationType, payload })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
/** Everything pushed since the last call, then forget it. */
async drainFrames() {
const drained = this.frames
this.frames = []
return drained
}
}
export default { fetch() { return new Response('ok') } }
`,
+6
View File
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
DB: D1Database
/** R2 bucket holding the served image objects, keyed by filename. */
IMAGES: R2Bucket
/**
* Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images
* uploaded through the `storage` worker are stored extensionless under
* `image/<date>/<uuid>` and requested from this worker by the bare name.
*/
CDN_ASSETS: R2Bucket
/** Static assets (fallback images) served from `static/`. */
ASSETS: Fetcher
/**
+33 -4
View File
@@ -15,6 +15,9 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net'
/** Static asset served (200) when the requested key is missing from R2. */
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */
const CDN_IMAGE_PREFIX = 'image/'
/**
* Cache-Control for served images. Uploaded images are immutable once written,
* so cache for a year and mark `immutable` so browsers never revalidate. A new
@@ -101,6 +104,23 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
}
}
/**
* Which bucket (and under which key) a requested path resolves in.
*
* Every object the `api` worker writes to `recflare-img` keeps a file extension
* (`.jpg` is forced when the upload has none), so an extensionless key can only be
* a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as
* `image/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
* name it got back. That makes the extension a reliable discriminator
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
*/
function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } {
const filename = key.slice(key.lastIndexOf('/') + 1)
return filename.includes('.')
? { bucket: env.IMAGES, objectKey: key }
: { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key }
}
// Import the signing key once per isolate. The key material is constant for the
// lifetime of the Worker, so caching the promise is safe.
let signingKey: Promise<CryptoKey | null> | undefined
@@ -231,9 +251,11 @@ app.get(
description: [
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
'backend. Serves every image the client renders — profile photos, room thumbnails,',
'club banners and the photo feed — from an R2 bucket, with bundled static assets',
'club banners and the photo feed — out of R2, with bundled static assets',
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
'as the fallback when a key is missing. Optional on-the-fly center-crop and resize',
'as the fallback when a key is missing. Keys with an extension come from the',
'`recflare-img` bucket; extensionless ones are `storage` uploads and come from the',
'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize',
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
'header the client verifies against `KEY:RSA:p1.rec.net`.',
'',
@@ -266,6 +288,12 @@ app.get(
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
'with a 200 rather than a 404, so the client never renders a broken image.',
'',
'Which bucket the key resolves in depends on its extension. A key with one (always',
'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is',
'a `storage` upload and comes from the shared `recflare-cdn` bucket under its',
'`image/` prefix, so `/2028-06-01/<uuid>` here serves `image/2028-06-01/<uuid>`',
'there.',
'',
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
'image is never rewritten in place, a new image gets a new key.',
'',
@@ -360,8 +388,9 @@ app.get(
// resized response carries no etag, so the client can never send a matching
// one. Skip the precondition when a transform is requested.
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
const object = await c.env.IMAGES.get(
key,
const { bucket, objectKey } = resolveObject(c.env, key)
const object = await bucket.get(
objectKey,
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
)
if (!object) {
+39
View File
@@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 =
// bucket path rather than a static asset.
const R2_KEY = 'user-photo.jpg'
// An extensionless name, as returned by the `storage` worker for a FileType 3
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
const CDN_NAME = '2028-06-01/12345-67890-12345'
beforeAll(async () => {
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
@@ -58,6 +65,38 @@ describe('img endpoints', () => {
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('serves an extensionless key from the cdn bucket under image/', async () => {
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('does not look for an extensionless key in the image bucket', async () => {
// Same bare name seeded into `recflare-img` instead: extensionless keys only
// ever resolve against `recflare-cdn`, so this falls through to the default.
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
expect(res.status).toBe(200)
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
})
it('resizes an extensionless cdn image', async () => {
// Exercises the transform path against the cdn bucket, not just the stream-through.
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
httpMetadata: { contentType: 'image/jpeg' },
})
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(res.headers.get('etag')).toBeNull()
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
})
it('serves a static asset in preference to an R2 object of the same key', async () => {
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
expect(res.status).toBe(200)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+8
View File
@@ -17,10 +17,18 @@
"run_worker_first": true
},
// Images are stored as objects in an R2 bucket and streamed back by key.
// `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound
// alongside it: uploads posted to `storage` as FileType 3 land under its
// `image/` prefix with no extension, and the client asks THIS worker for them
// by the bare name see the extensionless-key branch in src/img.app.ts.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Shared `recflare` D1 the `img` worker owns the `images` metadata table
+229 -27
View File
@@ -21,12 +21,15 @@ import {
getRoomByName,
getRoomInstance,
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
isClubMember,
isPlayerBannedFromRoom,
MessageType,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
setRoomInstancePrivate,
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -50,6 +53,7 @@ import {
NotifyDisconnectRequest,
PlayerDto,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
UNAUTHORIZED_RESPONSE,
} from './openapi'
@@ -273,6 +277,14 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
const NO_SUCH_ROOM = 20
/**
* MatchmakingErrorCode for "you are banned from this room". Unlike the opaque
* NoSuchRoom every other refusal answers, a banned player is told why: they already
* know the room exists, so there's nothing to hide, and the client can say so instead
* of showing a room that mysteriously fails to load.
*/
const BANNED_FROM_ROOM = 55
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -476,10 +488,20 @@ async function inviteParty(
)
}
/**
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
* there (NoSuchRoom) from one the caller is banned from those answer different codes.
*/
type ResolvedInstance =
| { instance: RoomInstance; errorCode: 0 }
| { instance: null; errorCode: number }
/**
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
* joinable instance of it (public matchmakes reuse one via the `room_instance`
* table) or create a new one. Returns null when the room isn't found.
* table) or create a new one. A null instance carries the error code to answer:
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
*/
async function resolveRoomInstance(
c: Context<App>,
@@ -487,14 +509,24 @@ async function resolveRoomInstance(
isPrivate: boolean,
ownerId: number,
subRoomId?: number
): Promise<RoomInstance | null> {
): Promise<ResolvedInstance> {
const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id)
if (!room) return null
if (!room) return { instance: null, errorCode: NO_SUCH_ROOM }
const f = instanceFieldsFromRoom(room, subRoomId)
// A banned player never gets an instance. This is the whole enforcement of a room
// ban: the Photon room id only ever reaches a player through a matchmake, so
// refusing here means they have no coordinates to join or interact with. Handled
// before any instance is created or reused so a ban can't spawn one.
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
return { instance: null, errorCode: BANNED_FROM_ROOM }
}
// Never place the player back into the instance they're already in: the client
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
// your current instance (e.g. the only public instance of a room you're already in)
@@ -525,13 +557,16 @@ async function resolveRoomInstance(
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(
return {
instance: roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
)
),
errorCode: 0,
}
}
/**
@@ -859,7 +894,8 @@ const app = new Hono<App>()
description: [
'Looks the club up, checks the caller is a member of it, and places them into an',
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
'club is unknown, has no clubhouse set, or the caller isnt a member.',
'club is unknown, has no clubhouse set, or the caller isnt a member — and errorCode',
'55 when they are banned from the clubhouse room.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
@@ -875,7 +911,7 @@ const app = new Hono<App>()
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or errorCode 20 with null when it cant be entered)'
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
@@ -895,13 +931,13 @@ const app = new Hono<App>()
}
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(
const { instance, errorCode } = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
@@ -925,7 +961,9 @@ const app = new Hono<App>()
'from the targets stored presence. FRIENDS ONLY: the caller must be a mutual friend',
'of the target (otherwise anyone could read a players presence and warp to them).',
'Returns errorCode 20 with a null instance when the target isnt a friend, is the',
'caller themselves, or isnt currently in a room.',
'caller themselves, or isnt currently in a room, and errorCode 55 when the caller is',
'banned from the room the friend is in — this path hands out join coordinates without',
'going through the room resolver, so it carries its own ban check.',
].join(' '),
security: AUTHED,
parameters: [
@@ -940,7 +978,7 @@ const app = new Hono<App>()
responses: {
200: json(
MatchmakeResponse,
'The friends instance (or errorCode 20 with null when it cant be joined)'
'The friends instance (or a null instance with errorCode 20 / 55 when it cant be joined)'
),
401: UNAUTHORIZED_RESPONSE,
},
@@ -961,6 +999,14 @@ const app = new Hono<App>()
const instance = targetPresence?.roomInstance ?? null
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
// This path hands out a Photon room id without going through
// resolveRoomInstance, so the room's bans have to be checked here too —
// otherwise following a friend in is a way around a ban.
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
}
// Join that same instance (same id + Photon room) and store it as the caller's
// presence, so the heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
@@ -968,6 +1014,93 @@ const app = new Hono<App>()
}
)
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
// where they pick a session of their room and drop into it. Unlike every other
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
// and a full or in-progress instance is still entered (moderating a full instance
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
// listing — the Photon room id is the join coordinate, so an open version of this
// would let anyone warp into any private session by guessing an id. Registered
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
.post(
'/matchmake/instance/:instanceId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Join a specific instance (owner only)',
description: [
'Places the caller into one specific live instance of their own room, picked by id',
'from the owners instance listing. Gated to the rooms creator or a co-owner.',
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
'instance or its room is gone, or the caller doesnt manage that room; errorCode 55',
'when banned.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'instanceId',
in: 'path',
required: true,
description: 'Room instance id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 / 55 when it cant be joined)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
const stored = await getRoomInstance(c.env.DB, instanceId)
// One opaque refusal for "no such instance", "no such room" and "not yours":
// a distinct code for the last would confirm which instance ids are live.
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const room = await getRoomById(c.env.DB, stored.roomId)
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!canManageRoom(room, id)) {
logger.info('instance matchmake refused: not the rooms owner', {
roomInstanceId: instanceId,
roomId: stored.roomId,
accountId: id,
})
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
// Like the follow-a-friend path, this hands out a Photon room id without going
// through resolveRoomInstance, so the room's bans are checked here too. An owner
// can't ban themselves out of their own room in practice, but a co-owner can be
// banned, and a ban must beat every route that yields join coordinates.
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
logger.info('instance matchmake refused: player banned from room', {
roomId: stored.roomId,
id,
})
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
}
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
// this instance's own id and Photon room, so the owner lands in exactly the
// session they picked rather than a new one alongside it.
const instance = roomInstanceFromRoom(
room,
stored.isPrivate,
stored.roomInstanceId,
stored.photonRoomId,
stored.subRoomId
)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
// — the client uses this to enter a room's other scenes). The subroom decides the
// scene the client loads and which instances are joinable, so it must be carried
@@ -994,7 +1127,10 @@ const app = new Hono<App>()
},
],
responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1003,14 +1139,14 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const instance = await resolveRoomInstance(
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
subRoomId
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
@@ -1033,7 +1169,10 @@ const app = new Hono<App>()
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1041,8 +1180,13 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
@@ -1165,16 +1309,20 @@ const app = new Hono<App>()
(c) => c.body(null, 200)
)
// The room owner flips the instance's in-progress flag once the session starts
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
// this is set by whoever in the room starts the game, not by the room's owner — a
// gate here would break game starts for everyone else. Body is a form post:
// `inProgress=True|False`.
.put(
'/roominstance/:id/inprogress',
describeRoute({
tags: ['Room instance'],
summary: 'Set instance in-progress flag',
description: [
'The room owner flips the instances in-progress flag when a session starts (e.g. a',
'round begins). Body is `inProgress=True|False`.',
'Flips the instances in-progress flag when a session starts (e.g. a round begins).',
'Set by whoever in the room starts the game — any authenticated player, not just the',
'rooms owner. Body is `inProgress=True|False`.',
].join(' '),
security: AUTHED,
requestBody: form(InProgressRequest, 'The inProgress flag'),
@@ -1202,18 +1350,72 @@ const app = new Hono<App>()
}
)
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
// makes the session they're running private, so public matchmaking stops feeding new
// players into it (getJoinableInstance only reuses non-private instances). Everyone
// already inside stays put; this shuts the door rather than clearing the room.
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
.post(
'/roominstance/:id/markprivate',
describeRoute({
tags: ['Room instance'],
summary: 'Mark an instance private (owner only)',
description: [
'Marks a live instance private, so public matchmaking stops placing new players',
'into it. Players already inside are unaffected. Auth-gated and gated to the',
'instances rooms creator or a co-owner (403 otherwise). Empty ack.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Room instance id',
schema: { type: 'string' },
},
],
responses: {
200: EMPTY_OK,
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (empty body)' },
404: { description: 'Non-numeric id or no such instance (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(instanceId)) return c.body(null, 404)
const stored = await getRoomInstance(c.env.DB, instanceId)
if (!stored) return c.body(null, 404)
const room = await getRoomById(c.env.DB, stored.roomId)
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
await setRoomInstancePrivate(c.env.DB, instanceId, true)
return c.body(null, 200)
}
)
// The room's live instances — the owner's view of active sessions of their room.
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
// bare RoomInstance DTO array (empty when the room has no live instances).
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
// summary per instance (empty when the room has no live instances) — id, subroom,
// fullness, creation time and who's currently in it — not the client's
// RoomInstance DTO: this is a management listing, so it answers "who's in there"
// and withholds the connection details of a session the owner isn't joining.
.get(
'/room/:roomId{[0-9]+}/instances',
describeRoute({
tags: ['Room instance'],
summary: 'A rooms live instances',
description: [
'The owners view of active sessions of their room. Auth-gated and gated to the',
'rooms creator or a co-owner (403 otherwise). Unknown room → 404.',
'The owners view of active sessions of their room — each instance with the',
'players currently in it. Auth-gated and gated to the rooms creator or a',
'co-owner (403 otherwise). Unknown room → 404.',
].join(' '),
security: AUTHED,
parameters: [
@@ -1226,7 +1428,7 @@ const app = new Hono<App>()
},
],
responses: {
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (empty body)' },
404: { description: 'No such room (empty body)' },
@@ -1243,7 +1445,7 @@ const app = new Hono<App>()
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
if (!canManageRoom(room, id)) return c.body(null, 403)
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId))
}
)
+19 -1
View File
@@ -95,6 +95,22 @@ export const RoomInstanceDto = z.object({
EncryptVoiceChat: z.boolean(),
})
/**
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
* details (photon ids, data blob, room code) of a session the owner isn't in.
*/
export const RoomInstanceSummaryDto = z.object({
roomInstanceId: z.int(),
roomId: z.int(),
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
isFull: z.boolean(),
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
playerIds: z
.array(z.int())
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
})
/**
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" a player
@@ -128,7 +144,9 @@ export const PlayerDto = z.object({
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*/
export const MatchmakeResponse = z.object({
errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'),
errorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
roomInstance: RoomInstanceDto.nullable(),
})
+228 -10
View File
@@ -15,6 +15,7 @@ import {
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
@@ -83,15 +84,9 @@ 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')
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()
// 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()
// 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>)
@@ -989,10 +984,32 @@ describe('auth-gated endpoints', () => {
headers: await bearer('42'),
})
expect(res.status).toBe(200)
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
const instances = (await res.json()) as Array<{
roomInstanceId: number
roomId: number
subRoomId: number
isFull: boolean
createdAt: string
playerIds: number[]
}>
expect(instances.length).toBeGreaterThanOrEqual(1)
expect(instances.every((i) => i.roomId === 3)).toBe(true)
// The summary projection: id/subroom/fullness/createdAt plus who's in there —
// and none of the client DTO's connection fields.
const instance = instances.find((i) => i.playerIds.includes(42))
expect(instance).toBeDefined()
expect(Object.keys(instance!).sort()).toEqual([
'createdAt',
'isFull',
'playerIds',
'roomId',
'roomInstanceId',
'subRoomId',
])
expect(instance!.isFull).toBe(false)
expect(Number.isNaN(Date.parse(instance!.createdAt))).toBe(false)
// The co-owner (account 43, Role 30) may view the instances too.
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
headers: await bearer('43'),
@@ -1001,6 +1018,141 @@ describe('auth-gated endpoints', () => {
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
})
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
// A player with no role on room 3 spins up an instance of it, which the room's
// owner should then be able to drop into by id.
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer('43'),
})
const spawned = (await spawn.json()) as {
roomInstance: { roomInstanceId: number; photonRoomId: string }
}
const instanceId = spawned.roomInstance.roomInstanceId
// No token → 401.
expect(
(await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' }))
.status
).toBe(401)
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
// so instance ids can't be probed for live private sessions.
const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
method: 'POST',
headers: await bearer('999'),
})
expect(stranger.status).toBe(200)
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
// Unknown instance → same refusal.
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
method: 'POST',
headers: await bearer('42'),
})
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
// Park the owner somewhere else first, so this is a real transition.
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('42'),
})
// The owner lands in that exact instance — same id AND same Photon room as the
// player already in it, which is what makes it the same session.
const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
method: 'POST',
headers: await bearer('42'),
})
expect(joined.status).toBe(200)
const body = (await joined.json()) as {
errorCode: number
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance.roomInstanceId).toBe(instanceId)
expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId)
expect(body.roomInstance.roomId).toBe(3)
// It's now the owner's presence, and the listing shows both of them in there.
const listed = (await (
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
const target = listed.find((i) => i.roomInstanceId === instanceId)
expect(target?.playerIds).toEqual([42, 43])
})
test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => {
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
// instances the other tests matchmake into.
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, {
method: 'POST',
headers: await bearer('42'),
})
const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } }
const instanceId = roomInstance.roomInstanceId
// No token → 401.
expect(
(
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
method: 'POST',
})
).status
).toBe(401)
// Unknown instance → 404.
expect(
(
await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, {
method: 'POST',
headers: await bearer('42'),
})
).status
).toBe(404)
// Room 77 has no creator and no roles, so nobody manages it → 403 even for 42.
expect(
(
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
method: 'POST',
headers: await bearer('42'),
})
).status
).toBe(403)
// Room 3 is account 42's, so its instances are theirs to close.
const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer('43'),
})
const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } })
.roomInstance.roomInstanceId
const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, {
method: 'POST',
headers: await bearer('42'),
})
expect(marked.status).toBe(200)
expect(await marked.text()).toBe('')
// Closed to strangers: a public matchmake into room 3 no longer reuses it, so a
// new player lands in a different instance.
const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer('999'),
})
const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } })
.roomInstance.roomInstanceId
expect(afterId).not.toBe(ownedId)
// The player already inside is untouched — this shuts the door, it doesn't clear
// the room.
const listed = (await (
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43)
})
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
@@ -1246,6 +1398,70 @@ describe('auth-gated endpoints', () => {
// No token → 401.
expect((await follow(9801)).status).toBe(401)
// A ban on the room blocks the follow too: this path hands out a Photon room id
// without going through resolveRoomInstance, so it carries its own ban check —
// otherwise following a friend in would be a way around a ban.
await env.DB.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
try {
expect(await (await follow(9801, '9800')).json()).toEqual({
errorCode: 55,
roomInstance: null,
})
} finally {
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800')
.run()
}
})
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
const matchmake = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
// Not banned yet → a normal join.
expect((await matchmake('9700')).errorCode).toBe(0)
await env.DB.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
// The ban is the whole enforcement: no instance means no Photon room id, so there
// is nothing for the banned player to join. errorCode 55 rather than the opaque
// NoSuchRoom every other refusal answers — a banned player already knows the room
// exists, so the client can say why. Applies to the subroom path as well.
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
method: 'POST',
headers: await bearer('9701'),
})
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
// Refused before any instance is created, and no presence was recorded for them.
expect(
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first()
).toBeNull()
// The ban is per-room — another room is unaffected.
const other = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, {
method: 'POST',
headers: await bearer('9701'),
})
).json()) as { errorCode: number }
expect(other.errorCode).toBe(0)
// Lifting the ban lets them in again.
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run()
expect((await matchmake('9701')).errorCode).toBe(0)
})
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
@@ -1339,6 +1555,7 @@ describe('auth-gated endpoints', () => {
'POST /invite',
'POST /matchmake/club/{clubId}',
'POST /matchmake/dorm',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
@@ -1347,6 +1564,7 @@ describe('auth-gated endpoints', () => {
'POST /player/login',
'POST /player/logout',
'POST /player/notifydisconnect',
'POST /roominstance/{id}/markprivate',
'POST /roominstance/{id}/reportjoinresult',
'PUT /player/gameserverregionpings',
'PUT /player/photonregionpings',
+3
View File
@@ -12,6 +12,9 @@ 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).
+9 -2
View File
@@ -50,13 +50,20 @@
"crons": ["*/5 * * * *"]
},
"logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
// with RECFLARE_SECRETS_STORE at deploy.
// 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.
"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 -1
View File
@@ -24,7 +24,7 @@ export enum NotificationType {
ModerationUpdateRequired = 21,
ModerationKick = 22,
ModerationKickAttemptFailed = 23,
ModerationRoomBan = 24,
ModerationRoomBan = "ModerationRoomBan",
ServerMaintenance = 25,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31,
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
@@ -80,6 +80,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -581,3 +581,44 @@ describe('clearing pending notifications', () => {
expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403)
})
})
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
describe('CORS', () => {
// The catch: `/internal/*` is behind `requireAdmin`, and a browser preflight carries
// NO Authorization header — it can't, that's the header it's asking permission to
// send. So the CORS middleware has to answer it before the admin gate sees it,
// otherwise every admin action fails the preflight with a 401 and never gets sent.
test('answers the preflight on an admin endpoint without a token', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/internal/broadcast`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization, content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
const allowed = res.headers.get('access-control-allow-headers')?.toLowerCase() ?? ''
expect(allowed).toContain('authorization')
expect(allowed).toContain('content-type')
})
// The gate itself is untouched: the preflight passing is not the request passing.
test('still rejects the actual call without an admin token', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/internal/broadcast`, {
method: 'POST',
headers: { origin: 'https://www.example.com', 'content-type': 'application/json' },
body: JSON.stringify({ notificationType: 25, data: {} }),
}),
env
)
expect(res.status).toBe(401)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
})
@@ -0,0 +1,29 @@
-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`
-- is how a room's creator changes what a role may do in one subroom (spawn inventions,
-- invite, use the delete-all button, …). The client addresses an entry by the
-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the
-- primary key: sending it again overwrites the stored row rather than appending a second.
--
-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox
-- the client draws next to each permission — `Override: true` stores the value, and
-- `Override: false` means "fall back to the default", which deletes the row. Reads always
-- serve `Override: true`.
--
-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the
-- matching default in the permission table the client applies when it spawns. That's why
-- this is its own table rather than a field on the subroom's `data` blob: that blob is
-- served to the client verbatim inside the room, and nothing client-facing reads these.
--
-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission
-- whose UI isn't a True/False picker carries something else, and we don't interpret it.
--
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
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)
);
+24
View File
@@ -0,0 +1,24 @@
-- Per-room player bans. `POST /rooms/{roomId}/bans` is how a room's owner (or a
-- staff account) bans a player from a room; one row per (room, player), so
-- re-banning someone already banned updates their row rather than appending a
-- second one.
--
-- `ban_mask` is the client's `banMask` form field, stored verbatim. Its meaning is
-- not known yet — the client sends 0 — so nothing interprets it; it's kept so the
-- value isn't lost once we work out what it selects.
--
-- Columnar rather than a JSON blob, and deliberately NOT part of the room's `data`
-- blob: that blob is served to the client verbatim as the room, and a room's ban
-- list is not something every reader of a room should receive.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS room_ban (
room_id INTEGER NOT NULL,
banned_player_id INTEGER NOT NULL,
ban_mask INTEGER NOT NULL DEFAULT 0,
banned_by_account_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (room_id, banned_player_id)
);
CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id);
+92 -4
View File
@@ -64,6 +64,12 @@ 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]+`). */
@@ -83,6 +89,12 @@ 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')
/** The `:playerId` path parameter on the unban route. */
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
/** 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' } }
@@ -123,7 +135,11 @@ export const RoomTagDto = z.object({
Type: z.int().describe('0 = owner-set, 2 = auto'),
})
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
/**
* 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.
*/
export const RoomStatsDto = z.object({
CheerCount: z.int(),
FavoriteCount: z.int(),
@@ -428,6 +444,42 @@ export const RoleRequest = z.object({
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
})
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
export const BanRequest = z.object({
id: z.string().describe('Account id of the player to ban'),
banMask: z
.string()
.optional()
.describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'),
})
/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */
export const RoomBanDto = z.object({
RoomId: z.int(),
BannedPlayerId: z.int(),
BanMask: z.int(),
BannedByAccountId: z.int().describe('Who issued the ban'),
CreatedAt: z.string(),
})
/**
* One entry of `GET /rooms/{roomId}/bans` the client's ban-list shape. camelCase and
* a different field set from the {@link RoomBanDto} the write answers: no room id (the
* path already says which room) and no ban mask.
*/
export const RoomBanEntryDto = z.object({
accountId: z.int().describe('The banned player'),
bannedByAccountId: z.int().describe('Who issued the ban'),
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
export const RoomBanEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty on success'),
value: RoomBanDto.nullable().describe('Null on a rejection'),
})
/** `PUT /rooms/{roomId}/warning`. */
export const WarningRequest = z.object({
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
@@ -453,7 +505,7 @@ export const RestrictionsRequest = z.object({
supportsJuniors: z.string().optional().describe('`True` / `False`'),
})
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
/** `PUT /rooms/{roomId}/loadscreen` — the posted screen replaces the whole list. */
export const LoadScreenRequest = z.object({
imageName: z.string().describe('A key from the storage upload'),
title: z.string().optional(),
@@ -479,6 +531,35 @@ export const SubRoomAccessibilityRequest = z.object({
),
})
/**
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` the entries to change, keyed by
* (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's
* checkbox: true stores the entry, false clears it back to the default.
*/
export const SubRoomPermissionsRequest = z
.array(
z.object({
Permission: z
.string()
.describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'),
Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'),
Override: z
.boolean()
.describe(
'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' +
'false DELETES any stored one so the pair falls back to its default'
),
Type: z.int().describe('Always 0 in what the client sends; stored verbatim'),
Value: z
.string()
.describe(
'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' +
'permissions UI is a True/False picker. Ignored when `Override` is false'
),
})
)
.describe('An array — the client sends one even when changing a single permission')
/**
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` promotes one save to live.
* Any id from the subroom's history works, so this is both publish and restore.
@@ -543,11 +624,13 @@ export const SubRoomSavesPage = z.object({
/** One entry of the permission table the client applies when it spawns into a room. */
export const RoomPermissionDto = z.object({
Override: z.boolean(),
Override: z.boolean().describe('Always true on an entry that came from a subrooms overrides'),
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
Type: z.int(),
Value: z.string().describe('Always `True` — a permission is present or absent'),
Value: z
.string()
.describe('A STRING, not a boolean — `True` on the defaults, anything on an override'),
})
/**
@@ -556,6 +639,11 @@ export const RoomPermissionDto = z.object({
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
*
* `Permissions` is the default table with the overrides stored on the subroom the caller
* is standing in merged over it (see
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the
* default with the same (`Permission`, `Role`), and one naming a new pair is appended.
*/
export const PhotonAccessTokenDto = z.object({
Permissions: z.array(RoomPermissionDto),
+518 -48
View File
@@ -4,6 +4,8 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
areFriends,
banPlayerFromRoom,
canManageRoom,
cloneRoom,
cloneSubRoom,
@@ -20,14 +22,18 @@ import {
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomBans,
getRoomById,
getRoomByName,
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
getSubRoomPermissions,
getSubRoomSaves,
getVisitedRooms,
MAX_ROOM_NAME_LENGTH,
modifySubRoom,
nameRejection,
publishSubRoomSave,
removeCheer,
removeFavorite,
@@ -37,17 +43,24 @@ import {
setRoomImage,
setRoomName,
setRoomRole,
setSubRoomPermissions,
toggleCheer,
toggleFavorite,
toggleRoomTag,
unbanPlayerFromRoom,
updateRoomFields,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
AccessibilityRequest,
AUTHED,
bannedPlayerIdParam,
BanRequest,
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
@@ -63,13 +76,17 @@ import {
MissingLookupParam,
ModifySubRoomRequest,
NameRequest,
NOT_FRIENDS_RESPONSE,
PagedRooms,
pageParams,
PhotonAccessTokenDto,
PlayerDataDto,
playerIdParam,
PublishSaveRequest,
RestrictionsRequest,
RoleRequest,
RoomBanEnvelope,
RoomBanEntryDto,
RoomDto,
RoomEnvelope,
roomIdParam,
@@ -81,6 +98,7 @@ import {
stringQuery,
SubRoomAccessibilityRequest,
subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesPage,
TagRequest,
UNAUTHORIZED_EMPTY,
@@ -90,6 +108,7 @@ import {
} from './openapi'
import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { App } from './context'
/**
@@ -131,9 +150,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
* hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
/** The slice of the shared presence row we read — the caller's current room instance. */
/**
* The slice of the shared presence row we read the caller's current room instance.
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
* the player is standing in, not to the room.
*/
interface PresenceView {
roomInstanceId?: number
subRoomId?: number
}
/**
@@ -143,16 +167,26 @@ interface PresenceView {
* they aren't in one). `PhotonAccessToken` stays empty the reference server
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
* Photon setup accepts an empty token.
*
* `overrides` are the permissions the room's creator saved on the subroom the caller is
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
* defaults by (`Permission`, `Role`) the same pair the client addresses an entry by
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
* standing in it as well.
*/
function photonAccessToken(accountId: number, roomInstanceId: number | null) {
const perm = (Permission: string, Role: number, Override: boolean) => ({
function photonAccessToken(
accountId: number,
roomInstanceId: number | null,
overrides: RoomPermission[] = []
) {
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
Override,
Permission,
Role,
Type: 0,
Value: 'True',
})
const permissions = [
const permissions: RoomPermission[] = [
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
perm('CAN_SAVE_INVENTIONS', 0, true),
@@ -165,9 +199,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
perm('CAN_SPAWN_INVENTIONS', 30, true),
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
]
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
}
// The subroom's stored table wins, applied LAST and over the dev grant too: a
// (Permission, Role) the table already carries is replaced in place — so the order
// doesn't shift under the client, and no pair is ever listed twice with two values —
// and one it doesn't (e.g. CAN_INVITE) is appended.
for (const override of overrides) {
const i = permissions.findIndex(
(p) => p.Permission === override.Permission && p.Role === override.Role
)
if (i === -1) permissions.push(override)
else permissions[i] = override
}
return {
Permissions: permissions,
PhotonAccessToken: '',
@@ -176,16 +223,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
}
/**
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
* resolves the caller, reads their current room instance from the shared
* `presence` table (see @repo/domain), and returns the permissions + token.
* Photon access-token handler. Auth-gated: resolves the caller, reads their current
* room instance from the shared `presence` table (see @repo/domain), and returns the
* permissions + token.
*/
async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
return c.json(photonAccessToken(accountId, roomInstanceId))
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
// The permission overrides are the ones saved on the subroom the caller is standing in.
// A player in no instance — sitting in the lobby, or an instance predating subroom
// tracking — gets the default table untouched.
const overrides =
typeof instance?.subRoomId === 'number'
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
: []
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
}
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
@@ -193,6 +246,22 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* Operator-granted elevated roles the ones the auth worker stamps from an account's
* isDeveloper/isModerator flags (see the admin CLI). Same set the `notify` / `www`
* workers gate their admin surfaces on.
*/
const STAFF_ROLES: ReadonlySet<string> = new Set(['developer', 'moderator'])
/**
* Whether the caller's token carries a staff role. Used alongside the per-room owner
* check for actions staff may take in a room they don't own.
*/
async function isStaff(c: Context<App>): Promise<boolean> {
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
}
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
@@ -214,6 +283,59 @@ function parseAccessibility(value: unknown): number | undefined {
return named ? (named[1] as number) : undefined
}
/** Parse an integer from the number or numeric string a JSON body may carry. */
function parseInt10(value: unknown): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
if (typeof value !== 'string') return undefined
const n = Number.parseInt(value.trim(), 10)
return Number.isNaN(n) ? undefined : n
}
/**
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` the
* True/False picker beside the override checkbox but a permission whose UI is something
* else carries a different value, so nothing here interprets it. A JSON boolean or number
* is rendered the way the client would have written it.
*/
function permissionValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'boolean') return value ? 'True' : 'False'
if (typeof value === 'number') return String(value)
return ''
}
/**
* Parse the subroom-permissions PUT body: a JSON ARRAY of
* `{ Permission, Role, Override, Type, Value }` entries.
*
* `Override` is the client's checkbox, not data see {@link setSubRoomPermissions}: true
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
* pair falls back to the default. It is carried through as sent.
*
* Entries without a permission name or a usable role are dropped rather than rejected
* the client ignores the response either way, so half a table applied beats none.
*/
function parseRoomPermissions(body: unknown): RoomPermission[] {
if (!Array.isArray(body)) return []
const permissions: RoomPermission[] = []
for (const entry of body) {
if (typeof entry !== 'object' || entry === null) continue
const e = entry as Record<string, unknown>
const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
const role = parseInt10(e.Role)
if (permission === '' || role === undefined) continue
permissions.push({
Permission: permission,
Role: role,
// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
Type: parseInt10(e.Type) ?? 0,
Value: permissionValue(e.Value),
})
}
return permissions
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -256,6 +378,63 @@ async function pushRoomUpdate(
}
}
/**
* `reportCategory` on a moderation frame. -1 is "Moderator" the category for an
* action a person took rather than one the system inferred, which is what a room ban
* is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
* 7 Underage, 10 VoteKick, 100104 CoC_*, 200 InappropriateClothing.
*/
const REPORT_CATEGORY_MODERATOR = -1
/**
* Eject a player from the room they're in a `ModerationKick` push (id 22), the frame
* the client acts on to remove someone. Sent on a ban: the row keeps them out of future
* matchmakes, this gets them out of the instance they're in right now.
*
* The payload is the client's moderation shape, camelCase, in wire order:
* `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
* `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
* expiry it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
* tracks one).
*
* `isHostKick` says the room's HOST ejected the player, as opposed to the room
* majority vote-kicking them. There is no vote-kick path yet, so the only false case
* here is a staff moderator acting in a room they don't host. `playerIdReporter` is
* whoever caused it the host today, and the player who started the vote once
* vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
*
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
* already committed, so a hub hiccup must not fail the request.
*/
async function pushRoomBan(
c: Context<App>,
ban: RoomBan,
roomName: string,
isHostKick: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
ban.BannedPlayerId,
NotificationType.ModerationKick,
{
reportCategory: REPORT_CATEGORY_MODERATOR,
duration: 0,
gameSessionId: 0,
isHostKick,
message: `You have been banned from ${roomName}.`,
playerIdReporter: ban.BannedByAccountId,
isBan: true,
isVoiceModAutoban: false,
}
)
} catch (err) {
logger.error('failed to push ModerationKick notification', {
playerId: ban.BannedPlayerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
@@ -304,6 +483,12 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
}
/**
* The same envelope for the ban write, whose `value` is the BAN rather than the room
* a ban isn't part of the room the client renders, so there is no updated room to send.
*/
const banEnvelope = roomEnvelope
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
@@ -410,19 +595,27 @@ const app = new Hono<App>()
}
)
// "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.
// "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.
.get(
'/rooms/hot',
describeRoute({
tags: ['Discovery'],
summary: 'The “hot” rooms feed',
description: [
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
'(the browse screens filter chips post one, e.g. `rro`).',
'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.',
].join(' '),
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
parameters: [
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
...pageParams(100),
],
responses: { 200: json(PagedRooms, 'The feed page') },
}),
async (c) => {
@@ -672,6 +865,45 @@ 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(
@@ -841,6 +1073,9 @@ const app = new Hono<App>()
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
// Shape before availability, so a rejected name costs no D1 read.
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) return roomEnvelope(c, null, badName)
if (await getRoomByName(c.env.DB, name)) {
return roomEnvelope(c, null, 'A room with that name already exists!')
}
@@ -965,6 +1200,12 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!',
})
}
// Same ErrorId as the empty case — the client keys off it to mark the field, and
// both are the name being unusable. The sentence is what tells them which.
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
// Reject if a different room already uses this name (case-insensitive).
const existing = await getRoomByName(c.env.DB, name)
@@ -1202,6 +1443,179 @@ const app = new Hono<App>()
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
// Answers a BARE array (not the room-write envelope), newest ban first.
.get(
'/rooms/:roomId{[0-9]+}/bans',
describeRoute({
tags: ['Room settings'],
summary: 'A rooms ban list',
description: [
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
'exactly like issuing a ban: the rooms creator or a co-owner, or an account whose',
'token carries the `developer` / `moderator` role. A ban list says who a rooms',
'owner has had trouble with, so it is not public.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
'and the entries are camelCase with a different field set: no room id (the path',
'already says which room) and no ban mask. An unknown room is an empty list rather',
'than an error — it reads the same as a room nobody is banned from.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(RoomBanEntryDto.array(), 'The rooms bans, newest first'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// No room → nothing banned. Same answer as a room with an empty ban list, so
// this doesn't become a way to probe which room ids exist.
if (!room) return c.json([])
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const bans = await getRoomBans(c.env.DB, roomId)
return c.json(
bans.map((ban) => ({
accountId: ban.BannedPlayerId,
bannedByAccountId: ban.BannedByAccountId,
banStartTime: ban.CreatedAt,
}))
)
}
)
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
// gated to the room's owner/co-owner OR a staff token (403). One row per
// (room, player) — re-banning rewrites it, so the call is idempotent.
.post(
'/rooms/:roomId{[0-9]+}/bans',
describeRoute({
tags: ['Room settings'],
summary: 'Ban a player from a room',
description: [
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
'someone already banned rewrites their row rather than adding a second. The row is',
'what the `match` worker checks: a banned players matchmake into this room is',
'refused with errorCode 55 and never gets a Photon room id.',
'',
'Gated to the rooms creator or a co-owner, OR to any account whose token carries the',
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
'yourself, or banning someone who can manage the room, is refused: otherwise a',
'co-owner could ban the owner out of their own room.',
'',
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
'what it selects is not known yet. It defaults to 0 when absent.',
'',
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
'the client acts on to eject someone — so a ban takes effect immediately rather than',
'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
'',
'`isHostKick` means the rooms HOST ejected them rather than the room majority',
'vote-kicking them; with no vote-kick path yet the only false case is a staff',
'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
'it — the host today, the player who started the vote once vote-kicks exist. The hub',
'queues the frame if they are offline.',
'',
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
'but `value` is the BAN, not the room — a ban is not part of the room the client',
'renders. This shape is unverified against the real service.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(BanRequest, 'The player to ban'),
responses: {
200: json(RoomBanEnvelope, 'The stored ban, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return banEnvelope(c, null, 'This room does not exist!')
// The room's own owners, or a staffer acting across rooms. Roles are only
// looked up when the cheaper room check fails. The room's own owner IS the
// host, which is what the kick frame's `isHostKick` reports.
const isHostKick = canManageRoom(room, accountId)
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const bannedPlayerId = Number.parseInt(str(body.id), 10)
if (Number.isNaN(bannedPlayerId)) {
return banEnvelope(c, null, 'You must provide a valid player to ban!')
}
if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!')
// Without this a co-owner could ban the room's creator out of their own room.
if (canManageRoom(room, bannedPlayerId)) {
return banEnvelope(c, null, 'You cannot ban an owner of this room!')
}
// Absent or unparseable → 0, the value the client sends.
const banMask = Number.parseInt(str(body.banMask), 10) || 0
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
// The banned player is told, not the caller — their client acts on the kick.
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, roomName, isHostKick)
return banEnvelope(c, ban)
}
)
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403).
.delete(
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Unban a player from a room',
description: [
'Removes the players `room_ban` row, so they can matchmake into the room again.',
'Gated exactly like issuing a ban: the rooms creator or a co-owner, or an account',
'whose token carries the `developer` / `moderator` role.',
'',
'Unbanning someone who is not banned is a rejection (`success: false`), not a silent',
'success — the caller asked to undo something that was not there.',
'',
'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No',
'notification is pushed: nothing tells a player their ban was lifted.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, bannedPlayerIdParam],
responses: {
200: json(RoomBanEnvelope, 'The removed ban, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return banEnvelope(c, null, 'This room does not exist!')
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
return banEnvelope(c, removed)
}
)
// Set a room's content warning: the `WarningMask` bit flags plus an optional
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
// the `warningMask` form field (an integer) and an optional `customWarning` string
@@ -1349,24 +1763,31 @@ const app = new Hono<App>()
}
)
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
// form field plus optional `title`/`subtitle`. Appends one
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
// room in the `{ success, error, value }` envelope.
// Set a room's load screen (`LoadScreens[]` — the image shown while the room loads).
// Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field
// plus optional `title`/`subtitle`. REPLACES the list with the single posted
// `{ ImageName, Title, Subtitle }` and returns the updated room in the
// `{ success, error, value }` envelope.
//
// The field is an array because the client's parser wants one, but the client only
// ever renders (and only ever posts) a single screen — appending left the old screen
// in slot 0 and the new one unreachable behind it, so setting a load screen appeared
// to do nothing. Kept as an array so multi-screen support can land without a
// migration.
.put(
'/rooms/:roomId{[0-9]+}/loadscreen',
describeRoute({
tags: ['Room settings'],
summary: 'Add a load screen to a room',
summary: 'Set a rooms load screen',
description: [
'APPENDS one `{ ImageName, Title, Subtitle }` to the rooms `LoadScreens` the images',
'shown while the room loads. There is no remove or replace counterpart. Owner or',
'co-owner only (403 otherwise).',
'REPLACES the rooms `LoadScreens` with the single posted `{ ImageName, Title,',
'Subtitle }` — the image shown while the room loads. The field is an array (the',
'clients parser expects one) but the client only supports a single screen, so this',
'never appends. Owner or co-owner only (403 otherwise).',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(LoadScreenRequest, 'The load screen to append'),
requestBody: form(LoadScreenRequest, 'The load screen to set'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
@@ -1390,8 +1811,8 @@ const app = new Hono<App>()
const title = typeof body.title === 'string' ? body.title : ''
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
// The posted screen becomes the whole list — the client shows one load screen.
const loadScreens = [{ ImageName: imageName, Title: title, Subtitle: subtitle }]
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
@@ -1665,6 +2086,10 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!',
})
}
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) {
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
}
const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
@@ -1820,6 +2245,67 @@ const app = new Hono<App>()
}
)
// Set a subroom's permission overrides — what each role may do in that subroom. The
// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
// is the client's checkbox, so true stores the entry for that pair and false clears it
// back to the default. The stored table then overwrites the matching defaults in
// `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other
// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
// so there is no envelope to match.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subrooms permissions',
description: [
'Stores the permission entries a rooms creator changed for one subroom — who may',
'save inventions, invite players, use the delete-all button, and so on. The body is a',
'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
'a pair overwrites the stored entry rather than adding a second, and pairs that were',
'never sent are left alone.',
'',
'`Override` is the checkbox the client draws beside each permission, not data:',
'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads',
'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept',
'verbatim, since not every permissions UI is a True/False picker.',
'',
'What this feeds is `GET /photon_access_token`: a stored entry replaces the default',
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
'and one naming a pair the defaults dont carry (e.g. `CAN_INVITE`) is added to it.',
'The overrides apply to the subroom the caller is standing in, resolved from presence.',
'',
'Creator-only — co-owners may build in a room but not decide what a role may do.',
'The response body is EMPTY: the client doesnt read one.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
responses: {
200: { description: 'Stored (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room or subroom' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't be written.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
return c.body(null, 200)
}
)
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
@@ -1912,6 +2398,8 @@ const app = new Hono<App>()
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
if (badName !== null) return roomEnvelope(c, null, badName)
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
@@ -2041,8 +2529,7 @@ const app = new Hono<App>()
}
)
// Photon access token + room permissions the client needs to spawn into a
// room. The client calls it on the rooms host both bare and under `/roomserver`.
// Photon access token + room permissions the client needs to spawn into a room.
.get(
'/photon_access_token',
describeRoute({
@@ -2065,23 +2552,6 @@ const app = new Hono<App>()
}),
handlePhotonAccessToken
)
.get(
'/roomserver/photon_access_token',
describeRoute({
tags: ['Session'],
summary: 'Photon token + room permissions (legacy path)',
description: [
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
'`/roomserver` prefix, so both forms are registered.',
].join(' '),
security: AUTHED,
responses: {
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
401: UNAUTHORIZED_RESPONSE,
},
}),
handlePhotonAccessToken
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
+793 -33
View File
@@ -13,6 +13,7 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { NotificationType } from '../../../../notify/src/notification-types'
import importRooms from '../../../static/ImportRooms.json'
import type { Env } from '../../context'
@@ -31,10 +32,12 @@ function b64url(input: ArrayBuffer | string): string {
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: string): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none — what a plain player's looks like to the role gates.
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -58,6 +61,25 @@ 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', () => {
@@ -260,6 +282,62 @@ 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)
@@ -293,6 +371,49 @@ 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`)
@@ -304,6 +425,66 @@ 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)
@@ -558,6 +739,21 @@ describe('rooms endpoints', () => {
}
})
const postForm = async (
path: string,
fields: Record<string, string>,
sub?: string,
roles?: string[]
) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub ? await bearer(sub, roles) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
@@ -745,6 +941,200 @@ describe('rooms endpoints', () => {
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
})
it('POST /rooms/:id/bans is gated to the rooms owners or staff, and persists', async () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const bansOf = async (roomId: number) =>
(
await env.DB.prepare(
'SELECT banned_player_id, ban_mask, banned_by_account_id FROM room_ban WHERE room_id = ?1'
)
.bind(roomId)
.all<{ banned_player_id: number; ban_mask: number; banned_by_account_id: number }>()
).results
// No token → 401 (auth gate).
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' })).status).toBe(401)
// A valid token, no role on the room and no staff role → 403.
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await postForm('/rooms/99999/bans', { banMask: '0', id: '205' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// The owner bans player 205 — the real client body.
const ok = await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({
success: true,
error: '',
value: { RoomId: 2, BannedPlayerId: 205, BanMask: 0, BannedByAccountId: 1 },
})
expect(await bansOf(2)).toEqual([
{ banned_player_id: 205, ban_mask: 0, banned_by_account_id: 1 },
])
// Re-banning rewrites the one row rather than appending a second.
expect((await postForm('/rooms/2/bans', { banMask: '7', id: '205' }, '2')).status).toBe(200)
expect(await bansOf(2)).toEqual([
{ banned_player_id: 205, ban_mask: 7, banned_by_account_id: 2 },
])
// A staff token bans in a room they have no role on.
const byStaff = await postForm('/rooms/2/bans', { id: '206' }, '999', [
'gameClient',
'moderator',
])
expect(byStaff.status).toBe(200)
// banMask defaults to 0 when the field is absent.
expect(await envOf(byStaff)).toMatchObject({ value: { BannedPlayerId: 206, BanMask: 0 } })
// Refusals: no id, yourself, and an owner of the room (a co-owner must not be
// able to ban the creator out of their own room).
expect(await envOf(await postForm('/rooms/2/bans', { id: 'nope' }, '1'))).toMatchObject({
success: false,
value: null,
})
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '1'))).toMatchObject({
success: false,
error: 'You cannot ban yourself!',
})
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '2'))).toMatchObject({
success: false,
error: 'You cannot ban an owner of this room!',
})
// Nothing was written by any of the refusals.
expect(await bansOf(2)).toHaveLength(2)
})
it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sentSince = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// The room's current name, read rather than hardcoded — earlier tests rename it.
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
// from the instance they're in now; the row keeps them out of future matchmakes.
// Asserted against the enum rather than a literal: the ids are notify's to change.
expect(await sentSince()).toEqual([
{
playerId: 207,
notificationType: NotificationType.ModerationKick,
// The client's moderation payload, camelCase, in wire order.
data: {
reportCategory: -1, // Moderator — a person acted, not the system
duration: 0, // a room ban has no expiry
gameSessionId: 0,
// The host ejected them (as opposed to a room vote-kick, which doesn't
// exist yet). Account 1 owns RecCenter, so it hosts it.
isHostKick: true,
message: `You have been banned from ${Name}.`,
playerIdReporter: 1,
isBan: true,
isVoiceModAutoban: false,
},
},
])
// A staff moderator doesn't host the room, so it isn't a host kick — and
// `playerIdReporter` is still whoever caused it.
await hub().fetch('http://do/all', { method: 'DELETE' })
expect(
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
).toBe(200)
expect((await sentSince())[0]).toMatchObject({
playerId: 208,
data: { isHostKick: false, playerIdReporter: 999 },
})
})
it('GET /rooms/:id/bans lists the rooms bans, under the same gate', async () => {
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
const list = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
// No token → 401; a valid token with no room role and no staff role → 403.
expect((await list('/rooms/3/bans')).status).toBe(401)
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
const res = await list('/rooms/3/bans', '1')
expect(res.status).toBe(200)
// A bare array in the client's camelCase shape — no room id, no ban mask.
const bans = (await res.json()) as Entry[]
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
expect(bans[0]).toEqual({
accountId: expect.any(Number),
bannedByAccountId: 1,
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
})
// A staffer can read a list for a room they have no role on.
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
// An unknown room reads the same as a room with nobody banned — no probing which
// room ids exist.
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
})
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
const del = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'DELETE',
headers: sub ? await bearer(sub, roles) : {},
})
const isBanned = async (roomId: number, playerId: number) =>
(await env.DB.prepare(
'SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2'
)
.bind(roomId, playerId)
.first()) !== null
// Two bans to lift: one removed by the owner, one by a staffer.
expect((await postForm('/rooms/2/bans', { id: '305' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '306' }, '1')).status).toBe(200)
// No token → 401; a valid token with no room role and no staff role → 403.
expect((await del('/rooms/2/bans/305')).status).toBe(401)
expect((await del('/rooms/2/bans/305', '999')).status).toBe(403)
expect(await isBanned(2, 305)).toBe(true)
// Unknown room → failure envelope.
expect(await envOf(await del('/rooms/99999/bans/305', '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
// The owner lifts it; the removed ban comes back as `value`.
const ok = await del('/rooms/2/bans/305', '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({
success: true,
error: '',
value: { RoomId: 2, BannedPlayerId: 305 },
})
expect(await isBanned(2, 305)).toBe(false)
// Unbanning someone who isn't banned is a rejection, not a silent success.
expect(await envOf(await del('/rooms/2/bans/305', '1'))).toMatchObject({
success: false,
error: 'This player is not banned from this room!',
value: null,
})
// A staff token may lift a ban in a room they have no role on.
expect((await del('/rooms/2/bans/306', '999', ['gameClient', 'developer'])).status).toBe(200)
expect(await isBanned(2, 306)).toBe(false)
})
it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401)
@@ -868,7 +1258,7 @@ describe('rooms endpoints', () => {
expect(typeof room.SupportsMobile).toBe('boolean')
})
it('PUT /rooms/:id/loadscreen appends a load screen (auth-gated, owner/co-owner-only)', async () => {
it('PUT /rooms/:id/loadscreen replaces the load screen (auth-gated, owner/co-owner-only)', async () => {
const screensOf = async (): Promise<Array<Record<string, unknown>>> => {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
LoadScreens?: Array<Record<string, unknown>>
@@ -888,10 +1278,8 @@ describe('rooms endpoints', () => {
success: false,
})
const before = (await screensOf()).length
// Owner adds one (imageName + title + subtitle) — appended, and the success
// envelope carries the updated room.
// Owner sets one (imageName + title + subtitle) — the success envelope carries the
// updated room, and the posted screen is the ONLY entry.
const added = await envOf(
await putForm(
'/rooms/2/loadscreen',
@@ -900,18 +1288,17 @@ describe('rooms endpoints', () => {
)
)
expect(added).toMatchObject({ success: true })
expect(added.value?.LoadScreens as unknown[]).toContainEqual({
ImageName: 'sharecamera/2026-07-15/abc.jpg',
Title: 'asdf',
Subtitle: 'sdf',
})
expect(await screensOf()).toHaveLength(before + 1)
expect(added.value?.LoadScreens).toEqual([
{ ImageName: 'sharecamera/2026-07-15/abc.jpg', Title: 'asdf', Subtitle: 'sdf' },
])
expect(await screensOf()).toHaveLength(1)
// A second call appends rather than replacing; title/subtitle default to empty.
// A second call REPLACES rather than appending (the client renders one screen, so
// an appended one would sit unreachable behind the old); title/subtitle default to
// empty when omitted.
const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2'))
expect(co).toMatchObject({ success: true })
expect(await screensOf()).toHaveLength(before + 2)
expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' })
expect(await screensOf()).toEqual([{ ImageName: 'second.jpg', Title: '', Subtitle: '' }])
})
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
@@ -1433,13 +1820,10 @@ describe('rooms endpoints', () => {
})
it('GET /photon_access_token 401s without a token', async () => {
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
const res = await SELF.fetch(`${ORIGIN}${path}`)
expect(res.status).toBe(401)
}
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
})
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => {
it('GET /photon_access_token returns permissions + presence instance', async () => {
// Seed the caller's presence so RoomInstanceId reflects their current instance.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
@@ -1450,9 +1834,7 @@ describe('rooms endpoints', () => {
})
)
.run()
const headers = await bearer('777')
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
expect(res.status).toBe(200)
const body = (await res.json()) as {
Permissions: Array<{ Permission: string; Role: number }>
@@ -1462,10 +1844,9 @@ describe('rooms endpoints', () => {
expect(body.Permissions.length).toBe(11)
expect(body.RoomInstanceId).toBe(1000042)
// A non-dev account does NOT get the global (Role 0) maker pen.
expect(
body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)
).toBe(false)
}
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe(
false
)
})
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
@@ -1526,6 +1907,55 @@ 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')
@@ -1596,7 +2026,7 @@ describe('rooms endpoints', () => {
})
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
const fields = { name: 'My Cool Subroom', accessibility: '1', maxPlayers: '20' }
const fields = { name: 'MyCoolSubroom', accessibility: '1', maxPlayers: '20' }
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → NotOwner.
@@ -1626,7 +2056,7 @@ describe('rooms endpoints', () => {
Accessibility: number
MaxPlayers: number
}
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
expect(sub).toMatchObject({ Name: 'MyCoolSubroom', Accessibility: 1, MaxPlayers: 20 })
})
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
@@ -1676,6 +2106,254 @@ describe('rooms endpoints', () => {
expect(await accessibilityOf()).toBe(1)
})
// The permission table a room's creator saves on a subroom, and how it reaches the
// client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
// `GET /photon_access_token` merges them over its defaults for whoever is standing in
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor
// whose presence points at it.
describe('subroom permissions', () => {
type Permission = { Permission: string; Role: number; Override: boolean; Value: string }
const putPermissions = async (path: string, body: unknown, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
// Put a player in an instance of the given subroom, then read the permission table
// the client would apply when it spawns there.
const permissionsIn = async (accountId: number, subRoomId: number): Promise<Permission[]> => {
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId },
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer(String(accountId)),
})
expect(res.status).toBe(200)
return ((await res.json()) as { Permissions: Permission[] }).Permissions
}
const entry = (list: Permission[], permission: string, role: number) =>
list.find((p) => p.Permission === permission && p.Role === role)
it('is auth-gated and creator-only', async () => {
const body = [
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
]
// No token → 401.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
// A valid token that isn't the room's creator → 403.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe(
403
)
// Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may
// build in a room but don't decide what a role may do.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403)
// Unknown room / unknown subroom → 404.
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe(
404
)
// A subroom id belonging to another room doesn't resolve either.
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe(
404
)
})
it('answers an empty 200 — the client reads no body', async () => {
const res = await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
})
it('a checked Override replaces the matching default in place', async () => {
const before = await permissionsIn(743, 2)
expect(before.length).toBe(11)
const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30)
// The default for this pair is an un-overridden grant.
expect(before[at]).toMatchObject({ Override: false, Value: 'True' })
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Type: 0,
Value: 'False',
},
],
'1'
)
).status
).toBe(200)
const after = await permissionsIn(743, 2)
// Replaced, not appended — and at the same index, so the table doesn't reshuffle.
expect(after.length).toBe(11)
expect(after[at]).toMatchObject({
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Value: 'False',
})
// Re-sending the same (Permission, Role) updates that entry rather than adding one.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
const changed = await permissionsIn(743, 2)
expect(changed.length).toBe(11)
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' })
})
it('an unchecked Override erases the entry, back to the default', async () => {
const stored = async () =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM subroom_permission
WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30`
).first<{ n: number }>())!.n
// The previous test left this pair overridden.
expect(await stored()).toBe(1)
// `Override: false` means "fall back to the default" — the `Value` riding along is
// not stored, it's whatever the picker happened to show.
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: false,
Type: 0,
Value: 'True',
},
],
'1'
)
).status
).toBe(200)
// The row is gone, and the token serves the default for the pair again.
expect(await stored()).toBe(0)
const table = await permissionsIn(743, 2)
expect(table.length).toBe(11)
expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({
Override: false,
Value: 'True',
})
// Clearing a pair that was never overridden is a no-op, not an insert.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
'1'
)
expect((await permissionsIn(743, 2)).length).toBe(11)
})
it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => {
// CAN_INVITE is in none of the defaults, so it lands as a new entry.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }],
'1'
)
const inSubRoom2 = await permissionsIn(744, 2)
expect(inSubRoom2.length).toBe(12)
expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({
Override: true,
Value: 'False',
})
// A different subroom is untouched — the table is per-subroom, not per-room.
expect((await permissionsIn(744, 3)).length).toBe(11)
// And so is a player in no instance at all.
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer('744'),
})
expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11)
})
it('keeps a Value that isnt True/False verbatim', async () => {
// Not every permission's UI is the True/False picker, so nothing interprets the
// string — it goes to the client exactly as the creator set it.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
'1'
)
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({
Override: true,
Value: '25',
})
})
it('applies over the dev accounts global maker pen, without listing a pair twice', async () => {
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' },
// The third sample body — a Role 0 grant the defaults already carry.
{
Permission: 'CAN_USE_DELETE_ALL_BUTTON',
Role: 0,
Override: true,
Type: 0,
Value: 'True',
},
],
'1'
)
// Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0)
// maker pen prepended — which this subroom then revokes. The merge runs last and
// replaces it in place, so the pair appears exactly ONCE: a table listing it twice
// with two values would leave which one applies up to the client.
const devTable = await permissionsIn(3, 2)
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' },
])
expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' })
// A normal player in the same subroom sees the same revocation.
expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({
Value: 'False',
})
})
it('a cloned subroom inherits the sources permission table', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } }
const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
const inClone = await permissionsIn(746, cloneId)
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' })
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' })
})
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
@@ -1821,10 +2499,10 @@ describe('rooms endpoints', () => {
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'to-delete' }).toString(),
body: new URLSearchParams({ name: 'ToDelete' }).toString(),
})
).json()) as { value: { SubRooms: SubRoom[] } }
const newId = created.value.SubRooms.find((s) => s.Name === 'to-delete')!.SubRoomId
const newId = created.value.SubRooms.find((s) => s.Name === 'ToDelete')!.SubRoomId
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
expect((await del(2, newId)).status).toBe(401)
@@ -1922,6 +2600,7 @@ describe('rooms endpoints', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
@@ -1939,13 +2618,15 @@ describe('rooms endpoints', () => {
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/photon_access_token',
'GET /roomserver/rooms/createdby/me',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
@@ -1963,6 +2644,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning',
])
@@ -1974,3 +2656,81 @@ describe('rooms endpoints', () => {
}
})
})
// Room and subroom names are held to the same rule as usernames — letters and digits,
// at most 32 (see `nameRejection` in @repo/domain). All four routes that take a
// player-supplied name enforce it, and each keeps its OWN refusal shape: the create
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
// already used for an empty name. The client keys off those, so the rule had to fit the
// existing shapes rather than introduce a fifth one.
//
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
// which this rule would reject. That's why the check lives in the handlers.
describe('room name validation', () => {
const bad = ['My Room', 'under_score', 'punct!', 'a'.repeat(33)]
const post = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
const put = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
it('refuses a bad name when a player clones a room into existence', async () => {
for (const name of bad) {
const res = await post('/rooms/2/clone', { name }, '1')
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
expect(body.success, name).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 32 characters/)
expect(body.value).toBeNull()
}
})
it('refuses a bad name on rename, with the id the client already handles', async () => {
for (const name of bad) {
const res = await put('/rooms/2/name', { name }, '1')
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
expect(body.Success, name).toBe(false)
expect(body.ErrorId).toBe('Rooms.InvalidName')
expect(body.Error).toMatch(/letters and numbers|at most 32 characters/)
}
// Unchanged: the refusals above never reached the write.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
expect(room.Name).not.toMatch(/[^A-Za-z0-9]/)
})
it('refuses a bad name when creating or modifying a subroom', async () => {
for (const name of bad) {
const created = await post('/rooms/2/subrooms', { name }, '1')
const env1 = (await created.json()) as { success: boolean; error: string }
expect(env1.success, name).toBe(false)
expect(env1.error).toMatch(/letters and numbers|at most 32 characters/)
const modified = await put(
'/rooms/2/subrooms/2/modify',
{ name, accessibility: '1', maxPlayers: '20' },
'1'
)
const res2 = (await modified.json()) as { Success: boolean; ErrorId: string }
expect(res2.Success, name).toBe(false)
expect(res2.ErrorId).toBe('Rooms.InvalidName')
}
})
it('accepts a 32-character alphanumeric name', async () => {
const name = 'a'.repeat(32)
const res = await post('/rooms/2/subrooms', { name }, '1')
const body = (await res.json()) as { success: boolean; value: { SubRooms: Array<{ Name: string }> } }
expect(body.success).toBe(true)
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
})
})
+15 -1
View File
@@ -21,11 +21,25 @@ export default defineConfig({
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET /all for the whole list, DELETE to
// reset it between assertions.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
async notifyPlayer() { return { delivered: 0, queued: true } }
sent = []
async notifyPlayer(playerId, notificationType, data) {
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
async fetch(request) {
if (request.method === 'DELETE') {
this.sent = []
return new Response(null, { status: 204 })
}
return Response.json(this.sent)
}
}
export default { fetch() { return new Response('ok') } }
`,
+8 -2
View File
@@ -35,7 +35,7 @@ import type { App } from './context'
*/
const UPLOAD_SUBFOLDER: Record<number, string> = {
1: 'room',
2: 'holotar',
2: 'data',
3: 'image',
4: 'video',
5: 'invention',
@@ -150,8 +150,14 @@ const app = new Hono<App>()
// does the extension, which is why it goes on the key, not just the name.
const datePrefix = new Date().toISOString().slice(0, 10)
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
const bytes = await file.arrayBuffer()
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
httpMetadata: { contentType: file.type || 'application/octet-stream' },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
// only a checksum given at put time is readable later — this lets the `api`
// worker answer one from a HEAD instead of downloading the blob to digest it.
sha256: await crypto.subtle.digest('SHA-256', bytes),
})
return c.json({ filename })
}
+47 -1
View File
@@ -25,8 +25,9 @@ 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` (account id + password) |
| POST | `/api/login` | auth `POST /connect/token` (username + password) |
| POST | `/api/logout` | clears the session cookie |
| GET | `/api/me` | accounts `GET /account/me` |
| POST | `/api/email` | accounts `POST /account/me/email` |
@@ -35,6 +36,51 @@ 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
+92
View File
@@ -0,0 +1,92 @@
/**
* What a refused `auth` `/connect/token` grant means to somebody filling in a form.
*
* Shared by the worker and the browser, because the two halves of the site refuse in
* different places and have to say the same thing. Signup is refused SERVER-side (it
* goes through www for the Turnstile check see www.app.ts), while sign-in is refused
* by `auth` directly, which the SPA calls itself. Without this shared table the second
* one would put a bare OAuth code on screen.
*
* No runtime dependencies, so it's safe to pull into the client bundle.
*/
/** Which grant was being made, so a shared refusal reads right on either form. */
export type AuthAction = 'signup' | 'login'
/**
* Keyed on the exact `error_description` auth sends (see its `/connect/token` handler).
* The platform arms aren't reachable from the web today signup posts `create_account`
* with no platform, sign-in posts `password` but they're mapped anyway so a future web
* flow that does assert one can't regress to a bare code.
*/
const AUTH_MESSAGES: Record<string, string> = {
'too many accounts created from this network':
'Too many accounts have already been created from your network. Try again later, or from a different connection.',
'account limit reached for this platform account':
'This platform account has already created as many accounts as it is allowed.',
'invalid account_id or password': 'That username or password is incorrect.',
'account_id or username is required': 'Username and password are required.',
'invalid or missing platform_auth': 'Your platform sign-in could not be verified.',
'unsupported platform; only Steam and Meta can be verified':
'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
}
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
const GENERIC_MESSAGES: Record<AuthAction, { rejected: string; broken: string }> = {
signup: {
rejected: 'Your account could not be created. Please check your details and try again.',
broken:
'Accounts cannot be created right now. This is a problem on our end — please try again later.',
},
login: {
rejected: 'You could not be signed in. Please check your details and try again.',
broken:
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
},
}
/** Message for an `auth` that couldn't be reached at all (the request itself threw). */
export const authUnreachable = (action: AuthAction): string => GENERIC_MESSAGES[action].broken
/** A rejected `/connect/token` grant, translated. */
export interface AuthFailure {
/** The sentence to put in front of the player. */
message: string
/** 400 when the grant was refused, 502 when `auth` itself couldn't proceed. */
status: 400 | 502
/** The raw `error`/`error_description` pair, for the operator's log line only. */
upstream: string
}
/**
* Translate a refusal auth has already answered.
*
* auth answers the OAuth shape `{ error: 'invalid_grant', error_description: … }`
* where `error` is one of three machine codes and the DESCRIPTION carries the actual
* reason. Showing that body verbatim put "invalid_grant" on screen for every failure,
* including the ones a player can act on (the per-network signup cap). An unrecognised
* description falls back to the generic line for the action rather than leaking whatever
* it did say those are written for an operator.
*/
export function authFailure(
action: AuthAction,
status: number,
code: string,
description: string
): AuthFailure {
// A 5xx (or a `server_error`) is an operator misconfiguration — an unset JWT_SECRET,
// an unset META_APP_SECRET — not something the player got wrong. Don't send them back
// to re-check a form that was fine; the real reason is in auth's log, not theirs.
const broken = status >= 500 || code === 'server_error'
const generic = GENERIC_MESSAGES[action]
return {
message:
(!broken && AUTH_MESSAGES[description]) || (broken ? generic.broken : generic.rejected),
status: broken ? 502 : 400,
upstream: description ? `${code || 'unknown'}: ${description}` : code || `HTTP ${status}`,
}
}
+804 -98
View File
@@ -1,41 +1,312 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages'
import {
DISCORD_INVITE,
DOWNLOAD_URL,
LICENSE_URL,
QUEST_DOWNLOAD_URL,
SOURCE_REPO,
} from '../links'
import type { ReactNode } from 'react'
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
/**
* The SPA calls the SAME endpoints the game does `auth` for tokens and the password
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
* broadcasts rather than proxying each one through `www`, exactly as rec.net's own
* site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the
* access token lives here in the browser.
*
* `www` serves only two things of its own (see www.app.ts): the config below, and
* signup, which is Turnstile-gated and so cannot leave the server.
*/
/** Where each worker lives. From `/api/config`, never baked into this build. */
interface Hosts {
auth: string
accounts: string
api: string
img: string
notify: string
}
/**
* Site config from `www`. `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
}
/** The private self DTO from `accounts` (`GET /account/me`). */
interface SelfAccount {
accountId: number
username: string
displayName: string
email: string | null
/** Whether this session may use admin controls (from the token's role claim). */
isAdmin?: boolean
/**
* Username changes left on the account each change spends one, and an account
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
* stays usable and lets the server be the one to refuse.
*/
availableUsernameChanges?: number
}
/**
* 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
* mutations use `error`) so callers can surface it.
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet the web signup is the (platform-less) password
* account path.
*/
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
const WEB_PLATFORM = '4'
/**
* The session's access token, in localStorage so a reload stays signed in.
*
* Readable by page JS, which the httpOnly cookie this replaced was not that is the
* tradeoff that comes with the browser calling the workers itself, and it's the same
* posture the game client has. Nothing third-party runs on this origin except the
* Turnstile widget, which is Cloudflare's own.
*/
const TOKEN_KEY = 'rf_token'
let token: string | null = localStorage.getItem(TOKEN_KEY)
function setToken(next: string | null) {
token = next
if (next === null) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, next)
}
/**
* Filled in once `/api/config` lands, before any worker call is made a module value
* rather than a prop threaded through every form, since the components that call a
* worker only render after the config resolves.
*/
let hosts: Hosts | null = null
/** The hostnames, once known. Throws rather than guessing a domain. */
function where(): Hosts {
if (hosts === null) throw new Error('Still starting up — please reload the page.')
return hosts
}
/**
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate
* this only decides whether to SHOW them; notify verifies the token on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/**
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
* verifying it a page holds no signing key, and faking one here only reveals buttons
* whose endpoints reject the same token. A malformed token reads as "not admin".
*/
function isAdmin(): boolean {
const payload = token?.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/**
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence a
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
* `error`, where the readable reason is in `error_description`.
*/
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
/**
* The message worth showing for a refusal. `error` wins, since that's where a worker
* puts a sentence it wrote for the player but NOT when it's a bare OAuth code, which
* tells nobody anything. Some refusals carry no body at all (accounts answers a
* malformed email with an empty 400), hence the last-resort line.
*/
function errorMessage(data: Record<string, unknown>, status: number): string {
const error = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
return (
(error && !(isErrorCode(error) && description) && error) ||
description ||
error ||
`Request failed (${status})`
)
}
interface CallOptions {
method?: 'GET' | 'POST' | 'PUT'
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
form?: Record<string, string>
/** A JSON body — what notify's internal endpoints take instead. */
json?: unknown
/** Send the session token. */
authed?: boolean
/**
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
* do exactly that (email, display name, bio), so without this the player reads
* "Request failed (400)" the status, not the reason.
*/
refusal?: string
}
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (opts.authed && token) headers.authorization = `Bearer ${token}`
let body: string | undefined
if (opts.form) {
headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(opts.form).toString()
} else if (opts.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(opts.json)
}
const res = await fetch(url, {
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
headers,
body,
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const message =
(typeof data.error === 'string' && data.error) ||
(typeof data.error_description === 'string' && data.error_description) ||
`Request failed (${res.status})`
throw new Error(message)
// Expired or revoked. Cleared here so no caller has to remember to.
if (res.status === 401 && opts.authed) {
setToken(null)
throw new Error('Your session has expired. Please sign in again.')
}
// Only when the body really is empty — a worker that did send a reason keeps it.
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
throw new Error(opts.refusal)
}
throw new Error(errorMessage(data, res.status))
}
return data as T
}
/** The signed-in account, straight from `accounts`. */
const fetchMe = (): Promise<SelfAccount> =>
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
/**
* Sign in with auth's password grant, posted directly the way the game posts it. The
* account is resolved by `username` (case-insensitive) web players sign in with their
* username, not the numeric account id.
*
* A refusal is translated through the table shared with the worker (see
* `auth-messages.ts`): auth's `error` is always a machine code, and the reason in
* `error_description` is written for an operator, not a player.
*/
async function signIn(username: string, password: string): Promise<void> {
const res = await fetch(`${where().auth}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
username,
platform: WEB_PLATFORM,
password,
}).toString(),
}).catch(() => null)
if (res === null) throw new Error(authUnreachable('login'))
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const code = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
throw new Error(authFailure('login', res.status, code, description).message)
}
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
setToken(data.access_token)
}
/**
* Create an account the one flow that goes through `www`, because it's gated by
* Turnstile and that check needs a secret key a page can't hold. www hands back auth's
* token response unchanged, so the session is established just as sign-in establishes it.
*/
async function signUp(password: string, turnstileToken: string): Promise<void> {
const data = await call<{ access_token?: string }>('/api/signup', {
json: { password, turnstileToken },
})
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup'))
setToken(data.access_token)
}
/**
* Change the username.
*
* `accounts` answers this one in its own envelope `{ success, error, value }` at HTTP
* 200 even when it refused (taken name, no changes left) so a 200 is not enough to
* call it done. The sentences it writes are already player-facing, so they're shown as-is.
*
* On success the SELF account is re-read rather than using the envelope's `value`: that
* is the PUBLIC DTO, and it carries no `availableUsernameChanges` the very field this
* form needs to know whether another change is left.
*/
async function changeUsername(username: string): Promise<SelfAccount> {
const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, {
method: 'PUT',
form: { username },
authed: true,
})
const refusal = typeof result.error === 'string' ? result.error : ''
if (refusal !== '') throw new Error(refusal)
return fetchMe()
}
/**
* Set the account's email.
*
* The address is NOT checked here first. `accounts` validates it with `isemail`, which
* can't come along into the browser (it reaches for node's `util`, which vite stubs with
* a throwing Proxy in dev) and a second, looser copy of the rule would only disagree
* with the real one. The server decides; this just names the refusal it answers with.
*/
const saveEmail = (email: string): Promise<unknown> =>
call(`${where().accounts}/account/me/email`, {
form: { email },
authed: true,
refusal: 'That email address looks wrong.',
})
/** Change the account's password. Lives on `auth`, not `accounts`. */
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
call(`${where().auth}/account/me/changepassword`, {
form: { oldPassword, newPassword },
authed: true,
})
/**
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
* so a session without the role is rejected there (403) even though the UI shows no
* button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game
* client's ServerMaintenance handler.
*/
const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> =>
call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, {
json: {
notificationType: NotificationType.ServerMaintenance,
data: { StartsInMinutes: startsInMinutes },
},
authed: true,
})
const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, {
json: { messageContent },
authed: true,
})
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
@@ -85,16 +356,37 @@ 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))
// Config first, and everything else after it: it carries the hostnames every other
// call needs. A config that doesn't land leaves the page signed out with signup
// closed rather than guessing where the workers are.
call<SiteConfig & { hosts: Hosts }>('/api/config')
.then(async ({ hosts: resolved, ...site }) => {
hosts = resolved
setConfig(site)
if (token === null) return setAccount(null)
// A stored token that `accounts` rejects is stale — `call` has already dropped
// it, so this just falls back to signed-out rather than surfacing an error.
await fetchMe()
.then(setAccount)
.catch(() => setAccount(null))
})
.catch(() => {
setConfig({ signupEnabled: false, turnstileSiteKey: null })
setAccount(null)
})
}, [])
const logout = useCallback(async () => {
await api('/api/logout', {})
// Nothing to tell a server: the access token is a stateless JWT, so dropping it here
// IS the sign-out. (The refresh token auth issues alongside it is never stored, so a
// closed session leaves nothing behind to redeem.)
const logout = useCallback(() => {
setToken(null)
setAccount(null)
navigate('/')
}, [navigate])
@@ -102,12 +394,22 @@ export function App() {
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' ? (
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
{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 === '/account' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
) : (
<HomePage />
<HomePage account={account} config={config} navigate={navigate} />
)}
<SiteFooter />
</>
@@ -179,6 +481,14 @@ function NavBar({
)
}
/**
* How many photos the hero asks the feed for. Explicit rather than left to the api's
* default, since the count is a design decision here: the stage rotates one photo every
* six seconds, so ten is a minute of it long enough that a repeat visitor sees fresh
* photos, short enough that the arrows stay walkable and the payload stays small.
*/
const SLIDESHOW_TAKE = 10
/** A recent public image plus who took it and where. */
interface Slide {
url: string
@@ -186,16 +496,35 @@ interface Slide {
roomName: string | null
}
/** Loads the public photo feed once. `slides === null` means still in flight. */
function useSlideshow() {
/**
* Loads the public photo feed once. `slides === null` means still in flight.
*
* Waits for the config, since the feed is served by the `api` worker the same public
* endpoint the game reads it from and its hostname arrives with the config. Each entry
* names an image; the browsable URL for it is on the `img` worker.
*/
function useSlideshow(config: SiteConfig | undefined) {
const [slides, setSlides] = useState<Slide[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
api<{ images: Slide[] }>('/api/slideshow')
.then((d) => setSlides(d.images))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
if (config === undefined) return
type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> }
// Wrapped in an async call rather than started directly, because `where()` THROWS
// when the config didn't land — synchronously, which straight out of an effect
// would take the page down instead of leaving an empty stage behind the fold.
void (async () => {
const h = where()
const d = await call<Feed>(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`)
setSlides(
(d.Images ?? []).map((i) => ({
url: `${h.img}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
)
})().catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [config])
return { slides, error }
}
@@ -205,12 +534,25 @@ function useSlideshow() {
* on top of them. Everything about how the thing is built sits below, for whoever
* scrolls looking for it.
*/
function HomePage() {
const feed = useSlideshow()
function HomePage({
account,
config,
navigate,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate
}) {
const feed = useSlideshow(config)
// 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} />
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
<div className="shell home">
<About slides={feed.slides} error={feed.error} />
</div>
@@ -219,23 +561,72 @@ function HomePage() {
}
/**
* 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.
* 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.
*/
function Stage({ slides }: { slides: Slide[] | null }) {
function Stage({
slides,
offerSignup,
navigate,
}: {
slides: Slide[] | null
offerSignup: boolean
navigate: Navigate
}) {
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 (!slides || slides.length < 2) return
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
return () => clearInterval(t)
}, [slides])
if (count < 2) return
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
return () => clearTimeout(t)
}, [count, idx])
const slide = slides && slides.length > 0 ? slides[idx] : null
const step = (by: number) => setIdx((i) => (i + by + count) % count)
return (
<section className="stage">
<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
plain nominative use next to the disclaimer. */}
<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"
@@ -244,47 +635,53 @@ function Stage({ slides }: { slides: Slide[] | null }) {
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
plain nominative use next to the disclaimer. */}
<h1 className="stage-title">
Play like it&apos;s <em>2023</em>.
</h1>
<div className="stage-actions">
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download for PC
</a>
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Join the Discord
</a>
</div>
</div>
{slide && (
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
<div className="stage-foot">
{slide && (
<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}
/>
))}
)}
{/* Arrows and a count, not a dot per photo: a dot each is wide enough to
shove the headline's half of the split off the page, and it would have
to be rebuilt the moment SLIDESHOW_TAKE grows. */}
{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>
)}
</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
@@ -297,8 +694,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 no gatekeeping, no basement
server.
<strong>feature-complete</strong> and infinitely scalable {' '}
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
</p>
</div>
<div className="about-side">
@@ -324,34 +721,85 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
)
}
/** The sign-in page. Redirects to the account page once a session exists. */
/**
* 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.
*/
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">
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.
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={(a) => {
onAuthed(a)
navigate('/account')
}}
/>
<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>
)}
</>
)}
</section>
</main>
)
@@ -409,9 +857,194 @@ function useAction() {
return { pending, error, done, run }
}
// 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.
/**
* 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: widgetToken, error: widgetError, reset } = useTurnstile(siteKey)
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const wanted = email.trim()
try {
await signUp(password, widgetToken)
} catch (err) {
// The widget token is spent either way, so re-arm before they retry. Only
// a failed signup gets here — past this point the account exists, and a
// retry would spend another slot against auth's per-IP cap.
reset()
throw err
}
// Saved with the new session's own token: `create_account` takes no email,
// `accounts` owns the field. Deliberately not fatal — the account exists and
// the session is live, and the same field is one call away on the account
// page.
if (wanted !== '') await saveEmail(wanted).catch(() => {})
// The session is already stored, so a failure here isn't one they can act on
// by retrying: a reload finds them signed in.
const me = await fetchMe().catch(() => {
throw new Error(
'Your account was created, but loading it failed. Reload the page — you are already signed in.'
)
})
onAuthed(me)
return ''
})
}}
>
<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 || widgetToken === ''}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
)
}
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -422,11 +1055,8 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/login', {
username,
password,
})
onAuthed(account)
await signIn(username, password)
onAuthed(await fetchMe())
return ''
})
}}
@@ -469,13 +1099,18 @@ function Dashboard({
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
// sections are appended when the session carries an admin role.
const sections = [
{
id: 'username',
label: 'Username',
render: () => <UsernameForm account={account} onChange={onChange} />,
},
{
id: 'email',
label: 'Email',
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
...(account.isAdmin
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
@@ -528,9 +1163,7 @@ function CoachMessageForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
messageContent: message,
})
const { sent } = await coachMessageAll(message.trim())
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
})
@@ -571,9 +1204,10 @@ function MaintenanceForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
startsInMinutes: Number(minutes),
})
// Coerced the way the worker used to: a blank or negative box means "now".
const asked = Number(minutes)
const startsIn = Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0
const { delivered: connections } = await broadcastMaintenance(startsIn)
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
})
}}
@@ -599,6 +1233,78 @@ function MaintenanceForm() {
)
}
/**
* Change the account's username the name used to sign in, here and in the game.
*
* Changes are rationed (an account starts with one), so the count is stated up front and
* the form locks itself once none are left rather than letting someone spend the attempt
* finding out. The server is still the one that decides: an unknown count leaves the form
* open, and a name taken since the page loaded is refused upstream.
*
* The response is the caller's whole self account, re-read after the write, so the
* remaining count on screen is the stored one and not a guess.
*/
function UsernameForm({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const [username, setUsername] = useState(account.username)
const { pending, error, done, run } = useAction()
const remaining = account.availableUsernameChanges
const spent = remaining !== undefined && remaining <= 0
// Retyping the current name would be refused upstream anyway ("already taken" is
// waived for your own name, but it would still spend a change).
const unchanged = username.trim() === account.username
return (
<section className="card">
<h2>Username</h2>
<p className="muted">
What you sign in with, here and in the game and what other players see you by.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const updated = await changeUsername(username.trim())
onChange(updated)
setUsername(updated.username)
return `You are now @${updated.username}.`
})
}}
>
<label>
Username
<input
type="text"
value={username}
autoComplete="username"
disabled={spent}
onChange={(e) => setUsername(e.target.value)}
required
/>
<span className="hint">
{remaining === undefined
? 'Changing your username uses up one of a limited number of changes.'
: spent
? 'You have no username changes remaining, so this can no longer be changed.'
: `You have ${remaining} username change${remaining === 1 ? '' : 's'} remaining — this one is permanent once used.`}
</span>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || spent || unchanged}>
{pending ? 'Changing…' : 'Change username'}
</button>
</form>
</section>
)
}
function EmailForm({
account,
onChange,
@@ -616,7 +1322,7 @@ function EmailForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/email', { email })
await saveEmail(email.trim())
onChange({ ...account, email })
return 'Email saved.'
})
@@ -654,7 +1360,7 @@ function PasswordForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/password', { oldPassword, newPassword })
await changePassword(oldPassword, newPassword)
setOldPassword('')
setNewPassword('')
return 'Password changed.'
+186 -70
View File
@@ -84,7 +84,7 @@ body {
max-width: 880px;
}
/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */
/* The homepage: the stage above brings its own top space and shares this width. */
.shell.home {
max-width: 1040px;
padding-top: 0;
@@ -153,19 +153,42 @@ body {
/* ---- The stage (hero) --------------------------------------------------- */
/*
* 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.
* 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.
*/
.stage {
position: relative;
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 {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: min(40vh, 320px);
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;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-hi);
isolation: isolate;
}
.stage-photo {
@@ -174,11 +197,6 @@ 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;
}
@@ -188,40 +206,20 @@ 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 {
max-width: 1040px;
width: 100%;
margin: 0 auto;
padding: 0 20px 28px;
min-width: 0;
}
/* 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.4rem);
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1;
letter-spacing: -0.03em;
color: #fff;
margin: 0 0 20px;
max-width: 15ch;
margin: 0 0 16px;
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. */
@@ -230,55 +228,84 @@ body {
color: var(--accent);
}
/* Credit line and slide dots, sitting under the headline on the photo itself. */
.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. */
.stage-foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px 20px;
max-width: 1040px;
width: 100%;
margin: 0 auto;
padding: 0 20px 22px;
gap: 8px 20px;
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
min-height: 28px;
font-size: 0.8rem;
color: rgb(255 255 255 / 72%);
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
color: var(--muted);
}
/* 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 {
/* 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 {
display: flex;
margin: -8px -6px;
align-items: center;
gap: 4px;
flex: none;
}
.dots button {
.steer button {
display: grid;
place-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: none;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.dots button::after {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: rgb(255 255 255 / 34%);
transition: background 0.2s ease;
.steer button:hover {
color: var(--text);
border-color: var(--muted);
}
.dots button:hover::after {
background: rgb(255 255 255 / 65%);
}
.dots button.on::after {
background: var(--accent);
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
.count {
font-variant-numeric: tabular-nums;
padding: 0 6px;
}
/* ---- What it is (below the stage) --------------------------------------- */
@@ -544,6 +571,40 @@ 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 {
@@ -572,6 +633,27 @@ 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);
@@ -579,6 +661,14 @@ 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;
@@ -602,6 +692,22 @@ 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 {
@@ -633,6 +739,15 @@ 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 {
@@ -643,8 +758,9 @@ button[type='submit']:disabled {
}
@media (max-width: 620px) {
.stage {
min-height: min(38vh, 300px);
.stage-actions .cta {
flex: 1 1 auto;
text-align: center;
}
.about-links .cta {
+27
View File
@@ -6,6 +6,33 @@ export type Env = SharedHonoEnv & {
DOMAIN: string
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
ASSETS: Fetcher
/**
* Service binding to the `auth` worker how the BFF reaches it, so the browser's real
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
*
* OPTIONAL because a deployed www always has it (it's declared in wrangler.jsonc) but
* standalone local dev doesn't: `vite dev` runs www on its own against a deployed
* DOMAIN, with no `auth` session to bind to. Absent, `postAuthForm` falls back to
* fetching auth.<DOMAIN> the pre-binding behaviour, correct except for the IP.
*/
AUTH?: 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,6 +13,9 @@ 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/22O3QO7ytn'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
+8 -5
View File
@@ -23,11 +23,14 @@ 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), 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.
* "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.
*/
/** Last substantive revision, shown in the header. Bump when the text changes. */
+200 -27
View File
@@ -1,53 +1,226 @@
import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest'
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
import { postAuthForm, readAuthError } from '../../upstream'
it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me')
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
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('refuses manual signups (disabled)', async () => {
// 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).
//
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
// A build with them missing can't sign anyone in.
it('advertises signup and where the other workers live', 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,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
api: 'https://api.rec.example.com',
img: 'https://img.rec.example.com',
notify: 'https://notify.rec.example.com',
},
})
})
// The BFF proxies are gone: the browser calls those workers itself. Pinned because
// nothing else would fail if one were left behind — a stale proxy keeps working, it just
// re-creates the maintenance burden (and the shared-IP bug) this removed. `/api/signup`
// is the deliberate exception, and it's covered below.
it('no longer proxies the endpoints the game already serves', async () => {
for (const path of [
'/api/me',
'/api/login',
'/api/logout',
'/api/username',
'/api/email',
'/api/password',
'/api/maintenance',
'/api/coach-message',
'/api/slideshow',
]) {
const res = await SELF.fetch(`https://example.com${path}`, { method: 'POST' })
// Falls through to the SPA catch-all, which has no ASSETS binding under test.
expect(res.status, path).toBe(404)
}
})
// 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 () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'whatever' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
// 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.' })
})
it('requires credentials to log in', async () => {
const res = await SELF.fetch('https://example.com/api/login', {
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({ username: 'alice' }),
body: JSON.stringify({ turnstileToken: 'dummy' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
expect(await res.json()).toEqual({ error: 'A password is required.' })
})
it('rejects an unauthenticated maintenance broadcast', async () => {
const res = await SELF.fetch('https://example.com/api/maintenance', {
method: 'POST',
// A refused grant reaches the form as a sentence, never as the OAuth code. auth answers
// `{ error: 'invalid_grant', error_description: <the actual reason> }`, and www used to
// relay that untouched — so every failed signup, including one the player could act on
// (the per-network cap), read simply "invalid_grant". Checked directly because the pass
// path can't be reached from here (it would call the real auth worker).
it('explains a refused signup instead of relaying invalid_grant', async () => {
const refused = (description: string, status = 400) =>
new Response(JSON.stringify({ error: 'invalid_grant', error_description: description }), {
status,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ startsInMinutes: 15 }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
const capped = await readAuthError(
refused('too many accounts created from this network'),
'signup'
)
expect(capped.status).toBe(400)
expect(capped.message).toContain('Too many accounts have already been created from your network')
// The raw pair still reaches the operator's log line.
expect(capped.upstream).toBe('invalid_grant: too many accounts created from this network')
const badPassword = await readAuthError(refused('invalid account_id or password'), 'login')
expect(badPassword.message).toBe('That username or password is incorrect.')
// A description auth grew since this table was written must not leak through as-is:
// it's written for an operator, so an unmapped one falls back to the generic sentence.
const unmapped = await readAuthError(refused('some new internal reason'), 'signup')
expect(unmapped.message).not.toContain('some new internal reason')
expect(unmapped.message).toContain('could not be created')
// Nothing about the form was wrong — auth couldn't proceed (an unset JWT_SECRET). Don't
// send them back to re-check their details, and don't answer 400 for our own fault.
const broken = await readAuthError(
new Response(
JSON.stringify({
error: 'server_error',
error_description: 'token signing is not configured',
}),
{ status: 500, headers: { 'content-type': 'application/json' } }
),
'signup'
)
expect(broken.status).toBe(502)
expect(broken.message).toContain('problem on our end')
// A body from something in front of auth (an edge error page) is not JSON at all.
const html = await readAuthError(new Response('<html>502</html>', { status: 502 }), 'signup')
expect(html.status).toBe(502)
expect(html.message).toContain('problem on our end')
expect(html.upstream).toBe('HTTP 502')
})
it('rejects an unauthenticated coach message', async () => {
const res = await SELF.fetch('https://example.com/api/coach-message', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messageContent: 'hello all' }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
// The signup cap counts auth's `CF-Connecting-IP` as the account's immutable `signupIp`,
// and www used to reach auth over https://auth.<DOMAIN> — a Worker subrequest, which
// re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
// address. Every browser signup therefore shared one IP, and the cap (3, never decaying)
// refused the fourth web account ever created, for everyone. The service binding skips
// the edge, so the header set here is the one auth reads.
//
// Checked directly rather than through /api/signup: the pass path would call Cloudflare's
// siteverify for real (see the Turnstile tests above).
it('carries the browser IP across to auth instead of losing it to the edge', async () => {
const seen: Request[] = []
const withAuth = (fetcher?: Fetcher) =>
({
DOMAIN: 'rec.example.com',
AUTH: fetcher,
}) as unknown as Env
const capture = {
fetch: async (request: Request) => {
seen.push(request)
return new Response('{}', { headers: { 'content-type': 'application/json' } })
},
} as unknown as Fetcher
await postAuthForm(
withAuth(capture),
'/connect/token',
{ grant_type: 'create_account', password: 'hunter2' },
{ clientIp: '203.0.113.7' }
)
// The binding is used in preference to the hostname, and the real IP rides along.
expect(seen).toHaveLength(1)
expect(seen[0]!.headers.get('cf-connecting-ip')).toBe('203.0.113.7')
// Still the same host/path/body auth already answers — only the transport changed.
expect(seen[0]!.url).toBe('https://auth.rec.example.com/connect/token')
const body = await seen[0]!.formData()
expect(body.get('grant_type')).toBe('create_account')
expect(body.get('password')).toBe('hunter2')
// A call with no IP to forward must not invent one: an absent header leaves auth's
// own `clientIp` empty, which SKIPS the cap, rather than counting everyone together.
// Reachable in local dev, where the edge sets no `cf-connecting-ip` to pass on.
await postAuthForm(withAuth(capture), '/connect/token', { grant_type: 'create_account' })
expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull()
})
it('serves the aggregated docs page with a source per documented service', async () => {
+129
View File
@@ -0,0 +1,129 @@
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
}
}
+56 -13
View File
@@ -1,11 +1,13 @@
import { authFailure } from './auth-messages'
import type { AuthAction, AuthFailure } from './auth-messages'
import type { Env } from './context'
/**
* The www worker is a backend-for-frontend (BFF): the browser only ever talks to
* www, and www forwards to the `auth` and `accounts` workers server-side. That
* keeps the JWT off other origins and sidesteps CORS (those workers set no CORS
* headers). Hosts are derived from the shared base domain (`auth.<DOMAIN>`,
* `accounts.<DOMAIN>`), matching how the workers are deployed.
* Where the other workers live. Derived from the shared base domain, matching how they
* are deployed. www serves these to the SPA (`/api/config`), which calls them DIRECTLY
* the same endpoints the game uses, as rec.net's own site did. The only one www still
* calls itself is `auth`, for the Turnstile-gated signup grant (see `postAuthForm`).
*/
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
@@ -15,22 +17,63 @@ export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
/**
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
* read their inputs via Hono's `parseBody()`, so they expect form fields (not
* JSON). `bearer`, when given, authenticates the caller.
* POST a form body to the `auth` worker, carrying the browser's real IP across.
*
* The browser could post `/connect/token` itself it does exactly that to sign in but
* not to SIGN UP: that grant is gated by Turnstile, whose secret key can't ship to a
* page. So signup goes through www, and www has to solve a problem the browser doesn't
* have: `auth` reads the caller's address from `CF-Connecting-IP` and records it as the
* account's immutable `signupIp`, and a Worker subrequest to https://auth.<DOMAIN>
* re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
* address. Every web signup therefore recorded one shared IP, and auth's per-IP cap
* 3 accounts, never decaying refused the fourth web account ever created, for everybody.
*
* Going through the service binding skips the edge, so the header set here is the one
* auth reads. That is safe precisely because the edge does overwrite it on the public
* route: a game client (or the SPA signing in) posting `/connect/token` directly still
* cannot spoof its own IP, so no shared secret is needed to tell the callers apart.
*
* `clientIp` is the caller's own edge-set `cf-connecting-ip`, and must never be anything
* a browser supplied. Absent, no header is sent at all auth's `clientIp` then reads
* empty, which SKIPS the cap rather than counting every such signup together.
*
* Falls back to the public hostname when the binding is absent (local `vite dev` see
* `Env.AUTH`); the edge then overwrites the header again, which is the old behaviour.
*/
export async function postForm(
url: string,
export async function postAuthForm(
env: Env,
path: string,
fields: Record<string, string>,
bearer?: string
opts: { bearer?: string; clientIp?: string } = {}
): Promise<Response> {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded',
}
if (bearer) headers.authorization = `Bearer ${bearer}`
return fetch(url, {
if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`
if (opts.clientIp) headers['cf-connecting-ip'] = opts.clientIp
const request = new Request(`${authBase(env)}${path}`, {
method: 'POST',
headers,
body: new URLSearchParams(fields).toString(),
})
return env.AUTH ? env.AUTH.fetch(request) : fetch(request)
}
/**
* Read a failed `auth` response into something worth showing. The translation itself is
* shared with the browser (see `auth-messages.ts`); this only unpacks the body. A
* non-JSON one from something in front of auth, like an edge error page falls
* through to the generic line for the action.
*/
export async function readAuthError(res: Response, action: AuthAction): Promise<AuthFailure> {
const parsed = (await res.json().catch(() => null)) as {
error?: unknown
error_description?: unknown
} | null
const body = parsed ?? {}
const code = typeof body.error === 'string' ? body.error : ''
const description = typeof body.error_description === 'string' ? body.error_description : ''
return authFailure(action, res.status, code, description)
}
+109 -250
View File
@@ -1,116 +1,40 @@
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withOnError } from '@repo/hono-helpers'
import { logger, withOnError } from '@repo/hono-helpers'
import { NotificationType } from '../../notify/src/notification-types'
import { authUnreachable } from './auth-messages'
import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import { turnstileKeys, verifyTurnstile } from './turnstile'
import {
accountsBase,
apiBase,
authBase,
imgBase,
notifyBase,
postAuthForm,
readAuthError,
} from './upstream'
import type { Context } from 'hono'
import type { CookieOptions } from 'hono/utils/cookie'
import type { App } from './context'
/**
* www the first frontend worker. It serves the React SPA (create account, set
* email, change password) and acts as a backend-for-frontend: the browser talks
* only to www, and www forwards to the `auth`/`accounts` workers server-side (see
* `upstream.ts`). The account's JWT lives in an httpOnly cookie set here, so it's
* never exposed to page JS.
* www the website worker. It serves the React SPA (create account, sign in, change
* username/email/password) and almost nothing else: the SPA calls the SAME endpoints
* the game does, on `auth`/`accounts`/`api`/`notify` directly, exactly as rec.net's own
* site did. Those workers answer CORS for it, and the browser holds the access token.
*
* Two things stay server-side here, both because they can't work any other way:
*
* - `/api/signup`, because it's gated by Turnstile and the secret key that turns a
* widget token into a verdict cannot ship to a browser. It's also the one account
* endpoint with no game equivalent the game never creates password accounts so
* there's no client contract being duplicated.
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
* workers live, so one client build works for any operator's domain.
*/
/** Name of the httpOnly session cookie holding the account's access token. */
const SESSION_COOKIE = 'rf_token'
/**
* RecNet (4) is the web platform, stamped as the token's `platform` claim on login.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet the web signup is the (platform-less) password
* account path.
*/
const WEB_PLATFORM = '4'
/**
* Roles that unlock the admin controls in the UI. Mirrors the notify worker's
* `ADMIN_ROLES` gate www only decides whether to *show* the controls; notify does
* the real enforcement (it verifies the token) on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
return {
httpOnly: true,
secure: !local,
sameSite: 'Lax',
path: '/',
maxAge,
}
}
/** Pull the session token out of the request cookie, or null when absent. */
function sessionToken(c: Context<App>): string | null {
return getCookie(c, SESSION_COOKIE) ?? null
}
/**
* Whether the session token carries an admin role. Decodes the JWT's `role` claim
* WITHOUT verifying www holds no signing key, and this only gates whether admin UI
* is shown; the notify worker verifies the token before acting on it. A malformed
* token simply reads as "not admin".
*/
function isAdminToken(token: string): boolean {
const payload = token.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/** Relay an upstream worker's JSON response back to the browser unchanged. */
async function relay(c: Context<App>, res: Response) {
const body = await res.text()
return c.body(body, res.status as never, {
'content-type': res.headers.get('content-type') ?? 'application/json',
})
}
/**
* 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).
*/
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 }
if (!token.access_token) {
return c.json({ error: 'auth did not return an access token' }, 502)
}
setCookie(
c,
SESSION_COOKIE,
token.access_token,
sessionCookieOptions(c, token.expires_in ?? 3600)
)
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
headers: { authorization: `Bearer ${token.access_token}` },
})
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
const account = (await me.json()) as Record<string, unknown>
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
}
const app = new Hono<App>()
.use(
'*',
@@ -124,163 +48,98 @@ const app = new Hono<App>()
.onError(withOnError())
// ---- BFF API ------------------------------------------------------------
// ---- Site config --------------------------------------------------------
// 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
// their username, not the numeric account id.
.post('/api/login', async (c) => {
const { username, password } = await c.req
.json<{ username?: string; password?: string }>()
.catch(() => ({}) as { username?: string; password?: string })
if (!username || !password) {
return c.json({ error: 'Username and password are required.' }, 400)
}
const res = await postForm(`${authBase(c.env)}/connect/token`, {
grant_type: 'password',
username,
platform: WEB_PLATFORM,
password,
})
return establishSession(c, res)
})
// Clear the session cookie.
.post('/api/logout', (c) => {
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ success: true })
})
// Public homepage slideshow. Proxies the api worker's (public) slideshow feed and
// projects each image to a full img.<domain> URL the browser can load directly, so
// the page JS never has to know the upstream hosts. No session required.
.get('/api/slideshow', async (c) => {
const res = await fetch(`${apiBase(c.env)}/api/images/v1/slideshow`)
if (!res.ok) return relay(c, res)
const data = (await res.json()) as {
Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }>
ValidTill?: string
}
const images = (data.Images ?? []).map((i) => ({
url: `${imgBase(c.env)}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
return c.json({ images, validTill: data.ValidTill ?? null })
})
// Current session's self account (used to restore UI state on page load).
.get('/api/me', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const res = await fetch(`${accountsBase(c.env)}/account/me`, {
headers: { authorization: `Bearer ${token}` },
})
// Token expired/invalid — drop the stale cookie so the client shows sign-in.
if (res.status === 401) {
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ error: 'session expired' }, 401)
}
if (!res.ok) return relay(c, res)
// Augment the self account with whether this session may use admin controls,
// read from the token's role claim (see isAdminToken).
const account = (await res.json()) as Record<string, unknown>
return c.json({ ...account, isAdmin: isAdminToken(token) })
})
// Set the signed-in account's email.
.post('/api/email', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { email } = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string })
if (!email) return c.json({ error: 'An email is required.' }, 400)
const res = await postForm(`${accountsBase(c.env)}/account/me/email`, { email }, token)
return relay(c, res)
})
// Change the signed-in account's password (current password required).
.post('/api/password', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { oldPassword, newPassword } = await c.req
.json<{ oldPassword?: string; newPassword?: string }>()
.catch(() => ({}) as { oldPassword?: string; newPassword?: string })
if (!newPassword) return c.json({ error: 'A new password is required.' }, 400)
const res = await postForm(
`${authBase(c.env)}/account/me/changepassword`,
{ oldPassword: oldPassword ?? '', newPassword },
token
)
return relay(c, res)
})
// Broadcast a ServerMaintenance countdown to every connected client. Forwards the
// session token to the notify worker, which enforces the admin-role gate — so a
// non-admin session is rejected upstream (403) even though www shows no button.
// The notification frame carries `Msg: { StartsInMinutes }`, matching the client's
// ServerMaintenance handler; the response mirrors the reference maintenance API.
.post('/api/maintenance', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { startsInMinutes } = await c.req
.json<{ startsInMinutes?: number }>()
.catch(() => ({}) as { startsInMinutes?: number })
const minutes = Number(startsInMinutes)
const startsIn = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : 0
const res = await fetch(`${notifyBase(c.env)}/internal/broadcast`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
notificationType: NotificationType.ServerMaintenance,
data: { StartsInMinutes: startsIn },
}),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { delivered?: number }
// What the SPA has to know before it can do anything: whether web signup is open,
// the Turnstile site key to mount its widget with, and the hostnames of the workers
// it calls directly. All three are served rather than baked into the client build so
// one build works for any operator. The site key is public (it ships in the widget
// markup either way); the secret never leaves the worker.
.get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env)
return c.json({
success: true,
starts_in_minutes: startsIn,
connections: result.delivered ?? 0,
signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
api: apiBase(c.env),
img: imgBase(c.env),
notify: notifyBase(c.env),
},
})
})
// Send a coach/system message to every online player. Like maintenance, this
// forwards the session token to notify, which enforces the admin-role gate.
.post('/api/coach-message', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
// ---- Signup -------------------------------------------------------------
const { messageContent } = await c.req
.json<{ messageContent?: string }>()
.catch(() => ({}) as { messageContent?: string })
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
if (content === '') return c.json({ error: 'A message is required.' }, 400)
// 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 (3, never decaying) is the only other thing in front of this path —
// and `auth` has no bot check of its own, which is why this one endpoint can't simply
// be called from the browser like the rest.
//
// Deliberately passes NO `platform`: create_account treats an asserted platform as one
// to verify against Steam and would reject RecNet, so this is the platform-less
// password-account path. The username is auto-assigned by auth — players don't pick one.
//
// On success auth's token response is returned VERBATIM, so the SPA stores it the same
// way it stores the one it gets from calling `/connect/token` itself to sign in. The
// account's email, when the player gave one, is saved by the client afterwards with
// that token — `create_account` takes no email, and `accounts` owns the field.
.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)
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ messageContent: content }),
})
if (!res.ok) return relay(c, res)
type SignupBody = { password?: string; turnstileToken?: string }
const { password, 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)
const result = (await res.json()) as { sent?: number }
return c.json({ success: true, sent: result.sent ?? 0 })
// 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, which is why it's forwarded to the grant below rather than
// left to the edge: see `postAuthForm`.
const clientIp = c.req.header('cf-connecting-ip')
const verified = await verifyTurnstile(keys.secretKey, turnstileToken, clientIp)
// 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)
// A throw here is auth being unreachable, not a rejected signup — answered as such
// rather than falling through to the generic 500 handler, whose "internal server
// error" tells the player nothing about whether they now have an account (they don't:
// nothing was created).
const res = await postAuthForm(
c.env,
'/connect/token',
{ grant_type: 'create_account', password },
{ clientIp }
).catch(() => null)
if (res === null) {
logger.error('could not reach auth to create an account')
return c.json({ error: authUnreachable('signup') }, 502)
}
// A refused grant is translated (see `readAuthError`) rather than relayed: auth
// answers the OAuth shape, whose `error` is always a code like `invalid_grant`, and
// that code is what the form used to show for every failure — including the
// per-network cap, which the player could otherwise understand. Sign-in doesn't need
// this (the browser calls `/connect/token` itself and reads `error_description`), but
// the cap is reachable only from signup, so the sentences live on this path.
if (!res.ok) {
const failure = await readAuthError(res, 'signup')
logger.info('auth refused a signup', { status: res.status, upstream: failure.upstream })
return c.json({ error: failure.message }, failure.status)
}
const token = (await res.json().catch(() => null)) as { access_token?: string } | null
if (!token?.access_token) {
logger.error('auth answered a signup with no access_token')
return c.json({ error: authUnreachable('signup') }, 502)
}
return c.json(token)
})
// ---- Privacy policy -----------------------------------------------------
+20
View File
@@ -6,8 +6,28 @@ export default defineConfig({
cloudflareTest({
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
miniflare: {
// Stands in for the `auth` service binding wrangler.jsonc declares — the real
// worker isn't part of this project's test run, and without an override the
// runtime refuses to start ("no such service is defined"). It echoes the
// forwarded `cf-connecting-ip` back so a test can assert the browser's IP
// actually survives the hop (see src/upstream.ts `postAuthForm`); every other
// auth call in the tests fails before reaching it.
serviceBindings: {
AUTH: (request: Request) =>
new Response(
JSON.stringify({
error: 'invalid_grant',
error_description: request.headers.get('cf-connecting-ip') ?? 'no ip',
}),
{ status: 400, headers: { 'content-type': 'application/json' } }
),
},
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.
},
},
}),
+47 -5
View File
@@ -13,7 +13,8 @@
// routing entirely, so the Worker runs ONLY for the listed patterns and every other
// path is served assets-first (with the SPA fallback index.html). It must therefore
// list EVERY route the Worker handles, not just the new ones otherwise `/api/*`
// falls through to index.html and the whole BFF breaks. Why it's needed at all: with
// falls through to index.html and both signup and the site config break (the SPA
// reads the other workers' hostnames from `/api/config`). Why it's needed at all: with
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
@@ -28,6 +29,46 @@
"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"
}
],
// The `auth` worker, reached directly instead of over its public hostname. This is
// about the CLIENT IP, not latency: a Worker subrequest to https://auth.<DOMAIN>
// re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's
// own address so auth recorded the SAME `signupIp` for every browser signup and its
// per-IP cap (3 by default) locked out every player after the third account ever
// created. A service binding skips the edge, so the real browser IP www forwards on
// that header survives (see src/upstream.ts `postAuthForm`).
//
// Only auth is bound, and only for SIGNUP the one call this worker still makes on
// the browser's behalf, because Turnstile's secret key can't ship to a page. Sign-in,
// the profile mutations and the photo feed are posted by the browser straight to
// auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real
// client IP for free.
"services": [{ "binding": "AUTH", "service": "auth" }],
"upload_source_maps": true,
"observability": {
"logs": {
@@ -38,10 +79,11 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Base domain the auth/accounts hosts are derived from (auth.<DOMAIN>,
// accounts.<DOMAIN>). Overridden at deploy time with the real RECFLARE_DOMAIN
// (see run-wrangler-deploy). For local dev, point this at a deployed domain so
// the BFF proxy can reach the auth/accounts workers.
// Base domain every worker hostname is derived from (auth.<DOMAIN>,
// accounts.<DOMAIN>, ). Overridden at deploy time with the real RECFLARE_DOMAIN
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
// is how one client build works for any operator. For local dev, point it at a
// deployed domain so the page has real workers to call.
"DOMAIN": "rec.example.com"
}
}
+3
View File
@@ -13,5 +13,8 @@
"@cloudflare/workers-types": "4.20260630.1",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*"
},
"dependencies": {
"isemail": "^3.2.0"
}
}
+22 -51
View File
@@ -11,18 +11,20 @@
* it from `@repo/domain` (each uses the subset it needs).
*/
/** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */
/**
* 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).
*/
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,
platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) 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). */
@@ -37,12 +39,14 @@ export interface Account {
identityFlags: number
createdAt: string
/**
* 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.
* 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.
*/
platformId?: string
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
@@ -216,23 +220,6 @@ 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
@@ -240,8 +227,9 @@ export async function getAccountsByPlatformId(
*
* 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 the way `platformId` is (see the 0004 migration).
* 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).
*
* 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.
@@ -258,7 +246,9 @@ 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()
}
@@ -295,9 +285,7 @@ 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()
}
@@ -324,21 +312,6 @@ 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 []
@@ -411,9 +384,7 @@ 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
+2
View File
@@ -7,4 +7,6 @@ export * from './rooms-db'
export * from './room-instance-db'
export * from './presence-db'
export * from './gifts-db'
export * from './inventory-invention-db'
export * from './relationships-db'
export * from './validation'
@@ -0,0 +1,104 @@
/**
* Owned inventions on the shared `recflare` D1 database the inventions a player has
* bought. One row per (account, invention), written at purchase time by the `econ`
* worker's `GET /api/storefronts/v2/buyInvention`.
*
* Only the invention id is stored: the invention record itself lives in the `invention`
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
* this same database, and copying its DTO here would leave two rows to keep in step. A
* creator is not listed here either they own their invention through its
* `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator.
*
* The `econ` worker owns the schema/migration (apps/econ/migrations/
* 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought
* inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table
* name and row shape live in one place the same split as gifts-db.ts.
*/
/** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */
export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS inventory_invention (
account_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
acquired_at TEXT NOT NULL,
PRIMARY KEY (account_id, invention_id)
)`,
]
/**
* Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary
* key: owning an invention is boolean, so a second grant keeps the original
* `acquired_at` rather than back-dating the purchase to now.
*/
export async function grantInvention(
db: D1Database,
accountId: number,
inventionId: number
): Promise<void> {
await db
.prepare(
'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(accountId, inventionId, new Date().toISOString())
.run()
}
/**
* Whether a player has bought an invention. This answers for BOUGHT inventions only
* the creator of an invention owns it without a row here, so callers that mean "may use
* this invention" must check `CreatorPlayerId` as well.
*/
export async function ownsInvention(
db: D1Database,
accountId: number,
inventionId: number
): Promise<boolean> {
const row = await db
.prepare(
'SELECT 1 AS owned FROM inventory_invention WHERE account_id = ?1 AND invention_id = ?2'
)
.bind(accountId, inventionId)
.first<{ owned: number }>()
return row !== null
}
/**
* How many times each invention was acquired at or after `since`, most-acquired first
* (ties broken by newest invention, so paging is stable). Backs the `api` worker's "top
* today" feed, which passes 24 hours ago.
*
* `acquired_at` holds `toISOString()` output, which is fixed-width UTC, so a lexical
* `>=` on the string is a chronological comparison no date parsing in SQL.
*
* This counts ACQUISITIONS, not spend: a free invention's grant is a row here just like
* a paid one, and one player can only ever contribute a single row per invention (the
* table's primary key), so a popular invention can't be inflated by one buyer. Creators
* are absent by design they own theirs through `CreatorPlayerId` and never buy it
* which is what makes this a measure of what other people picked up.
*/
export async function getInventionAcquisitionCounts(
db: D1Database,
since: string
): Promise<Array<{ inventionId: number; count: number }>> {
const { results } = await db
.prepare(
`SELECT invention_id, COUNT(*) AS count FROM inventory_invention
WHERE acquired_at >= ?1
GROUP BY invention_id
ORDER BY count DESC, invention_id DESC`
)
.bind(since)
.all<{ invention_id: number; count: number }>()
return results.map((r) => ({ inventionId: r.invention_id, count: r.count }))
}
/** The ids of every invention a player has bought, oldest purchase first. */
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
const { results } = await db
.prepare(
'SELECT invention_id FROM inventory_invention WHERE account_id = ?1 ORDER BY acquired_at, invention_id'
)
.bind(accountId)
.all<{ invention_id: number }>()
return results.map((r) => r.invention_id)
}
+51
View File
@@ -152,6 +152,57 @@ 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]))
}
/**
* Who is standing in each of a room's instances right now, keyed by instance id
* one grouped query rather than a lookup per instance, so the owner's instance list
* stays a single read. Reads only unexpired presence; instances nobody is in are
* simply absent from the map (callers default to an empty list), and lobby
* (null-instance) presence is excluded.
*/
export async function getPlayerIdsByRoomInstance(
db: D1Database,
roomId: number,
now = nowSeconds()
): Promise<Map<number, number[]>> {
const { results } = await db
.prepare(
`SELECT room_instance_id AS instanceId, account_id AS accountId FROM presence
WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL
ORDER BY account_id`
)
.bind(roomId, now)
.all<{ instanceId: number; accountId: number }>()
const out = new Map<number, number[]>()
for (const r of results) {
const players = out.get(r.instanceId)
if (players) players.push(r.accountId)
else out.set(r.instanceId, [r.accountId])
}
return out
}
/**
* 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
+77 -1
View File
@@ -12,7 +12,7 @@
* the client DTO (`toDto`).
*/
import { countPlayersInInstance } from './presence-db'
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
@@ -69,6 +69,22 @@ export interface RoomInstanceDto {
createdAt: string
}
/**
* The owner's view of one live instance of their room (`match`:
* `GET /room/:roomId/instances`). Deliberately NOT the client `RoomInstanceDto`:
* it's a management listing, so it carries who is in there (`playerIds`, from live
* presence) and drops the connection details (photon ids, data blob, room code) an
* owner has no business reading for a session they aren't in.
*/
export interface RoomInstanceSummary {
roomInstanceId: number
roomId: number
subRoomId: number
isFull: boolean
createdAt: string
playerIds: number[]
}
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
interface StoredRoomInstance extends RoomInstanceDto {
ownerAccountId: number
@@ -206,6 +222,35 @@ export async function setRoomInstanceInProgress(
return toDto(stored)
}
/**
* Flip an instance's `isPrivate` flag, rewriting the JSON blob (the generated
* `is_private` column follows it). Returns the updated DTO, or null when the
* instance doesn't exist.
*
* Marking an instance private is what closes it to strangers: {@link
* getJoinableInstance} only ever reuses instances with `is_private = 0`, so a public
* matchmake stops landing new players here the moment this is set. Everyone already
* inside stays this shuts the door, it doesn't clear the room.
*/
export async function setRoomInstancePrivate(
db: D1Database,
id: number,
isPrivate: boolean
): Promise<RoomInstanceDto | null> {
const row = await db
.prepare('SELECT data FROM room_instance WHERE id = ?1')
.bind(id)
.first<{ data: string }>()
if (!row) return null
const stored = parse(row.data)
stored.isPrivate = isPrivate
await db
.prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(stored), id)
.run()
return toDto(stored)
}
/**
* Recompute an instance's `isFull` flag from live match presence: full once the
* number of players currently present in the instance reaches its `maxCapacity`
@@ -292,3 +337,34 @@ export async function getRoomInstancesByRoom(
.all<{ data: string }>()
return results.map((r) => toDto(parse(r.data)))
}
/**
* A room's instances as the owner's management listing sees them the
* {@link RoomInstanceSummary} projection, each with the players currently standing
* in it. Presence is read once for the whole room (one grouped query), so this stays
* two reads regardless of how many instances are live; an instance nobody is in
* (everyone timed out, or it was just created) gets an empty `playerIds`.
*/
export async function getRoomInstanceSummariesByRoom(
db: D1Database,
roomId: number
): Promise<RoomInstanceSummary[]> {
const [{ results }, playersByInstance] = await Promise.all([
db
.prepare('SELECT data FROM room_instance WHERE room_id = ?1 ORDER BY id')
.bind(roomId)
.all<{ data: string }>(),
getPlayerIdsByRoomInstance(db, roomId),
])
return results.map((r) => {
const s = parse(r.data)
return {
roomInstanceId: s.roomInstanceId,
roomId: s.roomId,
subRoomId: s.subRoomId,
isFull: s.isFull,
createdAt: s.createdAt,
playerIds: playersByInstance.get(s.roomInstanceId) ?? [],
}
})
}
+435 -29
View File
@@ -18,6 +18,7 @@
*/
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[] = [
@@ -42,6 +43,23 @@ export const ROOM_SCHEMA_DDL: string[] = [
last_visited_at TEXT,
PRIMARY KEY (player_id, room_id)
)`,
// Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player),
// so re-banning someone already banned updates their row rather than appending.
// `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known
// yet (the client sends 0), so nothing interprets it.
//
// Deliberately NOT in the room's `data` blob: that blob is served to the client
// verbatim as the room, and a ban list is not something every reader of a room
// should receive.
`CREATE TABLE IF NOT EXISTS room_ban (
room_id INTEGER NOT NULL,
banned_player_id INTEGER NOT NULL,
ban_mask INTEGER NOT NULL DEFAULT 0,
banned_by_account_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (room_id, banned_player_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
]
/**
@@ -82,6 +100,26 @@ 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). */
@@ -115,6 +153,96 @@ export function canManageRoom(room: Room, accountId: number): boolean {
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
}
/** A player banned from a room (a `room_ban` row). */
export interface RoomBan {
RoomId: number
BannedPlayerId: number
/** The client's `banMask`, stored verbatim — its meaning isn't known yet. */
BanMask: number
BannedByAccountId: number
CreatedAt: string
}
interface RoomBanRow {
room_id: number
banned_player_id: number
ban_mask: number
banned_by_account_id: number
created_at: string
}
const toRoomBan = (row: RoomBanRow): RoomBan => ({
RoomId: row.room_id,
BannedPlayerId: row.banned_player_id,
BanMask: row.ban_mask,
BannedByAccountId: row.banned_by_account_id,
CreatedAt: row.created_at,
})
/**
* Ban a player from a room, returning the stored ban. One row per (room, player):
* re-banning someone already banned rewrites their row with the new mask and issuer
* rather than appending a second one, so the call is idempotent.
*/
export async function banPlayerFromRoom(
db: D1Database,
roomId: number,
bannedPlayerId: number,
banMask: number,
bannedByAccountId: number
): Promise<RoomBan> {
const row = await db
.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(room_id, banned_player_id) DO UPDATE SET
ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5
RETURNING *`
)
.bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString())
.first<RoomBanRow>()
// RETURNING always yields the upserted row.
return toRoomBan(row!)
}
/**
* Lift a player's ban on a room, returning the ban that was removed or null when
* they weren't banned, which lets the caller tell a real unban from a no-op.
*/
export async function unbanPlayerFromRoom(
db: D1Database,
roomId: number,
bannedPlayerId: number
): Promise<RoomBan | null> {
const row = await db
.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *')
.bind(roomId, bannedPlayerId)
.first<RoomBanRow>()
return row ? toRoomBan(row) : null
}
/** Everyone banned from a room, most recently banned first. */
export async function getRoomBans(db: D1Database, roomId: number): Promise<RoomBan[]> {
const { results } = await db
.prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC')
.bind(roomId)
.all<RoomBanRow>()
return results.map(toRoomBan)
}
/** Whether a player is banned from a room. */
export async function isPlayerBannedFromRoom(
db: D1Database,
roomId: number,
playerId: number
): Promise<boolean> {
const row = await db
.prepare('SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
.bind(roomId, playerId)
.first<{ hit: number }>()
return row !== null
}
/**
* Clone an existing room into a new one owned by `accountId`. Copies the source
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
@@ -157,6 +285,9 @@ 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(),
}
@@ -695,6 +826,9 @@ 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)
@@ -760,11 +894,13 @@ 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).
* 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.
*/
const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, ...rest } = room
return JSON.stringify(rest)
const { SubRooms: _subRooms, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
}
/**
@@ -831,15 +967,108 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
}
/** Hydrate a single room's `SubRooms` (no-op for null). */
// ---- 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). */
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
if (room) await attachSubRooms(db, [room])
if (room) await hydrateRooms(db, [room])
return room
}
/** Hydrate many rooms' `SubRooms` in one batched query. */
async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
await attachSubRooms(db, rooms)
/** 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)])
return rooms
}
@@ -942,6 +1171,119 @@ 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).
@@ -963,6 +1305,12 @@ 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.
@@ -1037,12 +1385,18 @@ 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 first — they're keyed by subroom, so they'd be unreachable afterwards.
// Saves and permission overrides first — both are keyed by subroom, so they'd be
// unreachable once the subrooms themselves are gone.
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),
])
}
@@ -1324,20 +1678,51 @@ export async function searchRooms(
}
}
/** 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)
/**
* 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
}
/**
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
* 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.
* 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}.
*/
export async function getHotRooms(
db: D1Database,
@@ -1351,15 +1736,34 @@ 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 roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
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)
)
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
TotalResults: rooms.length,
}
}
@@ -1378,13 +1782,14 @@ export async function getRecommendedRooms(
take: number
): Promise<Room[]> {
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
const stats = await getRoomStats(db)
return hydrateRooms(
db,
parseAll(results)
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
.slice(skip, skip + take)
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
.slice(skip, skip + take),
stats
)
}
@@ -1463,7 +1868,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 roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
const stats = await getRoomStats(db)
const scored = parseAll(results)
.filter(
@@ -1479,12 +1884,12 @@ export async function getSimilarRooms(
scored.sort(
(a, b) =>
b.shared - a.shared ||
hotScore(b.room) - hotScore(a.room) ||
hotScore(b.room, stats) - hotScore(a.room, stats) ||
roomIdOf(a.room) - roomIdOf(b.room)
)
const rooms = scored.map((x) => x.room)
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
TotalResults: rooms.length,
}
}
@@ -1499,7 +1904,6 @@ 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)
@@ -1574,6 +1978,8 @@ 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
+178
View File
@@ -0,0 +1,178 @@
import isEmail from 'isemail'
/**
* Limits on the free text a player can put into their account and their rooms.
*
* Shared by `accounts` and `rooms` so one rule can't drift from the other a username
* and a room name are held to the same shape, and both are typed into the same client.
*
* These check only what a player SUPPLIES. Names the server generates go around them:
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
* satisfy it. So validate at the request handler, never inside the db helpers.
*
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
* value in its own words, and those sentences reach players through response envelopes
* the client renders verbatim see the client-contract notes in CLAUDE.md.
*/
/**
* Name lengths. All three come from what the CLIENT will accept in the matching input
* box, not from a round number: accepting more here would store a name the game can't
* re-enter or edit, so the server matches the box rather than being generous.
*/
export const MAX_USERNAME_LENGTH = 50
export const MAX_DISPLAY_NAME_LENGTH = 15
export const MAX_ROOM_NAME_LENGTH = 32
/**
* Club and event limits. Longer than the name limits above because these aren't
* identifiers a club name and an event name are titles, and both allow the
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
* enforce none at all, since an event is called things like "Building a Better Room
* Using Trigonometry").
*/
export const MAX_CLUB_NAME_LENGTH = 40
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
export const MAX_EVENT_NAME_LENGTH = 64
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
/**
* Invention limits. A name is a title a player types into the invention-save box and
* reads back in a browse tile, so it allows the punctuation a title needs but
* nothing else, since it is also what invention search matches on. The minimum is real:
* one- and two-character names are unsearchable and unreadable in a tile, and the client
* offers `Untitled` rather than an empty box.
*/
export const MIN_INVENTION_NAME_LENGTH = 3
export const MAX_INVENTION_NAME_LENGTH = 24
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
/**
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
* browse chips are derived from (see `getInventionTagFilters`) a tag with digits,
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
* lowercased, so the rule is checked against the normalized form, not what was typed.
*/
export const MAX_INVENTION_TAG_LENGTH = 15
/**
* Length in code points rather than UTF-16 units, so an emoji or other astral character
* counts once instead of twice the way a player counts what they typed.
*/
export const glyphLength = (value: string): number => Array.from(value).length
/** Max length of a profile bio. */
export const MAX_BIO_LENGTH = 255
/**
* Letters and digits only no spaces, punctuation, or accents.
*
* Deliberately narrow: these names are shown to other players, used to search, and (for
* usernames) typed into a sign-in box, so anything that can be confused for another name
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
* with allowing arbitrary Unicode.
*/
const NAME_PATTERN = /^[A-Za-z0-9]+$/
/**
* Why a player-supplied name is unacceptable, or `null` when it's fine.
*
* `label` names the thing in the returned sentence ('username', 'room name'), so the
* message reads correctly wherever it's surfaced. `max` is required rather than
* defaulted: the three limits differ, and a caller that forgets which one it wants
* should have to say so instead of silently taking someone else's.
*/
export function nameRejection(value: string, label: string, max: number): string | null {
if (value.length > max) {
return `Your ${label} can be at most ${max} characters.`
}
if (!NAME_PATTERN.test(value)) {
return `Your ${label} can only contain letters and numbers.`
}
return null
}
/**
* Letters, digits, spaces, dashes and colons the title charset. Wider than
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
* arbitrary Unicode, for the same homoglyph reasons.
*
* The colon is not decorative: an invention the player never named is called after the
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
* would refuse every unnamed save the game makes. The dash stays last in the class so it
* reads as a literal rather than a range.
*/
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
/** Lowercase letters only — the normalized form a tag is stored in. */
const INVENTION_TAG_PATTERN = /^[a-z]+$/
/**
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
*
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
* not part of what they named the thing, and counting them toward the minimum would let
* `" a "` through.
*/
export function inventionNameRejection(value: string): string | null {
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
}
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
}
if (!INVENTION_NAME_PATTERN.test(value)) {
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
}
return null
}
/**
* Why an invention description is unacceptable, or `null` when it's fine. Length only
* a description is prose, so nothing is refused for the characters it's made of, and an
* empty one is fine (it's how a creator clears the field).
*/
export function inventionDescriptionRejection(value: string): string | null {
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
}
return null
}
/**
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
* tag (trimmed and lowercased, as `setInventionTags` stores it) checking what was typed
* instead would refuse `Racing` for a capital that never reaches the database.
*/
export function inventionTagRejection(value: string): string | null {
if (value.length > MAX_INVENTION_TAG_LENGTH) {
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
}
if (!INVENTION_TAG_PATTERN.test(value)) {
return 'Invention tags can only contain letters.'
}
return null
}
/**
* Whether a supplied email is one worth storing RFC 5321/5322 syntax, via `isemail`.
*
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
* itself, which is why there's no separate length cap.
*
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
* address costs nothing here.
*/
export function isValidEmail(value: string): boolean {
return isEmail.validate(value)
}
/** Whether a supplied bio is within the stored length. */
export function isValidBio(value: string): boolean {
return value.length <= MAX_BIO_LENGTH
}
+37 -1
View File
@@ -33,9 +33,44 @@ function addIntegerExamples(node: unknown): void {
for (const value of Object.values(obj)) addIntegerExamples(value)
}
/**
* hono-openapi registers a `validator('form', …)` body under `multipart/form-data` ONLY,
* and there is no way to ask it for another media type: its media selection reads
* `options?.media ?? target === 'json' ? 'application/json' : 'multipart/form-data'`,
* which by JS precedence is `((options?.media ?? target) === 'json') ? … : …` so the
* `media` option can never produce anything else.
*
* That leaves the spec claiming a validated route accepts only multipart, when the real
* callers (the Rec Room client and the website) post `application/x-www-form-urlencoded`
* and Hono's `parseBody()` reads both. So the urlencoded variant is mirrored back in.
*
* Safe because nothing here documents a genuinely multipart-only body there are no file
* uploads on these workers, and hand-written form bodies already declare both types. If
* one is ever added, it will need to opt out of this.
*/
function mirrorFormBodies(node: unknown): void {
if (Array.isArray(node)) {
for (const item of node) mirrorFormBodies(item)
return
}
if (node === null || typeof node !== 'object') return
const obj = node as Record<string, unknown>
const content = obj.content
if (content !== null && typeof content === 'object') {
const media = content as Record<string, unknown>
const multipart = media['multipart/form-data']
if (multipart !== undefined && media['application/x-www-form-urlencoded'] === undefined) {
media['application/x-www-form-urlencoded'] = multipart
}
}
for (const value of Object.values(obj)) mirrorFormBodies(value)
}
/**
* Wrap `openAPIRouteHandler(...)` so the generated document gets example values for its
* integer fields. Purely cosmetic nothing about the documented shapes changes.
* integer fields, and so a validated form body documents both content types it really
* accepts. Nothing about the runtime behaviour changes this only corrects the document.
*
* ```ts
* app.get('/openapi.json', describeRoute({ hide: true }), withCleanSpec(openAPIRouteHandler(app, { ... })))
@@ -47,6 +82,7 @@ export function withCleanSpec(handler: Handler | MiddlewareHandler): Handler {
if (!(res instanceof Response)) return res as never
const spec: unknown = await res.json()
addIntegerExamples(spec)
mirrorFormBodies(spec)
return c.json(spec as Record<string, unknown>)
}
}
+53 -6
View File
@@ -48,16 +48,61 @@ 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. These workers
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
# 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.
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"' 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)
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
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
CONFIG="wrangler.generated.jsonc"
@@ -131,8 +176,10 @@ 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"
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
[ -n "$IS_VITE" ] && MINIFY=""
# Deploy with wrangler using the extracted values as binding variables
echo "Deploying worker $NAME version $VERSION to $HOST"
+18
View File
@@ -864,6 +864,10 @@ importers:
version: 4.105.0(@cloudflare/workers-types@4.20260630.1)
packages/domain:
dependencies:
isemail:
specifier: ^3.2.0
version: 3.2.0
devDependencies:
'@cloudflare/workers-types':
specifier: 4.20260630.1
@@ -3174,6 +3178,10 @@ packages:
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
engines: {node: '>=12'}
isemail@3.2.0:
resolution: {integrity: sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==}
engines: {node: '>=4.0.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -3542,6 +3550,10 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -6154,6 +6166,10 @@ snapshots:
is-regexp@3.1.0: {}
isemail@3.2.0:
dependencies:
punycode: 2.3.1
jiti@2.6.1:
optional: true
@@ -6677,6 +6693,8 @@ snapshots:
property-information@7.2.0: {}
punycode@2.3.1: {}
quansync@0.2.11: {}
radix-vue@1.9.17(vue@3.5.40(typescript@6.0.3)):