mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc43d57172 | |||
| 8b804eaa33 | |||
| 6b7acc9435 | |||
| 93a46871de | |||
| 4111bc49aa | |||
| f6561f1ec9 | |||
| bc96a6245b | |||
| a73dec7c13 | |||
| ae3bef4cc4 | |||
| 1f615bab4f | |||
| a986d012f5 | |||
| b82a5e1dc0 | |||
| 6bfd4d9e50 | |||
| 079c889ccb | |||
| 9f4ce07aca | |||
| dfb1e9ab21 | |||
| 1d08ed8296 | |||
| f3e2ab422c | |||
| aa304dbede | |||
| 10eb89ac12 | |||
| 65611c15d8 | |||
| d6a0e3e6a6 | |||
| 7d300fa836 | |||
| 73bb7c4609 | |||
| dbc6d15ef5 | |||
| db003d54ef | |||
| 05b56e698e | |||
| dee7497fe5 | |||
| 12f6d7ab61 | |||
| 8d1539de03 | |||
| 7e0c26a100 | |||
| f94877347c | |||
| 23bc159c28 | |||
| 70df3cb6cb | |||
| 03b1c59f0d | |||
| d298977790 | |||
| 821bf54b9b | |||
| 108b061019 | |||
| af2a2a0683 | |||
| 339a91735b | |||
| a46f6db9d7 | |||
| b3f1d04823 | |||
| 55cb769de9 |
+16
-16
@@ -1,22 +1,9 @@
|
||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
||||
RECFLARE_DOMAIN=rec.example.com
|
||||
|
||||
# Optional per-service subdomain overrides, as a compact JSON object keyed by the
|
||||
# service's default subdomain (which, for a service backed by a worker, is that
|
||||
# worker's directory name). Unlisted services keep their default.
|
||||
#
|
||||
# One entry moves both sides: it decides which host `just deploy` puts the worker on
|
||||
# AND which host the `ns` discovery document advertises to the client, so the two can't
|
||||
# drift apart. Redeploy `ns` (`just deploy -F ns`) after changing this.
|
||||
#
|
||||
# {"playersettings":"settings"} the playersettings worker moves to settings.<domain>
|
||||
# {"moderation":"api"} Moderation has no worker of its own, so this is a pure
|
||||
# client-side redirect: it points the client's Moderation
|
||||
# calls at the api worker, which is where the
|
||||
# /api/PlayerReporting/… routes actually live
|
||||
#
|
||||
# Keep it compact — no spaces. Services are listed in SERVICES.md.
|
||||
# RECFLARE_SUBDOMAINS='{"moderation":"api"}'
|
||||
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
||||
# worker's directory name. Defaults to the directory name when unset.
|
||||
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
|
||||
|
||||
# Id of the shared `recflare` D1 database (create it manually with
|
||||
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
||||
@@ -77,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+85
-15
@@ -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:
|
||||
@@ -75,20 +79,13 @@ cp .env.example .env
|
||||
|
||||
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
|
||||
|
||||
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
|
||||
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
|
||||
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
|
||||
puts that worker on and which host the `ns` discovery document advertises to the client,
|
||||
so the two can't drift apart.
|
||||
|
||||
This is also how you merge two services together: `'{"moderation":"api"}'` points the
|
||||
client's Moderation calls at the `api` worker (which is where the `/api/PlayerReporting/…`
|
||||
routes already live) without deploying anything on `moderation.<domain>`. Redeploy `ns`
|
||||
after changing it — `just deploy -F ns`.
|
||||
(Optional) - per-app subdomain overrides come from
|
||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
||||
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
|
||||
|
||||
**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
|
||||
@@ -111,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
|
||||
@@ -206,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,6 +2,8 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||
|
||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||
|
||||
+1
-7
@@ -6,12 +6,6 @@ Each is reached at `https://<subdomain>.<your-domain>`. Services with a worker i
|
||||
`apps/` are implemented here; the rest are advertised in the endpoints document
|
||||
but not yet backed by a Worker. Not all services are fully implemented.
|
||||
|
||||
The subdomains below are the defaults. Any of them can be redirected from `.env` via
|
||||
`RECFLARE_SUBDOMAINS`, keyed by the subdomain in this table — which both moves where the
|
||||
worker deploys and what `ns` advertises. Pointing a service with no worker at one that has
|
||||
one merges them, e.g. `'{"moderation":"api"}'` sends the client's Moderation calls to the
|
||||
`api` worker, where the `/api/PlayerReporting/…` routes already live. See `DEPLOYING.md`.
|
||||
|
||||
A small `ns` worker itself serves this discovery document at the
|
||||
apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
`README.md` under `apps/<name>/` documenting its routes.
|
||||
@@ -40,7 +34,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
| Link | `link` | — | Not yet implemented |
|
||||
| Lists | `lists` | — | Not yet implemented |
|
||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
||||
| Moderation | `moderation` | — | No worker; point it at `api` to serve `/api/PlayerReporting/…` |
|
||||
| Moderation | `moderation` | — | Not yet implemented |
|
||||
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
|
||||
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
|
||||
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
||||
|
||||
@@ -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. */
|
||||
@@ -94,10 +100,6 @@ function toAccountDto(account: Account) {
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
// Nothing writes these yet, and rows stored before they existed have neither
|
||||
// key — always emit them as "" rather than letting them go missing.
|
||||
bannerImage: account.bannerImage ?? '',
|
||||
displayEmoji: account.displayEmoji ?? '',
|
||||
isJunior: account.isJunior,
|
||||
platforms: account.platforms,
|
||||
personalPronouns: account.personalPronouns,
|
||||
@@ -116,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,
|
||||
}
|
||||
}
|
||||
@@ -163,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())
|
||||
|
||||
@@ -413,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 })
|
||||
@@ -440,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)
|
||||
@@ -488,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 })
|
||||
}
|
||||
@@ -513,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 })
|
||||
}
|
||||
@@ -599,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 })
|
||||
|
||||
@@ -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. */
|
||||
@@ -52,8 +67,6 @@ export const AccountDto = z.object({
|
||||
username: z.string(),
|
||||
displayName: z.string(),
|
||||
profileImage: z.string().describe('Avatar object key'),
|
||||
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||
displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'),
|
||||
isJunior: z.boolean(),
|
||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||
@@ -124,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({
|
||||
@@ -149,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)'),
|
||||
|
||||
@@ -161,10 +161,6 @@ describe('auth-gated endpoints', () => {
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 1,
|
||||
// Nothing sets these yet, but the key has to be present — the client reads
|
||||
// both off the account DTO.
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
})
|
||||
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||
@@ -224,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)
|
||||
@@ -264,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)
|
||||
@@ -463,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')
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+163
-96
@@ -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). */
|
||||
@@ -135,7 +145,7 @@ export const ApiConfigV2 = JsonObject.describe(
|
||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||
)
|
||||
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we accept. */
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
@@ -160,9 +170,34 @@ export const RelationshipDto = z.object({
|
||||
Muted: z.int().describe('0/1 — the caller‘s 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 worker‘s 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('3–24 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(),
|
||||
@@ -352,86 +402,6 @@ export const CustomAvatarItemsPage = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One custom-item save — the rebuilt version of a legacy avatar item. This is the
|
||||
* official shape, recorded for documentation: nothing stores custom items yet, so we
|
||||
* never actually emit one of these.
|
||||
*/
|
||||
export const CustomAvatarItemSave = z.object({
|
||||
customAvatarItemSaveId: z.int().describe('The save’s id'),
|
||||
customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
|
||||
unityAssetId: z.string().describe('Guid of the built Unity asset'),
|
||||
createdAt: z.string().describe('ISO 8601 timestamp'),
|
||||
thumbnailFileName: z.string(),
|
||||
additionalConfiguration: z.string(),
|
||||
unityAsset: z.string(),
|
||||
unityAssetHash: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
|
||||
* item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
|
||||
* the value shape is documented rather than served.
|
||||
*/
|
||||
export const LegacyAvatarItemSaves = z.object({
|
||||
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
|
||||
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
|
||||
* account form, where every field that would carry an outfit is null/empty and
|
||||
* `DataVersion` is 9.
|
||||
*/
|
||||
export const OutfitsMeResponse = z.object({
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
Selections: JsonArray,
|
||||
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
Slot: z.int().describe('0 — the outfit being worn'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
|
||||
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
|
||||
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
|
||||
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
|
||||
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
|
||||
* structured form, and the client sends both. `Selections` arrives empty — the actual
|
||||
* selections are inside those strings.
|
||||
*/
|
||||
export const OutfitsMeRequest = z.object({
|
||||
DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'),
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
Selections: JsonArray.describe('Empty in observed saves'),
|
||||
Slot: z.int(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
@@ -450,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 doesn’t pin a subroom'),
|
||||
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
|
||||
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 event’s 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 Can’t 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 RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
|
||||
'entry shape has not been observed yet'
|
||||
),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
@@ -471,16 +499,13 @@ export const SubscriptionResponse = z.object({
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet), mirroring the reference server's stub
|
||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||
* they carry their C# defaults (false / null).
|
||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
||||
* which is a real category; `Message` is null, not an empty string — the client
|
||||
* distinguishes "no message" from a blank one.
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
@@ -491,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('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s 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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+110
-174
@@ -1,19 +1,23 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { CURRENT_OUTFIT_SLOT, getOutfit, setOutfit } from '@repo/domain'
|
||||
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,
|
||||
@@ -41,9 +45,6 @@ import {
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
@@ -218,141 +219,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
// can render the custom version instead. Nothing stores custom items yet, so nothing
|
||||
// has a save — an empty list means "use the legacy items as-is".
|
||||
.post(
|
||||
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Custom-item saves for legacy avatar items',
|
||||
description:
|
||||
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
|
||||
'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
|
||||
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
|
||||
'request body is ignored.\n\n' +
|
||||
'The value shape is the official one, recorded here for documentation; we never ' +
|
||||
'emit one until custom items are stored.',
|
||||
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
|
||||
}),
|
||||
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
)
|
||||
|
||||
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
|
||||
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
|
||||
// worker's saved-outfit slots live in); a player who has never saved gets the
|
||||
// brand-new-account envelope instead.
|
||||
.get(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s outfit',
|
||||
description:
|
||||
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
|
||||
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
|
||||
'handed back exactly as it was saved, since the payload’s heavy fields are the ' +
|
||||
'client’s own JSON-in-a-string documents.\n\n' +
|
||||
'A player who has never saved gets the brand-new-account envelope: all-null ' +
|
||||
'`LegacyData`, no `Selections`, `DataVersion` 9.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
|
||||
if (outfit !== null) return c.json(outfit)
|
||||
|
||||
return c.json({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Saving an outfit through the same bare path — into the slot the body names, which
|
||||
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
|
||||
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
|
||||
// it has to parse back. Answers the saved outfit, which is what the client re-renders
|
||||
// from.
|
||||
.put(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save the caller’s outfit',
|
||||
description:
|
||||
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
|
||||
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
|
||||
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
|
||||
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
|
||||
'strings by the client’s own serializer, so nothing here parses or re-encodes them.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
|
||||
responses: {
|
||||
200: json(OutfitsMeRequest, 'The outfit as stored'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
// The client sends `Slot`; a body without one saves the worn outfit.
|
||||
const outfit = {
|
||||
...body,
|
||||
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
|
||||
}
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return c.json(outfit)
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||
// rather than guessing.
|
||||
.get(
|
||||
'/outfits/me/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s saved outfits',
|
||||
description:
|
||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
||||
'list should carry, and in what shape, is not pinned down yet.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get(
|
||||
@@ -470,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'),
|
||||
@@ -498,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)
|
||||
}
|
||||
)
|
||||
@@ -519,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', '3–24 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 caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -548,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
|
||||
@@ -655,13 +544,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'`CustomTags` are the creator’s 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 (a–z once lowercased); one ' +
|
||||
'that isn’t 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 caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -678,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) })
|
||||
}
|
||||
@@ -713,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') },
|
||||
}),
|
||||
@@ -734,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') },
|
||||
}),
|
||||
@@ -780,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 caller’s 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 caller’s inventions'),
|
||||
@@ -799,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))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -818,14 +736,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Records an invention’s 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 3–24 ' +
|
||||
'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 creator’s 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,
|
||||
},
|
||||
}),
|
||||
@@ -844,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),
|
||||
|
||||
@@ -17,19 +17,6 @@ import {
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Client builds the version check answers as current. `GAME_VERSION` is the build the
|
||||
* rest of the stack targets; `20230616` and `20231207` are later clients that talk the
|
||||
* same protocol, so we let them through rather than telling them to update.
|
||||
*
|
||||
* DEBUGGING ONLY: the extra builds are here so we can point other clients at this
|
||||
* server while working on it — they are not a supported-version list. Nothing else in
|
||||
* the stack targets them, so a client waved through here can still hit protocol
|
||||
* differences the version check would otherwise have caught. Trim this back to
|
||||
* `GAME_VERSION` alone before anyone but us is playing.
|
||||
*/
|
||||
const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616', '20231207'])
|
||||
|
||||
// ---- Config / version ------------------------------------------------------
|
||||
export const configRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
@@ -113,13 +100,13 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||
'the builds we accept — our target `GAME_VERSION` plus `20230616`: ' +
|
||||
'`VersionStatus` is 0 for either, 1 for any other build.',
|
||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
||||
'client is on a different build.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: ACCEPTED_GAME_VERSIONS.has(c.req.query('v') ?? '') ? 0 : 1,
|
||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
|
||||
@@ -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 caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d 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 isn’t 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 caller’s 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 club’s event ' +
|
||||
'list is small enough to serve in one page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'The club’s 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 Can’t 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 event’s `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 0–2, 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 can’t 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 can’t 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 doesn’t ' +
|
||||
'transfer and RSVPs aren’t 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 event’s 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)
|
||||
}
|
||||
)
|
||||
@@ -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 caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d 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({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -1,42 +1,79 @@
|
||||
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 ban
|
||||
// storage yet, so this is always the "not blocked" answer — the reference server's
|
||||
// stub `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1), not 0,
|
||||
// which is a real category. `Message` is null rather than the empty string that stub
|
||||
// sends: the client distinguishes "no message" from a blank one.
|
||||
// `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but left unset there, so they
|
||||
// go out with their C# defaults.
|
||||
// POST with no body is the client's actual call, despite this being a pure read; it
|
||||
// answers GET too, so the path is reachable either way.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
||||
// an empty string — the client distinguishes "no message" from a blank one.
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||
'this is always the “not blocked” answer, following the reference server’s stub: ' +
|
||||
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category. ' +
|
||||
'`Message` is null rather than the empty string that stub sends — the client ' +
|
||||
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
||||
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
||||
'defaults.',
|
||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||
'message” from a blank one.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
(c) =>
|
||||
@@ -77,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 service’s `{ 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 CLI’s `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
|
||||
|
||||
@@ -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` worker’s ' +
|
||||
'`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
|
||||
|
||||
@@ -4,18 +4,29 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
GAME_VERSION,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
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'
|
||||
|
||||
@@ -49,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()
|
||||
@@ -84,11 +90,20 @@ beforeAll(async () => {
|
||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// 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
|
||||
@@ -102,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',
|
||||
@@ -118,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`)
|
||||
@@ -152,11 +176,6 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 reports current for the 20230616 build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=20230616`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
@@ -201,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)
|
||||
@@ -221,52 +229,25 @@ 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`)
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
)
|
||||
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: [] })
|
||||
// ReportCategory -1 = no category (0 is a real one), and Message is null.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
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([])
|
||||
})
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
||||
// GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
||||
async (method) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Unauthenticated by design — the client posts this before it has an account, so
|
||||
// there's no bearer token to check and nothing to attribute the id to.
|
||||
test('POST /api/PlayerReporting/v1/deviceId accepts an unauthenticated report', async () => {
|
||||
@@ -358,128 +339,6 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }),
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
})
|
||||
|
||||
test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Account 77 never saves an outfit, so it keeps getting the new-account envelope.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
|
||||
// The client's own payload, trimmed to one selection: the point is that the heavy
|
||||
// JSON-in-a-string fields survive the round trip as strings, unparsed.
|
||||
const outfit = {
|
||||
DataVersion: 2,
|
||||
LegacyData: {
|
||||
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
|
||||
SelectionsV2:
|
||||
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
|
||||
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
|
||||
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
|
||||
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
|
||||
},
|
||||
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
|
||||
Selections: [],
|
||||
Slot: 0,
|
||||
Name: null,
|
||||
Accessibility: 1,
|
||||
ThumbnailFileName: null,
|
||||
}
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(outfit)
|
||||
|
||||
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
|
||||
// strings, not re-encoded objects.
|
||||
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await read.json()).toEqual(outfit)
|
||||
|
||||
// Re-saving overwrites slot 0 rather than adding a second row.
|
||||
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(changed),
|
||||
})
|
||||
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await reread.json()).toEqual(changed)
|
||||
const rows = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
|
||||
).first<{ n: number }>()
|
||||
expect(rows?.n).toBe(1)
|
||||
|
||||
// A save naming another slot does not touch what the caller is wearing.
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
|
||||
})
|
||||
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
||||
})
|
||||
|
||||
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -567,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'
|
||||
@@ -599,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 caller’s 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',
|
||||
@@ -630,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 3–24 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! It’s 100% good.' })).status).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save accepts the client’s 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)
|
||||
@@ -709,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)
|
||||
@@ -908,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' },
|
||||
@@ -919,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`
|
||||
)
|
||||
@@ -928,6 +915,7 @@ describe('public endpoints', () => {
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
BlobHash: await base64Sha256(data),
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
@@ -950,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',
|
||||
@@ -991,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)
|
||||
@@ -1102,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,
|
||||
@@ -1145,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 () => {
|
||||
@@ -1189,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> => {
|
||||
@@ -1296,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.
|
||||
@@ -2009,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 caller’s 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`)
|
||||
@@ -2080,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',
|
||||
@@ -2102,25 +2999,27 @@ describe('openapi', () => {
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /outfits/me',
|
||||
'GET /outfits/me/saved',
|
||||
'GET /voice/config',
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'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',
|
||||
@@ -2135,7 +3034,6 @@ describe('openapi', () => {
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'PUT /outfits/me',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — an undescribed one renders as a bare path.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"CurrentAnnouncement": {
|
||||
"Message": "Server powered by RecFlare",
|
||||
"MoreInfoUrl": "https://github.com/djdevin/recflare"
|
||||
"MoreInfoUrl": "https://recflare.net"
|
||||
},
|
||||
"InstagramImages": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-20
@@ -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
|
||||
@@ -78,11 +122,12 @@ 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` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
| Binding | Type | Notes |
|
||||
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
|
||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||
@@ -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;
|
||||
+385
-120
@@ -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',
|
||||
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
|
||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
'entry’s 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 account’s 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,43 +601,91 @@ 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
|
||||
// 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 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
|
||||
)
|
||||
}
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth ticket',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
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: 'unsupported platform; only Steam and Meta can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (proof.status === 'unconfigured') {
|
||||
// An operator misconfiguration, not the client's fault: without the app secret
|
||||
// every Meta player is locked out, so it answers 500 the way an unset
|
||||
// JWT_SECRET does below rather than blaming the credential. (We never fall
|
||||
// back to trusting the posted id — that would let anyone log into any
|
||||
// Meta-linked account by naming its user id.)
|
||||
logger.error('refusing a Meta login: META_APP_SECRET is empty')
|
||||
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
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
+198
-106
@@ -6,27 +6,32 @@ import {
|
||||
consumeGift,
|
||||
createGift,
|
||||
getGift,
|
||||
getOutfits,
|
||||
getPendingGifts,
|
||||
setOutfit,
|
||||
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'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||
import myProgress from '../static/my-progress.json'
|
||||
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,
|
||||
@@ -38,19 +43,16 @@ import {
|
||||
grantConsumable,
|
||||
} from './consumables-db'
|
||||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
|
||||
import { getInventory, grantItem } from './inventory-db'
|
||||
import {
|
||||
AUTHED,
|
||||
AvatarItemV4Dto,
|
||||
AvatarV2Dto,
|
||||
BalanceEntry,
|
||||
BuyInventionResponse,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
ChallengeProgressRequest,
|
||||
ChallengeProgressResponse,
|
||||
ChecklistCompleteResponse,
|
||||
ChecklistEntry,
|
||||
CompleteChecklistRequest,
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
@@ -68,19 +70,22 @@ import {
|
||||
SubscriptionResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
import { getOutfits, setOutfit } from './outfit-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, StoredGift } from '@repo/domain'
|
||||
import type { GiftContent, StoredGift } from '@repo/domain'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
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, …).
|
||||
*
|
||||
@@ -198,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,
|
||||
}
|
||||
@@ -358,22 +370,6 @@ function toGiftContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
|
||||
* ordinal (from the client's `ProgressionManager`) that the client matches its own
|
||||
* progress events against — the names below are what those ordinals mean.
|
||||
*/
|
||||
const DEFAULT_CHECKLIST = [
|
||||
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
|
||||
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
|
||||
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
|
||||
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
|
||||
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
|
||||
]
|
||||
|
||||
/** The `UpdateResponse` context a checklist reward is reported under. */
|
||||
const CHECKLIST_REWARD_CONTEXT = 303
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||||
@@ -415,12 +411,11 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json(defaultAvatarItems)
|
||||
)
|
||||
|
||||
// The base items UGC clothing is built on top of — served from bundled static JSON,
|
||||
// separate from the `defaultunlocked` catalog. No auth.
|
||||
// Default base avatar items — empty stub for now. No auth.
|
||||
.get(
|
||||
'/api/avatar/v1/defaultbaseavataritems',
|
||||
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
|
||||
(c) => c.json(defaultBaseAvatarItems)
|
||||
listRoute('Default base avatar items', 'Empty stub for now'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The player's avatar items — the items they've bought (from `buyItem`, stored in
|
||||
@@ -434,12 +429,10 @@ const app = new Hono<App>({ strict: false })
|
||||
description: [
|
||||
'The items the player has bought (from buyItem, in the inventory table) prepended',
|
||||
'to the default catalog. A player who has bought nothing gets just the catalog.',
|
||||
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
|
||||
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
|
||||
200: json(JsonArray, 'Owned items followed by the default catalog'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -447,7 +440,7 @@ const app = new Hono<App>({ strict: false })
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const owned = await getInventory(c.env.DB, id)
|
||||
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
|
||||
return c.json([...owned, ...defaultAvatarItems])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -562,69 +555,15 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// NUX checklist — the client fetches this on the econ host during load, on either
|
||||
// version path. A 404 here can abort the load orchestration before matchmake. We
|
||||
// serve the default brand-new-account list to everyone: nothing records per-player
|
||||
// checklist progress yet, so it never shrinks as steps are done.
|
||||
.on(
|
||||
'GET',
|
||||
['/api/checklist/v1/current', '/api/checklist/v2/current'],
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'NUX checklist',
|
||||
description:
|
||||
'The new-user checklist, as the default brand-new-account list — nothing records ' +
|
||||
'per-player progress yet, so the same rows come back however much the player has ' +
|
||||
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
|
||||
'progress events against. v1 and v2 serve the same list.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// NUX checklist — the client fetches this on the econ host during load. []
|
||||
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
||||
.get(
|
||||
'/api/checklist/v1/current',
|
||||
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(DEFAULT_CHECKLIST)
|
||||
}
|
||||
)
|
||||
|
||||
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
|
||||
// table to record the completion in, and no reward ledger to make the 25-token grant
|
||||
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
|
||||
// we grant nothing and report a change of 0. The envelope is still the balance-update
|
||||
// shape the client parses, so the flow completes instead of erroring.
|
||||
.on(
|
||||
'POST',
|
||||
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Complete a checklist row (stub)',
|
||||
description:
|
||||
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
|
||||
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
|
||||
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
|
||||
're-posting the same row would mint tokens indefinitely. The response is still the ' +
|
||||
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
|
||||
responses: {
|
||||
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
|
||||
// once there is somewhere to record it.
|
||||
return c.json({
|
||||
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
|
||||
Balance: 0,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceType: -2,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1080,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'),
|
||||
@@ -1201,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
|
||||
@@ -1248,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 client’s `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 buyer’s resulting',
|
||||
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
|
||||
'their CHANGE (the buyer’s negative, the creator’s 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(
|
||||
|
||||
@@ -40,43 +40,6 @@ export interface AvatarItem extends Record<string, unknown> {
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase
|
||||
* `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`,
|
||||
* `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw.
|
||||
*/
|
||||
export interface AvatarItemV4 {
|
||||
avatarItemId: number
|
||||
avatarItemDesc: string
|
||||
friendlyName: string
|
||||
tooltip: string
|
||||
tagList: string
|
||||
avatarItemType: number
|
||||
rarity: number
|
||||
isBaseAvatarItem: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored or bundled avatar item into the v4 DTO. Neither source carries an
|
||||
* `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops
|
||||
* we grant from have none and the default catalog has none either — so those default to
|
||||
* 0 / "" / false rather than being invented.
|
||||
*/
|
||||
export function toAvatarItemV4(item: Record<string, unknown>): AvatarItemV4 {
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return {
|
||||
avatarItemId: num(item.AvatarItemId),
|
||||
avatarItemDesc: str(item.AvatarItemDesc),
|
||||
friendlyName: str(item.FriendlyName),
|
||||
tooltip: str(item.Tooltip),
|
||||
tagList: str(item.TagList),
|
||||
avatarItemType: num(item.AvatarItemType),
|
||||
rarity: num(item.Rarity),
|
||||
isBaseAvatarItem: item.IsBaseAvatarItem === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
|
||||
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
|
||||
|
||||
+28
-51
@@ -98,56 +98,6 @@ export const CustomAvatarItemsResponse = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase
|
||||
* records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty
|
||||
* for every item we have: neither the default catalog nor a storefront gift-drop carries
|
||||
* them.
|
||||
*/
|
||||
export const AvatarItemV4Dto = z.object({
|
||||
avatarItemId: z.int(),
|
||||
avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'),
|
||||
friendlyName: z.string(),
|
||||
tooltip: z.string(),
|
||||
tagList: z.string(),
|
||||
avatarItemType: z.int(),
|
||||
rarity: z.int(),
|
||||
isBaseAvatarItem: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished.
|
||||
* The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when
|
||||
* `ItemIndex` is absent or 0.
|
||||
*/
|
||||
export const CompleteChecklistRequest = z.object({
|
||||
ItemIndex: z.int().describe('The row’s index — what the client actually sends'),
|
||||
Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape
|
||||
* buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted)
|
||||
* completion reports 0. `UpdateResponse` 303 is the checklist-reward context.
|
||||
*/
|
||||
export const ChecklistCompleteResponse = z.object({
|
||||
BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })),
|
||||
Balance: z.int().describe('The change applied — 0 while completion is stubbed'),
|
||||
CurrencyType: z.int(),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is
|
||||
* an `ObjectiveType` ordinal the client matches its own progress events against.
|
||||
*/
|
||||
export const ChecklistEntry = z.object({
|
||||
Order: z.int().describe('Position in the list, from 0'),
|
||||
Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'),
|
||||
Count: z.int().describe('How many times the objective must happen'),
|
||||
CreditAmount: z.int().describe('Tokens awarded on completion'),
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
@@ -180,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 -------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
|
||||
* saves from the avatar screen.
|
||||
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from
|
||||
* `GET /api/avatar/v3/saved`.
|
||||
*
|
||||
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
|
||||
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
|
||||
@@ -8,19 +9,11 @@
|
||||
* serializer. Round-tripping it verbatim is both the simplest and the safest thing —
|
||||
* re-encoding risks changing a payload the client has to parse back.
|
||||
*
|
||||
* The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and
|
||||
* serves the slot list (`GET /api/avatar/v3/saved`, `POST /api/avatar/v3/saved/set`). The
|
||||
* `api` worker reads and writes SLOT 0 through `/outfits/me` — the newer client treats
|
||||
* slot 0 as the outfit currently worn. Both import these helpers so the table name and
|
||||
* row shape live in one place.
|
||||
*
|
||||
* Note the two write paths store DIFFERENT payload shapes into the same column: econ's
|
||||
* saved-outfit slots hold the old flat PascalCase outfit, while `/outfits/me` holds the
|
||||
* newer `{ DataVersion, LegacyData, CustomizationSettings, … }` envelope. Each endpoint
|
||||
* serves back what it stored, so don't add a projection that assumes either one.
|
||||
* The `econ` worker owns this table and its migration (apps/econ/migrations/
|
||||
* 0002_outfit.sql).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
|
||||
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */
|
||||
export const OUTFIT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS outfit (
|
||||
account_id INTEGER NOT NULL,
|
||||
@@ -34,15 +27,13 @@ export const OUTFIT_SCHEMA_DDL: string[] = [
|
||||
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
|
||||
* `set_id` column) — saving to a slot the player already used overwrites it, which is
|
||||
* exactly what the avatar screen's "save over this outfit" does. The rest of the
|
||||
* payload is stored and served back untouched.
|
||||
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor,
|
||||
* CustomAvatarItems, …) is stored and served back untouched.
|
||||
*/
|
||||
export interface Outfit extends Record<string, unknown> {
|
||||
Slot: number
|
||||
}
|
||||
|
||||
/** The slot the newer client wears — what `/outfits/me` reads and writes. */
|
||||
export const CURRENT_OUTFIT_SLOT = 0
|
||||
|
||||
/** Every outfit a player has saved, ordered by slot. */
|
||||
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
|
||||
const { results } = await db
|
||||
@@ -52,19 +43,6 @@ export async function getOutfits(db: D1Database, accountId: number): Promise<Out
|
||||
return results.map((r) => JSON.parse(r.avatar) as Outfit)
|
||||
}
|
||||
|
||||
/** One slot's outfit, or null when the player has never saved into it. */
|
||||
export async function getOutfit(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
slot: number
|
||||
): Promise<Outfit | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 AND set_id = ?2')
|
||||
.bind(accountId, slot)
|
||||
.first<{ avatar: string }>()
|
||||
return row ? (JSON.parse(row.avatar) as Outfit) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an outfit into one of the player's slots, replacing whatever was there. The
|
||||
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
|
||||
@@ -4,8 +4,15 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../econ.app'
|
||||
|
||||
import { OUTFIT_SCHEMA_DDL, 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,
|
||||
@@ -17,6 +24,7 @@ import {
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -38,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
|
||||
@@ -98,15 +171,10 @@ describe('econ endpoints', () => {
|
||||
expect(body[0]).toHaveProperty('AvatarItemDesc')
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
|
||||
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
|
||||
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
|
||||
// is part of the item descriptor — both are served verbatim.
|
||||
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
|
||||
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v4/items 401s without a token', async () => {
|
||||
@@ -114,34 +182,16 @@ describe('econ endpoints', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
|
||||
test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<Record<string, unknown>>
|
||||
const body = (await res.json()) as unknown[]
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
// Every key of the DTO is present on every item, and nothing PascalCase leaks
|
||||
// through from the stored/bundled records.
|
||||
for (const item of body) {
|
||||
expect(Object.keys(item).sort()).toEqual([
|
||||
'avatarItemDesc',
|
||||
'avatarItemId',
|
||||
'avatarItemType',
|
||||
'friendlyName',
|
||||
'isBaseAvatarItem',
|
||||
'rarity',
|
||||
'tagList',
|
||||
'tooltip',
|
||||
])
|
||||
}
|
||||
expect(typeof body[0]?.avatarItemDesc).toBe('string')
|
||||
expect(typeof body[0]?.friendlyName).toBe('string')
|
||||
// The catalog carries no ids, tags or base flag — those default rather than
|
||||
// being invented.
|
||||
expect(body[0]?.avatarItemId).toBe(0)
|
||||
expect(body[0]?.tagList).toBe('')
|
||||
expect(body[0]?.isBaseAvatarItem).toBe(false)
|
||||
expect(body[0]).toHaveProperty('AvatarItemDesc')
|
||||
expect(body[0]).toHaveProperty('FriendlyName')
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v2 401s without a token', async () => {
|
||||
@@ -292,53 +342,14 @@ describe('econ endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
|
||||
const expected = [
|
||||
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
|
||||
]
|
||||
// Both version paths are live and serve the same list.
|
||||
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
expect(anon.status).toBe(401)
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
|
||||
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ItemIndex: 1 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ItemIndex: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
|
||||
Balance: 0,
|
||||
CurrencyType: 2,
|
||||
BalanceType: -2,
|
||||
})
|
||||
}
|
||||
|
||||
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
|
||||
// tokens, and the checklist still lists every row.
|
||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('33'),
|
||||
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
|
||||
expect(anon.status).toBe(401)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
|
||||
@@ -690,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' },
|
||||
@@ -717,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'),
|
||||
@@ -727,9 +749,9 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('20'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
|
||||
expect(list[0].friendlyName).toBe('Bowtie (White)')
|
||||
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
|
||||
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
|
||||
expect(list[0].FriendlyName).toBe('Bowtie (White)')
|
||||
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
|
||||
|
||||
// And a pending gift box is waiting to be opened.
|
||||
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
||||
@@ -810,8 +832,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('25'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true)
|
||||
|
||||
// Buying it again stacks: a second instance, count summed to 2.
|
||||
expect((await buy()).status).toBe(200)
|
||||
@@ -877,8 +899,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('31'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||
|
||||
expect(first[0].Favorited).toBe(false)
|
||||
|
||||
@@ -977,8 +999,155 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('23'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
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 buyer’s 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 () => {
|
||||
@@ -1018,8 +1187,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('24'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
|
||||
|
||||
// Opening it again is a harmless no-op — still 200.
|
||||
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
|
||||
@@ -1230,7 +1399,6 @@ describe('econ endpoints', () => {
|
||||
'GET /api/avatar/v4/items',
|
||||
'GET /api/challenge/v2/getCurrent',
|
||||
'GET /api/checklist/v1/current',
|
||||
'GET /api/checklist/v2/current',
|
||||
'GET /api/consumables/v2/getUnlocked',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gamerewards/v1/pending',
|
||||
@@ -1244,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',
|
||||
@@ -1253,8 +1422,6 @@ describe('econ endpoints', () => {
|
||||
'POST /api/avatar/v3/saved/set',
|
||||
'POST /api/avatar/v4/saved/set',
|
||||
'POST /api/challenge/v2/updateProgress',
|
||||
'POST /api/checklist/v1/complete',
|
||||
'POST /api/checklist/v2/complete',
|
||||
'POST /api/consumables/v1/consume',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[
|
||||
{
|
||||
"AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,",
|
||||
"AvatarItemType": 0,
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "(UGCTee_Shirt) ",
|
||||
"Tooltip": "",
|
||||
"Rarity": -1,
|
||||
"TagList": "",
|
||||
"AvatarItemId": 2184,
|
||||
"IsBaseAvatarItem": true,
|
||||
"CreatedAt": "2022-04-19T23:40:30.807Z",
|
||||
"ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png"
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,",
|
||||
"AvatarItemType": 0,
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "(UGCPulloverHoodie_Shirt) ",
|
||||
"Tooltip": "",
|
||||
"Rarity": -1,
|
||||
"TagList": "",
|
||||
"AvatarItemId": 2918,
|
||||
"IsBaseAvatarItem": true,
|
||||
"CreatedAt": "2023-04-07T17:07:07.04Z",
|
||||
"ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png"
|
||||
}
|
||||
]
|
||||
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
+235
-216
@@ -21,23 +21,25 @@ 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'
|
||||
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import {
|
||||
AUTHED,
|
||||
ConnectionInfoResponse,
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
@@ -50,8 +52,8 @@ import {
|
||||
MatchmakeRoomRequest,
|
||||
NotifyDisconnectRequest,
|
||||
PlayerDto,
|
||||
QosRegion,
|
||||
RoomInstanceDto,
|
||||
RoomInstanceSummaryDto,
|
||||
StatusVisibilityRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
@@ -87,54 +89,6 @@ const NULL_CONNECTION_INFO = {
|
||||
experiments: null,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The Photon applications the client connects to (`GET /player/connection-info`).
|
||||
* Temporary placeholders — move them to wrangler vars before they need to differ per
|
||||
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
|
||||
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
|
||||
*/
|
||||
const PHOTON_APPS = {
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
photonRegion: 'us',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Networking feature flags the client reads off its connection info. Verbatim from
|
||||
* the reference server — the client changes how it replicates based on these, so they
|
||||
* are not free to tune. The load-bearing one is `shouldUseGameServerNetworking`:
|
||||
* true makes the client connect to a local game server (127.0.0.1:7777) instead of
|
||||
* Photon, which is not what recflare runs.
|
||||
*/
|
||||
const PHOTON_EXPERIMENTS = {
|
||||
networkTransformSyncInterval: 10.0,
|
||||
shouldUseUnreliableOnChange: false,
|
||||
shouldAvoidDiscontinuityRPCs: true,
|
||||
shouldAvoidRedundantDiscontinuity: false,
|
||||
r2RuntimeStaticBaking: true,
|
||||
r2AutoEmbodiment: true,
|
||||
r2RuntimeStaticBakingMinShapeThreshold: 1,
|
||||
r2UseCheapReplicas: true,
|
||||
shouldUseGameServerNetworking: false,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The regions the client probes for latency (`GET /player/qos`), reporting the results
|
||||
* back through `PUT /player/photonregionpings`. Rec Room's own QoS endpoints, served
|
||||
* verbatim: recflare doesn't run probe servers, and the client only uses the timings to
|
||||
* rank regions — a ranking it can't act on here, since `PHOTON_APPS.photonRegion` pins
|
||||
* every session to one region regardless. `address` is `host:port`, not a URL.
|
||||
*/
|
||||
const QOS_REGIONS = [
|
||||
{ id: 'us-west1', address: '34.169.254.144:50000' },
|
||||
{ id: 'europe-west1', address: '35.205.141.119:50000' },
|
||||
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
|
||||
{ id: 'us-east1', address: '34.73.244.122:50000' },
|
||||
{ id: 'us-central1', address: '34.69.179.51:50000' },
|
||||
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A player's presence as the client reads it (`/player`, `/player/heartbeat`).
|
||||
* `isOnline` means "has a live presence row" — presence rows expire, so a player who
|
||||
@@ -323,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'
|
||||
|
||||
@@ -526,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>,
|
||||
@@ -537,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)
|
||||
@@ -575,13 +557,16 @@ async function resolveRoomInstance(
|
||||
roomInstanceType: f.roomInstanceType,
|
||||
})
|
||||
}
|
||||
return roomInstanceFromRoom(
|
||||
room,
|
||||
isPrivate,
|
||||
instance.roomInstanceId,
|
||||
instance.photonRoomId,
|
||||
f.subRoomId
|
||||
)
|
||||
return {
|
||||
instance: roomInstanceFromRoom(
|
||||
room,
|
||||
isPrivate,
|
||||
instance.roomInstanceId,
|
||||
instance.photonRoomId,
|
||||
f.subRoomId
|
||||
),
|
||||
errorCode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -909,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 isn’t a member.',
|
||||
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
||||
'55 when they are banned from the clubhouse room.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||
@@ -925,7 +911,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The clubhouse instance (or errorCode 20 with null when it can’t be entered)'
|
||||
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
@@ -945,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 })
|
||||
}
|
||||
@@ -975,7 +961,9 @@ const app = new Hono<App>()
|
||||
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||
'caller themselves, or isn’t currently in a room.',
|
||||
'caller themselves, or isn’t 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: [
|
||||
@@ -990,7 +978,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
||||
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
@@ -1011,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)
|
||||
@@ -1018,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 owner’s instance listing. Gated to the room’s 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 doesn’t 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 can’t 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 room’s 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
|
||||
@@ -1044,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,
|
||||
},
|
||||
}),
|
||||
@@ -1053,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)
|
||||
@@ -1083,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,
|
||||
},
|
||||
}),
|
||||
@@ -1091,49 +1180,19 @@ 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)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
// Matchmake with no target. The client posts this when it needs an instance but isn't
|
||||
// going anywhere in particular — at startup, and while sitting in Orientation. It
|
||||
// answers the instance the player is ALREADY in, so it never warps anyone out of the
|
||||
// room they're standing in; only a player with no live presence falls back to their
|
||||
// dorm. Either way presence is re-committed, which refreshes its TTL.
|
||||
.post(
|
||||
'/matchmake/none',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Matchmake with no target',
|
||||
description: [
|
||||
'Answers the instance the caller is already in, rather than sending them anywhere —',
|
||||
'this is what the client posts at startup and while in Orientation, so forcing a',
|
||||
'destination here would warp the player out of the room they are standing in. A',
|
||||
'caller with no live presence (their TTL lapsed, or they have never entered a room)',
|
||||
'falls back to their personal dorm. Re-commits presence either way, refreshing its',
|
||||
'TTL.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The caller’s current instance, or their dorm'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
|
||||
await enterRoom(c, id, current)
|
||||
return c.json({ errorCode: 0, roomInstance: current })
|
||||
}
|
||||
)
|
||||
|
||||
.post(
|
||||
'/matchmake/dorm',
|
||||
describeRoute({
|
||||
@@ -1160,104 +1219,6 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The realtime credentials the caller should connect with: a freshly minted Photon
|
||||
// auth token, the Photon applications, and the Photon room they belong in. That last
|
||||
// one comes from the caller's own presence — the instance matchmaking put them in —
|
||||
// so it's the same name every other player in that instance is given. The reference
|
||||
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
|
||||
// param up when presence has no room (it expires on a TTL, and the client sometimes
|
||||
// asks before matchmaking has landed), and to an empty string when neither resolves.
|
||||
.get(
|
||||
'/player/connection-info',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'Photon connection info',
|
||||
description: [
|
||||
'The realtime (Photon) credentials the caller should connect with, in a',
|
||||
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
|
||||
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
|
||||
'(from their presence, falling back to the `roomInstanceId` query param). There is',
|
||||
'no separate voice server, so the voice fields are null. `experiments` carries the',
|
||||
'client’s networking flags.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomInstanceId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The instance being connected to; used only when presence has no room',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(ConnectionInfoResponse, 'The Photon credentials, room, and experiment flags'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
// Presence first (it's the instance the player is actually in); the query param
|
||||
// only stands in when there's no live presence to read.
|
||||
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
|
||||
if (!photonRoomId) {
|
||||
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
|
||||
if (!Number.isNaN(requested)) {
|
||||
photonRoomId = (await getRoomInstance(c.env.DB, requested))?.photonRoomId ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
||||
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
||||
// self-hosted, so it's identifying rather than authorizing.
|
||||
const photonAuthToken = await generatePhotonAuthToken(
|
||||
id,
|
||||
{
|
||||
platformId: (await getAccount(c.env.DB, id))?.platformId ?? '',
|
||||
platform: presence?.platform ?? 0,
|
||||
deviceClass: presence?.deviceClass ?? 0,
|
||||
audience: PHOTON_APPS.photonRealtimeAppId,
|
||||
},
|
||||
await c.env.JWT_SECRET.get()
|
||||
)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
value: {
|
||||
photonAuthToken,
|
||||
...PHOTON_APPS,
|
||||
photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
experiments: PHOTON_EXPERIMENTS,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The regions to probe, which the two ping-report routes below are the other half of.
|
||||
// Unauthenticated: it's a fixed public list, and the client fetches it early. A bare
|
||||
// array — no `{ success, value, error }` envelope.
|
||||
.get(
|
||||
'/player/qos',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'QoS probe targets',
|
||||
description: [
|
||||
'The regions the client pings to measure latency, reporting the results back through',
|
||||
'`PUT /player/photonregionpings`. Rec Room’s own probe endpoints, served verbatim —',
|
||||
'recflare runs none of its own, and the resulting ranking is unused anyway: every',
|
||||
'session is pinned to the one region `/player/connection-info` hands out.',
|
||||
].join(' '),
|
||||
responses: { 200: json(QosRegion.array(), 'The regions to probe, as `host:port`') },
|
||||
}),
|
||||
(c) => c.json(QOS_REGIONS)
|
||||
)
|
||||
|
||||
// Region ping reports — accept-and-ack (the reference returns Ok()).
|
||||
.put(
|
||||
'/player/photonregionpings',
|
||||
@@ -1348,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 instance’s in-progress flag when a session starts (e.g. a',
|
||||
'round begins). Body is `inProgress=True|False`.',
|
||||
'Flips the instance’s 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',
|
||||
'room’s owner. Body is `inProgress=True|False`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
||||
@@ -1385,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',
|
||||
'instance’s room’s 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 room’s 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 room’s live instances',
|
||||
description: [
|
||||
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
||||
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
||||
'The owner’s view of active sessions of their room — each instance with the',
|
||||
'players currently in it. Auth-gated and gated to the room’s creator or a',
|
||||
'co-owner (403 otherwise). Unknown room → 404.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -1409,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 room’s creator or a co-owner (empty body)' },
|
||||
404: { description: 'No such room (empty body)' },
|
||||
@@ -1426,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
-60
@@ -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,72 +144,15 @@ 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(),
|
||||
})
|
||||
|
||||
/** `POST /player/exclusivelogin` — a bare error code. */
|
||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
||||
|
||||
/**
|
||||
* The networking feature flags the client reads off its connection info — verbatim
|
||||
* from the reference server. The client changes how it replicates based on these, so
|
||||
* they are not free to tune. `shouldUseGameServerNetworking` is the load-bearing one:
|
||||
* true points the client at a local game server (127.0.0.1:7777) instead of Photon.
|
||||
*/
|
||||
export const ConnectionExperiments = z.object({
|
||||
networkTransformSyncInterval: z.number(),
|
||||
shouldUseUnreliableOnChange: z.boolean(),
|
||||
shouldAvoidDiscontinuityRPCs: z.boolean(),
|
||||
shouldAvoidRedundantDiscontinuity: z.boolean(),
|
||||
r2RuntimeStaticBaking: z.boolean(),
|
||||
r2AutoEmbodiment: z.boolean(),
|
||||
r2RuntimeStaticBakingMinShapeThreshold: z.int(),
|
||||
r2UseCheapReplicas: z.boolean(),
|
||||
shouldUseGameServerNetworking: z
|
||||
.boolean()
|
||||
.describe('true connects to a local game server instead of Photon'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /player/connection-info` — the realtime (Photon) credentials, in a
|
||||
* `{ success, value, error }` envelope. The applications and region are fixed for
|
||||
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
|
||||
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
||||
* — the same name every other player in that instance is handed. There's no separate
|
||||
* voice server, so both voice fields are null. `photonRegion` matches the one stamped
|
||||
* on every room instance, so the two can't disagree.
|
||||
*/
|
||||
export const ConnectionInfo = z.object({
|
||||
photonAuthToken: z.string().describe('Short-lived HS256 token identifying the caller to Photon'),
|
||||
photonRealtimeAppId: z.string().describe('Photon Realtime application id'),
|
||||
photonVoiceAppId: z.string().describe('Photon Voice application id'),
|
||||
photonChatAppId: z.string().describe('Photon Chat application id'),
|
||||
photonRegion: z.string().describe('Region id, matching a room instance’s `photonRegion`'),
|
||||
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'),
|
||||
voiceConnectionInfo: z.null().describe('Null — no separate voice server'),
|
||||
voiceServerId: z.null().describe('Null — no separate voice server'),
|
||||
experiments: ConnectionExperiments,
|
||||
})
|
||||
|
||||
/** `GET /player/connection-info` — the connection info in the client's standard envelope. */
|
||||
export const ConnectionInfoResponse = z.object({
|
||||
success: z.literal(true),
|
||||
value: ConnectionInfo,
|
||||
error: z.null(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One QoS probe target (`GET /player/qos`) — a region the client pings to measure
|
||||
* latency, then reports back through `PUT /player/photonregionpings`. A bare array,
|
||||
* not the `{ success, value, error }` envelope. `id` is the region id the pings are
|
||||
* keyed by; `address` is `host:port`, not a URL.
|
||||
*/
|
||||
export const QosRegion = z.object({
|
||||
id: z.string().describe('Region id, e.g. `us-east1`'),
|
||||
address: z.string().describe('`host:port` of the probe endpoint'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The session `LoginLock` GUID form field. The client posts it on every presence
|
||||
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
|
||||
|
||||
@@ -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>)
|
||||
@@ -470,25 +465,6 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
test('GET /player/connection-info 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /player/qos returns the probe targets', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
|
||||
expect(res.status).toBe(200)
|
||||
// A bare array, not the { success, value, error } envelope connection-info uses.
|
||||
expect(await res.json()).toEqual([
|
||||
{ id: 'us-west1', address: '34.169.254.144:50000' },
|
||||
{ id: 'europe-west1', address: '35.205.141.119:50000' },
|
||||
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
|
||||
{ id: 'us-east1', address: '34.73.244.122:50000' },
|
||||
{ id: 'us-central1', address: '34.69.179.51:50000' },
|
||||
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
|
||||
])
|
||||
})
|
||||
|
||||
test('PUT /player/photonregionpings returns 200', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -530,99 +506,6 @@ describe('auth-gated endpoints', () => {
|
||||
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
|
||||
})
|
||||
|
||||
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
|
||||
const matchmaked = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('960'),
|
||||
})
|
||||
).json()) as { roomInstance: { photonRoomId: string } }
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('960'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
value: {
|
||||
// A signed JWT, not an opaque id — three base64url segments.
|
||||
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
// Matches the region every room instance is stamped with.
|
||||
photonRegion: 'us',
|
||||
// The room the client is told to join has to be the one matchmaking placed
|
||||
// them in, or they end up alone in a room of their own.
|
||||
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
experiments: {
|
||||
networkTransformSyncInterval: 10,
|
||||
shouldUseUnreliableOnChange: false,
|
||||
shouldAvoidDiscontinuityRPCs: true,
|
||||
shouldAvoidRedundantDiscontinuity: false,
|
||||
r2RuntimeStaticBaking: true,
|
||||
r2AutoEmbodiment: true,
|
||||
r2RuntimeStaticBakingMinShapeThreshold: 1,
|
||||
r2UseCheapReplicas: true,
|
||||
// true would send the client to a local game server instead of Photon.
|
||||
shouldUseGameServerNetworking: false,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /player/connection-info mints a token carrying the caller’s id', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('961'),
|
||||
})
|
||||
const body = (await res.json()) as { value: { photonAuthToken: string } }
|
||||
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
|
||||
sub: string
|
||||
aud: string
|
||||
exp: number
|
||||
'rn.env': string
|
||||
}
|
||||
expect(claims.sub).toBe('961')
|
||||
// Scoped to the realtime app, and short-lived.
|
||||
expect(claims.aud).toBe('xx')
|
||||
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
|
||||
// The client is built against prod regardless of which environment we run in.
|
||||
expect(claims['rn.env']).toBe('prod')
|
||||
})
|
||||
|
||||
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
|
||||
// Player 962 never matchmade, so there's no presence to read the room from; the
|
||||
// param names the instance they're trying to connect to.
|
||||
const instance = await createRoomInstance(env.DB, {
|
||||
roomId: 2,
|
||||
subRoomId: 2,
|
||||
roomInstanceType: 0,
|
||||
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
maxCapacity: 12,
|
||||
isPrivate: false,
|
||||
ownerAccountId: 962,
|
||||
})
|
||||
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
|
||||
{ headers: await bearer('962') }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { value: { photonRoomId: string } }
|
||||
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
})
|
||||
|
||||
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('963'),
|
||||
})
|
||||
const body = (await res.json()) as { value: { photonRoomId: string } }
|
||||
expect(body.value.photonRoomId).toBe('')
|
||||
})
|
||||
|
||||
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
|
||||
// The client keys the room transition off a changing roomInstanceId; handing back
|
||||
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
||||
@@ -676,45 +559,6 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/none 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
|
||||
const none = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
|
||||
|
||||
// Account 44 has never entered a room → their personal dorm, and a second call is
|
||||
// idempotent now that presence holds it.
|
||||
const fresh = await none('44')
|
||||
expect(fresh.errorCode).toBe(0)
|
||||
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
|
||||
expect((await none('44')).roomInstance).toMatchObject({
|
||||
roomId: fresh.roomInstance.roomId,
|
||||
roomInstanceId: fresh.roomInstance.roomInstanceId,
|
||||
})
|
||||
|
||||
// Once in a real room, `none` must NOT warp them out of it — that is the whole
|
||||
// point of the endpoint, since the client posts it while sitting in Orientation.
|
||||
const entered = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('44'),
|
||||
})
|
||||
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
|
||||
expect(entered.roomInstance.roomId).toBe(2)
|
||||
expect((await none('44')).roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
roomInstanceId: entered.roomInstance.roomInstanceId,
|
||||
})
|
||||
})
|
||||
|
||||
test('each player’s dorm gets a distinct global subroom id', async () => {
|
||||
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
|
||||
// With subrooms minted from the global sequence, each dorm gets its own unique id.
|
||||
@@ -1140,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'),
|
||||
@@ -1152,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 = {
|
||||
@@ -1397,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 () => {
|
||||
@@ -1484,15 +1549,13 @@ describe('auth-gated endpoints', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /player',
|
||||
'GET /player/connection-info',
|
||||
'GET /player/qos',
|
||||
'GET /room/{roomId}/instances',
|
||||
'GET /rooms/requiring/developer',
|
||||
'GET /rooms/requiring/rrplus',
|
||||
'POST /invite',
|
||||
'POST /matchmake/club/{clubId}',
|
||||
'POST /matchmake/dorm',
|
||||
'POST /matchmake/none',
|
||||
'POST /matchmake/instance/{instanceId}',
|
||||
'POST /matchmake/player/{playerId}',
|
||||
'POST /matchmake/room/{roomId}',
|
||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||
@@ -1501,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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,7 @@ export enum NotificationType {
|
||||
ModerationUpdateRequired = 21,
|
||||
ModerationKick = 22,
|
||||
ModerationKickAttemptFailed = 23,
|
||||
ModerationRoomBan = 24,
|
||||
ModerationRoomBan = "ModerationRoomBan",
|
||||
ServerMaintenance = 25,
|
||||
GiftPackageReceived = 30,
|
||||
GiftPackageReceivedImmediate = 31,
|
||||
|
||||
@@ -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('*')
|
||||
})
|
||||
})
|
||||
|
||||
+4
-11
@@ -7,18 +7,11 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
|
||||
Notifications, …).
|
||||
|
||||
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
|
||||
service → subdomain map in `src/endpoints.ts`, with the `SUBDOMAINS` var applied
|
||||
on top. Both vars are injected at deploy time from `RECFLARE_DOMAIN` and
|
||||
`RECFLARE_SUBDOMAINS` (see `run-wrangler-deploy`) and default to
|
||||
`rec.example.com` / `{}` in `wrangler.jsonc` for local dev.
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
|
||||
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
|
||||
`rec.example.com` in `wrangler.jsonc` for local dev.
|
||||
|
||||
## Updating endpoints
|
||||
|
||||
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
|
||||
- To point one service at a different host, add it to `RECFLARE_SUBDOMAINS` (in
|
||||
`.env`) and redeploy. It's keyed by the service's _default_ subdomain — the
|
||||
same object `run-wrangler-deploy` reads to pick a worker's host, so an entry
|
||||
moves the deployed worker and the advertised host together. An entry for a
|
||||
service with no worker (e.g. `{"moderation":"api"}`) is a pure client-side
|
||||
redirect onto a host another worker already serves.
|
||||
- To add or rename a service, edit the map in `src/endpoints.ts`.
|
||||
- To add or rename a service host, edit the map in `src/endpoints.ts`.
|
||||
|
||||
@@ -8,14 +8,6 @@ export type Env = SharedHonoEnv & {
|
||||
* for local dev and tests.
|
||||
*/
|
||||
DOMAIN: string
|
||||
|
||||
/**
|
||||
* Per-service subdomain overrides as a raw JSON object keyed by default subdomain,
|
||||
* e.g. `{"moderation":"api"}`. The operator's `RECFLARE_SUBDOMAINS`, injected at deploy
|
||||
* time via `--var SUBDOMAINS`; defaults to `{}` in `wrangler.jsonc`. See
|
||||
* `parseOverrides` in `endpoints.ts`.
|
||||
*/
|
||||
SUBDOMAINS: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Service-discovery map: service label → default subdomain. The game client fetches the
|
||||
* Service-discovery map: service label → subdomain. The game client fetches the
|
||||
* generated `{ label: "https://<subdomain>.<domain>" }` document from `/`.
|
||||
*
|
||||
* The base domain is injected at deploy time via the `DOMAIN` var (see
|
||||
* `run-wrangler-deploy`), so the real domain never lives in a versioned file. The
|
||||
* subdomains here are defaults — an operator redirects any of them from `.env`, see
|
||||
* `applyOverrides` below.
|
||||
* `run-wrangler-deploy`), so the real domain never lives in a versioned file.
|
||||
*/
|
||||
const SERVICE_SUBDOMAINS = {
|
||||
Accounts: 'accounts',
|
||||
@@ -46,48 +44,9 @@ const SERVICE_SUBDOMAINS = {
|
||||
WWW: 'www',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parses the `SUBDOMAINS` var — the operator's `RECFLARE_SUBDOMAINS` object, injected at
|
||||
* deploy time by `run-wrangler-deploy`.
|
||||
*
|
||||
* It is keyed by the DEFAULT subdomain above, not by the service label, because the deploy
|
||||
* script reads the very same object keyed by a worker's directory name — and every worker's
|
||||
* directory name is its default subdomain. So one `.env` entry moves both sides at once:
|
||||
* `{"playersettings":"settings"}` both deploys the `playersettings` worker onto
|
||||
* `settings.<domain>` and advertises that host to the client. Entries naming a service with
|
||||
* no worker of its own are pure client-side redirects — `{"moderation":"api"}` points the
|
||||
* client's Moderation calls at the `api` worker, which is where the
|
||||
* `/api/PlayerReporting/…` routes actually live.
|
||||
*
|
||||
* A malformed value is ignored rather than thrown: this document is the first thing the
|
||||
* client fetches, so a typo in `.env` should cost one redirect, not every service host.
|
||||
*/
|
||||
function parseOverrides(subdomains: string | undefined): Record<string, string> {
|
||||
if (!subdomains) return {}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(subdomains)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
||||
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
|
||||
export function buildEndpoints(domain: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the endpoints document for `domain`, e.g. `rec.example.com`, applying any
|
||||
* subdomain overrides from `subdomains` (the raw `SUBDOMAINS` var JSON).
|
||||
*/
|
||||
export function buildEndpoints(domain: string, subdomains?: string): Record<string, string> {
|
||||
const overrides = parseOverrides(subdomains)
|
||||
return Object.fromEntries(
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
|
||||
label,
|
||||
`https://${overrides[sub] ?? sub}.${domain}`,
|
||||
])
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,6 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Endpoints document, derived from the deploy-time base domain.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS)))
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -18,19 +18,6 @@ describe('ns endpoints', () => {
|
||||
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
})
|
||||
|
||||
test('a subdomain override redirects that service only', () => {
|
||||
const endpoints = buildEndpoints(TEST_DOMAIN, '{"moderation":"api"}')
|
||||
expect(endpoints.Moderation).toBe(`https://api.${TEST_DOMAIN}`)
|
||||
expect(endpoints.API).toBe(`https://api.${TEST_DOMAIN}`)
|
||||
expect(endpoints.Accounts).toBe(`https://accounts.${TEST_DOMAIN}`)
|
||||
})
|
||||
|
||||
test('a malformed override object is ignored', () => {
|
||||
for (const bad of ['', '{', 'null', '[]', '{"moderation":42}', '{"moderation":""}']) {
|
||||
expect(buildEndpoints(TEST_DOMAIN, bad)).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
|
||||
"SUBDOMAINS": "{}" // per-service subdomain overrides; overridden during deployment
|
||||
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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);
|
||||
@@ -64,6 +64,12 @@ export const FORBIDDEN_RESPONSE = {
|
||||
description: 'A valid token, but not the room’s 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 ' +
|
||||
'permission’s 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 subroom’s 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
@@ -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, 100–104 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 screen’s 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 room’s instances — falling back to stored engagement',
|
||||
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||
'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 friend’s 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 player’s 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 room’s ban list',
|
||||
description: [
|
||||
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
|
||||
'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
|
||||
'token carries the `developer` / `moderator` role. A ban list says who a room’s',
|
||||
'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 room’s 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 player’s matchmake into this room is',
|
||||
'refused with errorCode 55 and never gets a Photon room id.',
|
||||
'',
|
||||
'Gated to the room’s 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 room’s 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 player’s `room_ban` row, so they can matchmake into the room again.',
|
||||
'Gated exactly like issuing a ban: the room’s 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 room’s load screen',
|
||||
description: [
|
||||
'APPENDS one `{ ImageName, Title, Subtitle }` to the room’s `LoadScreens` — the images',
|
||||
'shown while the room loads. There is no remove or replace counterpart. Owner or',
|
||||
'co-owner only (403 otherwise).',
|
||||
'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,',
|
||||
'Subtitle }` — the image shown while the room loads. The field is an array (the',
|
||||
'client’s 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 subroom’s permissions',
|
||||
description: [
|
||||
'Stores the permission entries a room’s 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 permission’s 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 don’t 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 doesn’t 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.
|
||||
|
||||
@@ -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 friend’s 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 room’s 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 room’s 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,22 +1834,19 @@ 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 })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Permissions: Array<{ Permission: string; Role: number }>
|
||||
PhotonAccessToken: string
|
||||
RoomInstanceId: number | null
|
||||
}
|
||||
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)
|
||||
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 }>
|
||||
PhotonAccessToken: string
|
||||
RoomInstanceId: number | null
|
||||
}
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
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 isn’t 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 source’s 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') } }
|
||||
`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
+805
-99
@@ -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))
|
||||
.catch(() => setAccount(null))
|
||||
// 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,31 +561,36 @@ 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">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
<div className="stage-body">
|
||||
{/* Deliberately doesn't name the game: this is a fan project, so the
|
||||
trademark stays out of the headline and appears lower down, in
|
||||
@@ -251,40 +598,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {
|
||||
<h1 className="stage-title">
|
||||
Play like it'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>
|
||||
{slide && (
|
||||
<div className="stage-show">
|
||||
<div className="stage-frame">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
|
||||
<div className="stage-foot">
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
{slide && (
|
||||
<span className="credit">
|
||||
Photo by @{slide.username}
|
||||
{slide.roomName && ` in ${slide.roomName}`}
|
||||
</span>
|
||||
)}
|
||||
{/* 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">
|
||||
<h2>Sign in</h2>
|
||||
<p className="muted">
|
||||
Launch the game first — that creates an account linked to your Steam ID. Once you set a
|
||||
password, use your username and that password to sign in here.
|
||||
</p>
|
||||
<LoginForm
|
||||
onAuthed={(a) => {
|
||||
onAuthed(a)
|
||||
navigate('/account')
|
||||
}}
|
||||
/>
|
||||
{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'll see it on your account page. Choose a
|
||||
password, and the two together sign you in here and in the game.
|
||||
</p>
|
||||
<SignupForm siteKey={siteKey} onAuthed={authed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Sign in</h2>
|
||||
<p className="muted">
|
||||
Use your username and password. Launching the game also creates an account, linked to
|
||||
your Steam ID — set a password on it and it signs in here too.
|
||||
</p>
|
||||
<LoginForm onAuthed={authed} />
|
||||
{/* The tabs above already offer this; the line under the button is where
|
||||
someone who just found out they have no account is actually looking.
|
||||
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||
{siteKey && (
|
||||
<p className="muted swap">
|
||||
Don'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'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
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ startsInMinutes: 15 }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||
// 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' },
|
||||
})
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"@cloudflare/workers-types": "4.20260630.1",
|
||||
"@repo/tools": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"isemail": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
@@ -31,22 +33,20 @@ export interface Account {
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
/** Profile banner image key. No route sets it yet, so it's `""` on every account. */
|
||||
bannerImage: string
|
||||
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
|
||||
displayEmoji: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
personalPronouns: number
|
||||
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. */
|
||||
@@ -164,8 +164,6 @@ export function defaultAccount(id: number, overrides: Partial<Account> = {}): Ac
|
||||
username: `Player${id}`,
|
||||
displayName: `Player${id}`,
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
isJunior: false,
|
||||
platforms: 0,
|
||||
personalPronouns: 0,
|
||||
@@ -222,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
|
||||
@@ -246,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.
|
||||
@@ -264,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()
|
||||
}
|
||||
@@ -301,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()
|
||||
}
|
||||
@@ -330,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 []
|
||||
@@ -417,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user