42 Commits

Author SHA1 Message Date
Nexi 7add875223 [api] implement invention cheers (#55)
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-09 17:27:55 -04:00
Devin Zuczek 548089a3ec [notify] fix intermittant drop in notifications 2026-09-09 17:26:14 -04:00
Devin Zuczek f681af34fa [chat] parties WIP 2026-09-09 17:26:14 -04:00
Devin Zuczek cdb17f5284 [api] fix missing playerevents endpoint 2026-09-09 17:26:14 -04:00
Devin Zuczek db01d2bedd [discovery] add cache since this never changes 2026-09-09 17:26:14 -04:00
Devin Zuczek 629a702403 [accounts] emoji 2026-09-09 17:26:14 -04:00
Devin Zuczek 261ff21e0d [accounts] profanity filter on user/display name 2026-09-09 17:26:14 -04:00
Nexi 438475e326 [api] fix(security): bound API-owned uploads (#56)
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-09 17:24:02 -04:00
Devin Zuczek 87a1cd6b55 [econ] update giftdrop type 2026-09-09 00:24:31 -04:00
Devin Zuczek 9a8fc1cab5 [rooms] add showcase stub 2026-09-08 23:52:56 -04:00
Nexi 222547ee13 [api] audit fixes: account tests, dependencies and security hardening (#54)
* test(accounts): cover three username changes

* chore(deps): update vulnerable runtime dependencies

* fix(security): bound uploads and validate token subjects strictly

---------

Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-08 23:47:58 -04:00
Devin Zuczek 9696c56317 [playersettings] fix delete setting 2026-09-08 23:27:04 -04:00
Devin Zuczek a2946fb9f4 [api] fix saved outfits not appearing 2026-09-08 23:07:27 -04:00
Devin Zuczek acf70a083d [commerce] just cache all of commerce b/c we dont use it 2026-09-07 16:18:09 -04:00
Devin Zuczek e0f802cee5 [api] improve moderationblock 2026-09-03 11:40:45 -04:00
Devin Zuczek 3d7ea3cf27 [api] show ban details 2026-09-03 11:05:16 -04:00
Devin Zuczek 4bc5f51b9c [rooms] remove friendlyname 2026-09-03 11:04:54 -04:00
Devin Zuczek fef6754aad [api] add non-working v3/votekick 2026-09-02 14:54:14 -04:00
Devin Zuczek 30b441aa65 [app] room owner insta-kicks 2026-09-02 14:01:19 -04:00
Devin Zuczek d441181b03 [invention] add invention v3 purchase 2026-09-02 11:22:03 -04:00
Devin Zuczek 20196361c8 [match] consume invite on join 2026-09-02 11:21:31 -04:00
Devin Zuczek bc12bb07d4 [api] disable statsig in 2025 2026-09-02 10:41:59 -04:00
Devin Zuczek 010c83d3ff [api] add statsig dump 2026-09-02 10:41:13 -04:00
Devin Zuczek 36f8b01f83 [accounts] allow 3 username changes 2026-09-02 10:40:59 -04:00
Devin Zuczek 6adb3ab741 [rooms] fix #50 room leaderboards 2026-09-02 00:45:11 -04:00
Devin Zuczek 9c63f077b8 [match] fix play-and-invite endpoint 2026-09-01 14:47:49 -04:00
Devin Zuczek 2af5d5089d [match] support a cluster of tachyon servers 2026-09-01 10:55:47 -04:00
Devin Zuczek cae0ebd4ae [docs] update some documentation about versions 2026-09-01 09:56:55 -04:00
Devin Zuczek ab623c376b [rooms] add recrally default 2026-09-01 01:20:58 -04:00
Devin Zuczek 1e26082f2e [match] the other v2 invite endpoint 2026-09-01 01:17:37 -04:00
Devin Zuczek 7d20f96414 [www] add single person notify for fun 2026-08-31 23:43:51 -04:00
Devin Zuczek 99417e052e [www] update year 2026-08-31 23:43:51 -04:00
Devin Zuczek 8260c5abcd [plus] discord role verifier to grant RR plus 2026-08-31 23:43:51 -04:00
Devin Zuczek 740e9efa09 [api] delete invention 2026-08-31 23:43:51 -04:00
Devin Zuczek 5928e07509 [api] turn off statsig 2026-08-31 23:43:19 -04:00
Devin Zuczek 45a07c6ba5 [img] add RecRally 2026-08-31 11:10:24 -04:00
Devin Zuczek 3be2066526 [econ] better quest rewards (for now until chests figured out) 2026-08-30 02:46:08 -04:00
Devin Zuczek 8e452f23eb [match] gate 2025 rooms 2026-08-30 01:37:43 -04:00
Devin Zuczek 02c965c2ee Merge remote-tracking branch 'origin/main' 2026-08-30 00:49:13 -04:00
Devin Zuczek e07c52ced7 [match] add basic stat table for fun 2026-08-30 00:44:50 -04:00
Devin Zuczek 5aea5a4cf9 [api] 2025 inventions 2026-08-29 14:40:57 -04:00
Devin Zuczek a620cadde0 [tests] fix a few failing tests 2026-08-29 13:15:37 -04:00
120 changed files with 35607 additions and 1510 deletions
+52 -5
View File
@@ -87,6 +87,17 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Largest binary the storage worker accepts in one upload, in bytes. The default is
# 64 MiB. The endpoint rejects larger files with HTTP 413 before writing to R2. This
# value must be a positive integer; zero or an invalid value restores the default.
# RECFLARE_MAX_UPLOAD_BYTES=67108864
# Largest file the API worker accepts through either saved-image upload or either
# custom-avatar-item file field, in bytes. The default is 64 MiB PER FILE. Larger
# parsed files receive HTTP 413 before arrayBuffer() or an R2 write. This complements
# RECFLARE_MAX_UPLOAD_BYTES, which protects the separate storage worker.
# RECFLARE_MAX_API_UPLOAD_BYTES=67108864
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
@@ -108,12 +119,21 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_PHOTON_VOICE_APP_ID=
# RECFLARE_PHOTON_CHAT_APP_ID=
# The Tachyon voice server (`match`, GET /player/connection-info): the `host:port` the
# client is handed as `voiceConnectionInfo`, and its id as `voiceServerId`. EMPTY unless
# you set them — no separate voice server. Set both or neither; like the Photon ids they
# are not secrets (the client receives them in the clear).
# The pool of Tachyon servers sessions are spread across (`match`, GET
# /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
# the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
# voice server. Not a secret (the client receives the address in the clear), like the
# Photon ids above. One entry is the ordinary single-server case:
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
# RECFLARE_TACHYON_NAME=server-1
#
# List several and each room instance is assigned one for its lifetime, so everybody in
# a session lands on the same server while different sessions spread across the pool.
# The id the client displays (`voiceServerId`) is generated from an entry's POSITION —
# `tachyon-1`, `tachyon-2`, … — so listing one address twice models two server slots on
# one box, and inserting an entry renames every server after it. The five below are a
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
# a host, as do tachyon-4/-5.
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
@@ -141,3 +161,30 @@ RECFLARE_DOMAIN=rec.example.com
# 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.
# The website's benefits claim (www `/claim`): a player proves a role in your Discord and
# gets Rec Room Plus. FOUR settings, and ALL FOUR are required — with any missing, the claim
# stays closed, `/api/config` reports `benefitsEnabled: false`, and the page and its nav link
# never appear. That is the usual reason "I set the secrets and nothing shows up".
#
# The two CREDENTIALS live in the Secrets Store, like the Turnstile pair above:
#
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
# --scopes workers --remote
#
# The two IDS are plain vars, and go HERE — they are not secrets, and setting the secrets
# alone is not enough. Both are Discord snowflakes: all digits, no letters, copied with
# Developer Mode on (right-click the server or role -> Copy ID). Ids, not names.
#
# ROLE_IDS is a list and ANY one of them qualifies, so several tiers can share the benefit.
# SEPARATE THEM WITH COMMAS AND NO SPACES: these knobs are passed to wrangler as `--var`
# flags that are word-split, so a value containing a space silently breaks the deploy. (The
# worker itself also accepts whitespace, which is fine in wrangler.jsonc but not here.)
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
#
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
+21
View File
@@ -44,6 +44,20 @@
- Changesets manage versions and changelogs
</architecture>
<game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
- `20230414` — default, official. Manifest `7859140924515540835` (2023).
- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via
the [patch-2025](https://github.com/recflare/patch-2025) patch.
- `20250424.01`, `20231207`, `20230616` — alpha.
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
`rooms` featured rooms) rather than on an explicit list.
</game-clients>
<code-style>
- Use tabs for indentation, spaces for alignment
- Type imports use `import type`
@@ -169,6 +183,13 @@ inconsistency here without checking the client first.
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
the two live side by side in the same worker and must not be unified.
- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
object. An object there does not degrade: the client's decoder rejects it outright
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
whose `Data` carries `{ PlayerId, Response, GameSessionId }``PlayerId` a string inside
it, as the reference relays it) and, the same way, a chat message's `Contents`.
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
place and the top of a board must be 0. Its own slice says the same: it asks for the first
+2 -1
View File
@@ -194,10 +194,11 @@ its built-in default: copy the lines you want to change into your `.env`, uncomm
edit the value, then re-deploy the worker that reads them.
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | ------- | ------- | -------------------------------------------------------------- |
| --------------------------------------- | --------- | ---------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
+34 -16
View File
@@ -2,7 +2,7 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
![badge](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
@@ -36,15 +36,24 @@ resulting in bugs getting fixed faster. I hope.
## Game client
See [RecFlare Client](https://github.com/djdevin/recflare-client)
See [RecFlare Client](https://github.com/djdevin/recflare-client) for the official RecFlare build.
RecFlare is compatible with the
[RecNet Plugin](https://github.com/djdevin/recnet-plugin) and the build of Rec
Room with manifest `7859140924515540835` (around 2023). Other client or game versions may expect
different endpoints and response shapes and are not supported.
These game builds are supported:
| Build | Manifest | Support |
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20250424.01` | | Alpha |
| `20231207` | | Alpha |
| `20230616` | | Alpha |
Alpha builds get past the version check and largely work, but nothing else in the
stack targets them, so expect protocol differences. Other client or game versions
may expect different endpoints and response shapes and are not supported.
Generally speaking any client that effectively rewrites the nameserver with the
right mods can be used with this server.
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
## Services
@@ -58,9 +67,11 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
### What year is this for?
This works with 2023 clients. It has been tested with manifest `7859140924515540835`. Other clients may not work.
Most 2023 and 2025 clients. A few older builds work at
alpha quality — see the table in the "Game client" section above. Other clients may not
work.
See the "Client" section above for instructions on how to modify a client to connect to this server.
See the "Game client" section above for instructions on how to modify a client to connect to this server.
### Can I run this locally on my PC?
@@ -70,11 +81,13 @@ See "Run the development microservices" above. It may be possible later as Wrang
### Can I use this to make my own server?
Yes, that's the point. Just set your custom domain and deploy it.
Yes, that's the point. See [DEPLOYING.md](DEPLOYING.md)
### Is there an admin panel?
Not yet. But there could be.
Yes, the server comes bundled with a simple web panel with more functionality being added.
There are also [CLI tools](CLI.md) you can use for admin tasks like granting roles.
### Can I copy this project and modify it?
@@ -89,15 +102,20 @@ keeps those in sync: `pnpm` workspaces share dependencies, `@repo/` packages
share code, Turborepo runs build/test/lint with a single cached task graph, and
cross-service changes land in one atomic commit.
This makes it easier to deploy the whole stack at once or a smaller selection
of microservices to avoid downtime events.
## Credits
I started this after the official servers shut down, so I could only see the
I started this soon after the official servers shut down when I saw there were
only monolithic servers usually running on one server. I could only see the
request shapes coming from the game client. I used many different projects as
resources to get response shapes, logic examples, enums, etc. They all had
missing pieces. Again, another reason to come together on one project and
stop gatekeeping.
Unfortunately, they were all leaked code except for
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
collection of RecNet APIs which is gone for some reason. So I will not list the
leaks publicly.
- [CannedNet](https://github.com/CannedNet/CannedNet)
- [DorkNet](https://github.com/DorkSquadRR/DorkNet)
- jordanparki7's postman collection of RecNet APIs which is gone for some reason.
- Leaked C# projects I won't list (for response shapes)
- Claude and my [wire shapes skill](https://github.com/recflare/skills/blob/main/.claude/skills/wire-shapes/SKILL.md)
+3 -2
View File
@@ -15,12 +15,13 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -32,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+69 -5
View File
@@ -32,6 +32,7 @@ import {
CreateAccountResult,
DisplayNameRequest,
EmailRequest,
EmojiRequest,
form,
HealthResponse,
IdentityFlagsRequest,
@@ -45,7 +46,9 @@ import {
SuccessResponse,
UsernameRequest,
UsernameResult,
WhitelistedEmojis,
} from './openapi'
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
@@ -76,7 +79,7 @@ function unauthorized(c: Context<App>) {
}
/** Username changes a fresh account starts with (until one has been consumed). */
const DEFAULT_USERNAME_CHANGES = 1
const DEFAULT_USERNAME_CHANGES = 3
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
@@ -109,8 +112,8 @@ 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.
// Rows stored before these fields existed have neither key — always emit them as
// "" rather than letting them go missing.
bannerImage: account.bannerImage ?? '',
displayEmoji: account.displayEmoji ?? '',
isJunior: account.isJunior,
@@ -210,6 +213,24 @@ const app = new Hono<App>()
(c) => c.json({ service: 'accounts', status: 'ok' })
)
// ---- Emoji config --------------------------------------------------------
// The picker the client fills its displayEmoji grid from. A BARE array — no
// `{ success, error, value }` envelope and no wrapper object; the client parses the
// response body itself as the list.
.get(
'/emojiConfig/whitelistedEmojis',
describeRoute({
tags: ['Config'],
summary: 'Emoji a player may use as their displayEmoji',
description: [
'A bare JSON array of emoji, in the order the client draws them. Static — not',
'auth-gated, and identical for every player.',
].join(' '),
responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') },
}),
(c) => c.json(WHITELISTED_EMOJIS)
)
// ---- Self account --------------------------------------------------------
.get(
'/account/me',
@@ -448,7 +469,9 @@ const app = new Hono<App>()
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -475,7 +498,8 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'Letters and digits only, at most 50 characters, and free of profanity (the same',
'word list as `api`s `POST /api/sanitize/v1/isPure`). 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).',
@@ -662,6 +686,46 @@ const app = new Hono<App>()
}
)
// The emoji shown beside the player's display name. The body is a single field —
// `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list
// `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL
// form: `displayEmoji` is compared as a plain string, and the client highlights the
// current pick by matching it against the picker list it fetched, so a stored value
// that differs only by a variation selector highlights nothing.
//
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
// AccountUpdate payload, so it redraws beside the name without a refetch.
.put(
'/account/me/emoji',
describeRoute({
tags: ['Profile'],
summary: 'Set display emoji',
description: [
'Persists the emoji shown beside the display name and broadcasts it in the',
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
'clears the pick.',
].join(' '),
security: AUTHED,
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a whitelisted emoji (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const submitted = (await formField(c, 'displayEmoji')).trim()
// An empty value clears the pick; anything else has to be on the list.
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
if (displayEmoji === null) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { displayEmoji })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// The profile banner — the wide image behind the header on a player's profile. Same
// shape as the avatar below: the body names an image the player has already uploaded
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
+47 -5
View File
@@ -9,6 +9,11 @@ import {
nameRejection,
} from '@repo/domain'
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
// than copied so a name is held to the very same word list every other player-typed
// string is.
import { nameContainsSwears } from '../../api/src/sanitize'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -70,7 +75,7 @@ export const AccountDto = z.object({
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)'),
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
@@ -134,6 +139,15 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
*/
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
/**
* `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope
* and no object around it (see `WHITELISTED_EMOJIS`).
*/
export const WhitelistedEmojis = z
.string()
.array()
.describe('The emoji a player may set as their displayEmoji, in picker order')
/** Root health check. */
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
@@ -158,26 +172,44 @@ export const CreateAccountRequest = z.object({
* 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. */
/**
* Zod check that defers to the shared name rule, message and all, and then refuses a name
* with a swear in it — the same filter, and the same word list, as `api`'s
* `POST /api/sanitize/v1/isPure`.
*
* Shape first, profanity second: a name that already broke the charset rule gets the one
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
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 })
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
// Deliberately vague about WHICH word: naming it back to the player prints the
// swear in the UI, and the player knows what they typed.
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
}
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
),
})
export const UsernameRequest = z.object({
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'),
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
@@ -207,6 +239,16 @@ export const BioRequest = z.object({
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
/**
* `PUT /account/me/emoji` form body. The value must be one of the emoji served by
* `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked
* in the handler rather than here, because the check also CANONICALIZES the value
* (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it.
*/
export const EmojiRequest = z.object({
displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
})
+196 -9
View File
@@ -6,6 +6,8 @@ import '../../accounts.app'
import { SCHEMA_DDL } from '@repo/domain'
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -149,6 +151,15 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /account/me rejects a signed token with a non-canonical account subject', async () => {
for (const sub of ['42junk', '42.5', '042', '-42', '9007199254740992']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, {
headers: await bearer(sub),
})
expect(res.status, sub).toBe(401)
}
})
test('GET /account/me returns the self account with a valid token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
expect(res.status).toBe(200)
@@ -160,11 +171,11 @@ describe('auth-gated endpoints', () => {
username: 'Player42',
personalPronouns: 0,
identityFlags: 0,
availableUsernameChanges: 1,
availableUsernameChanges: 3,
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
// Nothing sets these yet, but the key has to be present — the client reads
// Unset on a fresh account, but the key has to be present — the client reads
// both off the account DTO.
bannerImage: '',
displayEmoji: '',
@@ -181,7 +192,7 @@ describe('auth-gated endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
headers: await bearer(),
})
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: false })
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: true })
})
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
@@ -214,6 +225,32 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
})
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
const headers = {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(400)
}
// And the words that merely contain one still get through — refusing these is worse
// than missing a swear, because the player can't see why.
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(200)
}
})
test('PUT /account/me/username 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'whoever' }),
@@ -235,7 +272,31 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('')
})
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => {
test('PUT /account/me/username refuses a swear without spending a change', async () => {
const headers = {
...(await bearer('894')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'ShitLord' }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
// Vague on purpose — the message must not print the swear back at the player.
expect(body.error).toMatch(/can't contain that word/i)
expect(body.value).toBe('')
// The schema runs before the handler, so a refused name costs none of the account's
// rationed changes.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(3)
})
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
const headers = {
...(await bearer('892')),
'Content-Type': 'application/x-www-form-urlencoded',
@@ -260,11 +321,27 @@ describe('auth-gated endpoints', () => {
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.username).toBe('coachx')
expect(me.availableUsernameChanges).toBe(0)
expect(me.availableUsernameChanges).toBe(2)
// A second change is blocked — no changes remaining (still HTTP 200).
// The second and third changes consume the rest of the account's allowance.
for (const username of ['coachy', 'coachz']) {
const changed = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
expect(changed.status).toBe(200)
expect(((await changed.json()) as { success: boolean }).success).toBe(true)
}
const exhausted = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(exhausted.username).toBe('coachz')
expect(exhausted.availableUsernameChanges).toBe(0)
// A fourth change is blocked — no changes remaining (still HTTP 200).
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'coachy' }),
...form({ username: 'coachq' }),
headers,
})
expect(blocked.status).toBe(200)
@@ -328,6 +405,91 @@ describe('auth-gated endpoints', () => {
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
})
test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/', { method: 'DELETE' })
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
method: 'PUT',
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'displayEmoji=%F0%9F%A4%AA',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Served back by both the self and public reads — displayEmoji is in the public DTO.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
const pub = await exports.default.fetch(`${ORIGIN}/account/779`)
expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
// And it rides the profile-update notification, so it redraws beside the name.
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(sent.length).toBeGreaterThan(0)
expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain(
'\u{1F92A}'
)
})
// The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji
// without its U+FE0F variation selector means the same pick, but storing what arrived
// would leave a string the picker list no longer matches, so the current pick would
// stop highlighting.
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{2764}' }),
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') })
const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji
expect(stored).toBe('\u{2764}\u{FE0F}')
expect(WHITELISTED_EMOJIS).toContain(stored)
})
// An empty value clears the pick rather than 400ing — that's how the picker's "none"
// gets back to no emoji at all.
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F389}' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(set.status).toBe(200)
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(cleared.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
// displayEmoji renders beside the display name, so an unchecked field would be a
// free-text label on every profile. Off-list values are refused, not stored.
test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
})
expect(anon.status).toBe(401)
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji }),
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(400)
}
// Nothing was stored by the refusals.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
...form({ imageName: 'x.jpg' }),
@@ -488,6 +650,7 @@ describe('auth-gated endpoints', () => {
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/create',
'POST /account/me/email',
@@ -495,6 +658,7 @@ describe('auth-gated endpoints', () => {
'PUT /account/me/bannerimage',
'PUT /account/me/bio',
'PUT /account/me/displayname',
'PUT /account/me/emoji',
'PUT /account/me/identityflags',
'PUT /account/me/personalpronouns',
'PUT /account/me/profileimage',
@@ -565,12 +729,12 @@ describe('name, email and bio validation', () => {
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,
// A rationed change must NOT be spent by a refusal: an account starts with three,
// 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)
expect(me.availableUsernameChanges).toBe(3)
// 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`, {
@@ -683,3 +847,26 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
// The emoji picker. Served as a BARE array — the client parses the response body itself
// as the list, so wrapping it in `{ value: [...] }` or the success envelope every
// mutation here uses would leave the picker empty.
test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
const body = (await res.json()) as string[]
expect(Array.isArray(body)).toBe(true)
expect(body).toEqual(WHITELISTED_EMOJIS)
// Order is the picker's order, and the first entry anchors it.
expect(body[0]).toBe('😀')
// Every entry is a non-empty string and appears once — a duplicate draws twice in
// the grid, and the list is compared against `displayEmoji` as an exact string.
expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true)
expect(new Set(body).size).toBe(body.length)
})
// Not auth-gated: the client asks for the picker before it has a token in hand.
test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
})
+312
View File
@@ -0,0 +1,312 @@
/**
* The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON
* array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it.
*
* ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is
* grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music,
* sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new
* entry to the end of the group it belongs to instead.
*
* Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored
* as the exact code-point sequence the client sends back, variation selectors (U+FE0F)
* and zero-width joiners included: `displayEmoji` is compared as a plain string, so
* `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed
* here round-trips.
*/
export const WHITELISTED_EMOJIS: string[] = [
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😍',
'🥰',
'😘',
'😗',
'😚',
'😋',
'😛',
'😜',
'🤪',
'😝',
'🤑',
'🤗',
'🤭',
'🤫',
'🤔',
'🤨',
'😐',
'😑',
'😶',
'🙄',
'😏',
'😣',
'😥',
'😮',
'🤐',
'😯',
'😪',
'😫',
'🥱',
'😴',
'😌',
'🤓',
'😎',
'🤩',
'🥳',
'😤',
'😭',
'😢',
'🥺',
'😡',
'🤬',
'😱',
'😨',
'😰',
'😬',
'🤯',
'🥶',
'🥵',
'🤠',
'🤖',
'👽',
'👻',
'💀',
'☠️',
'👹',
'👺',
'👾',
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💖',
'💗',
'💓',
'💕',
'💞',
'💘',
'💝',
'💟',
'❣️',
'💔',
'👍',
'👎',
'👌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙',
'👏',
'🙌',
'👐',
'🤲',
'🙏',
'👋',
'✋',
'🤚',
'🫶',
'💪',
'🧠',
'👀',
'👁️',
'👄',
'🦾',
'🦿',
'🐶',
'🐱',
'🐭',
'🐹',
'🐰',
'🦊',
'🐻',
'🐼',
'🐨',
'🐯',
'🦁',
'🐸',
'🐵',
'🐧',
'🐦',
'🦅',
'🦆',
'🦄',
'🐴',
'🐢',
'🐙',
'🦈',
'🐬',
'🐳',
'🦋',
'🐝',
'🐞',
'🦖',
'🦕',
'🐲',
'🍎',
'🍌',
'🍇',
'🍉',
'🍓',
'🍒',
'🥝',
'🍍',
'🥑',
'🌮',
'🍕',
'🍔',
'🍟',
'🌭',
'🥪',
'🍗',
'🍿',
'🍩',
'🍪',
'🎂',
'🍫',
'🍬',
'🍭',
'🧋',
'☕',
'🥤',
'🍺',
'🥛',
'☀️',
'🌤️',
'⛅',
'🌥️',
'☁️',
'🌧️',
'⛈️',
'❄️',
'🌈',
'⭐',
'🌟',
'✨',
'⚡',
'🔥',
'💧',
'🌊',
'🌸',
'🌹',
'🍀',
'🌲',
'🎮',
'🕹️',
'💻',
'⌨️',
'🖥️',
'📱',
'🖱️',
'🎧',
'📷',
'📹',
'💿',
'💾',
'🔋',
'🔌',
'🛰️',
'🚀',
'🎵',
'🎶',
'🎼',
'🎤',
'🥁',
'🎸',
'🎹',
'🎺',
'🎷',
'🎻',
'⚽',
'🏀',
'🏈',
'⚾',
'🎾',
'🏐',
'🏓',
'🥊',
'🏆',
'🥇',
'🥈',
'🥉',
'💎',
'💰',
'💸',
'🪙',
'🎁',
'📦',
'🔑',
'🗝️',
'🛡️',
'⚔️',
'🧸',
'🎈',
'🎉',
'🎊',
'🕯️',
'💡',
'📚',
'📖',
'✏️',
'🖊️',
'🚗',
'🚕',
'🚌',
'🚓',
'🚑',
'🚒',
'🏎️',
'🚲',
'✈️',
'🚁',
'🚢',
'✔️',
'✅',
'❌',
'⭕',
'❗',
'❓',
'💯',
'♾️',
'🔔',
'🔕',
'❤️‍🔥',
]
/**
* The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the
* client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and
* `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different
* strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271
* distinct keys — so the fold can never make two picks ambiguous.
*/
const BY_STRIPPED = new Map(
WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji])
)
/**
* Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it
* isn't on the list. Storing the canonical form (rather than what arrived) is what keeps
* a stored `displayEmoji` string-equal to the picker entry it came from — the client
* highlights the current pick by comparing against the list it fetched.
*
* Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display
* name, so an unchecked field is a free-text label on every player's profile.
*/
export function resolveWhitelistedEmoji(input: string): string | null {
return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2
View File
@@ -8,6 +8,8 @@ API surface. Database-backed queries and on-disk JSON files are stubbed for now
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **API-owned uploads** enforce `RECFLARE_MAX_API_UPLOAD_BYTES` per file (64 MiB
by default) before copying a parsed file into an `ArrayBuffer` or writing it to R2.
- **Static data** is served verbatim:
- `src/default-avatar-items.ts``GET /api/avatar/v4/items`
- `src/default-settings.ts``GET /api/settings/v2`
@@ -0,0 +1,13 @@
-- A player's interaction with an invention. One row per (player, invention); `cheered`
-- is toggled in place and the invention JSON's denormalized `CheerCount` is resynced
-- after every write. Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
);
CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id);
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2
View File
@@ -15,6 +15,8 @@ export type Env = SharedHonoEnv & {
* for local dev and tests.
*/
DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: string
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
DB: D1Database
+17 -9
View File
@@ -290,9 +290,13 @@ export interface PlayerEventNotification {
}
/**
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
* serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
* the stored record, but not identical to it — don't unify them:
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`),
* the room shelf (`.../room/{roomId}`) and the bulk read (`POST|GET .../bulk`) all serve,
* and the same thing the v2 envelope carries once `Tags` is added. Those three are one
* generic helper over one element type on the client side, so they are shape-identical by
* construction there; `toEventBase` is what holds that here.
*
* PascalCase like the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which neither the feed nor the envelope carries;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
@@ -300,8 +304,8 @@ export interface PlayerEventNotification {
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
* null.
*
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
* nullable `ImageName` included. Two shapes; keep them apart.
* The by-id, search, searchlive and club reads serve the stored RECORD verbatim instead,
* `State` and nullable `ImageName` included. Two shapes; keep them apart.
*/
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
ImageName: string
@@ -856,9 +860,12 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
}
/**
* 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.
* Several events by id — the bulk fetch behind `POST /api/playerevents/v1/bulk` (the form
* body the client sends) and the query-string GET on the same path. 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.
*
* Returns stored records; both routes project them with `toEventBase` before serving.
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
@@ -907,7 +914,8 @@ export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promi
/**
* A room's events — what is happening in this room and what is coming up, soonest first.
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`), which serves
* them through `toEventBase` like the browse feed and the bulk read.
*
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
* up to in this room", and an event that ended last month is not that. Running events count
+20 -6
View File
@@ -27,13 +27,27 @@ export function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Reads the `Ids` form field into a list of integer ids. */
/**
* Reads the `Ids` form field of a bulk POST into a list of integer ids.
*
* BOTH spellings, because the client uses both: `Ids` REPEATED once per id
* (`Ids=101&Ids=102&Ids=103`, what the player-events bulk sends) and a single
* comma-separated `Ids=1,2,3`. `parseBody({ all: true })` is what keeps the repeated form
* from collapsing to its last value — plain `parseBody()` would answer one id out of
* three, which reads as a short result rather than as an error.
*
* `ids` is accepted alongside `Ids` so a hand-written request doesn't silently come back
* empty. Values that aren't integers are dropped; duplicates and order are left alone,
* since the caller renders them in request order.
*/
export async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
if (typeof ids !== 'string') return []
return ids
.split(',')
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const raw = [body.Ids, body.ids].flat()
return raw
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
+483 -20
View File
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
/**
* 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
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); 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[] = [
@@ -39,6 +39,15 @@ export const SCHEMA_DDL: string[] = [
`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)`,
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
`CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id)`,
]
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
@@ -53,6 +62,16 @@ export interface InventionVersion {
ChipsCost: number
CloudVariablesCost: number
AICost: number
/**
* Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the
* VERSION, where the client's own `RRInventionVersion` carries it and for the same
* reason the costs do: it describes the one revision saved, not the invention across
* all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see
* {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends
* neither; the client's decoder reads a missing member as its default, so an old
* record is not retroactively wrong.
*/
HasBetaContent?: boolean
}
/**
@@ -103,9 +122,32 @@ export interface SavedInvention {
HideFromPlayer: boolean
ReferencedInventions: number[]
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
* save: the real `RRInvention` carries no Tags field and the client sends no tags
* when saving, so an untagged invention's DTO stays identical to the real one.
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
* they most resemble, and the one this record has always carried on the invention.
* Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION
* carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and
* never reads them back); they are stored anyway, because what the client sent is
* worth keeping, and `toSaveResultV9` puts each where that client expects it.
*
* `UgcVersion` is the exception that has to be got right rather than tolerated: it is
* an invention field there, not a version one, next to `CurrentVersionNumber`.
*
* `DisplayMetadataJson` is stored as the opaque string the client sent: it is the
* client's own display state (`{"0":0,"99":0}`), and re-encoding it would be
* inventing a schema for something only the client reads.
*
* Each is absent on a `v6/save` record, which sends none of them.
*/
ReferencedUnityAssetIds?: string[]
UgcVersion?: number
LongDescription?: string
DisplayMetadataJson?: string
ConvertedFromInventionId?: number
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional: the real
* `RRInvention` carries no Tags field, so an untagged invention's DTO stays
* identical to the real one. `v6/save` never sets it (that client tags in a second
* call); `v9/save` sets it when its `tagsRequest` names at least one tag.
*/
Tags?: InventionTag[]
}
@@ -130,6 +172,224 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
}
/**
* `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members
* run Success 0 … ReservedWordViolation 13. Only Success is named: the members between
* were not recovered from the client, and it never reads this field anyway, so a refused
* tag needs only to be something other than Success.
*/
export const INVENTION_TAG_RESULT = {
success: 0,
rejected: 1,
} as const
/**
* The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is
* NOT the record this server stores (that one mirrors the older shape the read endpoints
* still serve). The differences that matter: no nested `CurrentVersion` (the version rides
* beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client
* infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/
* `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for.
*
* The client reads exactly one of these keys — `InventionId` — and its decoder null-checks
* every member and drops the ones it doesn't know, so this projection is about being right
* rather than about being parseable.
*/
export interface InventionV9Dto {
InventionId: number
ReplicationId: string
CreatorPlayerId: number
Name: string
Description: string
ImageName: string
UgcVersion: number
CurrentVersionNumber: number
LatestVersionNumber: number
Accessibility: number
ForceCannotPublish: boolean
ModifiedAt: string
CreatedAt: string
FirstPublishedAt: string | null
CreationRoomId: number | null
NumPlayersHaveUsedInRoom: number
NumDownloads: number
CheerCount: number
CreatorPermission: number
GeneralPermission: number
IsAGInvention: boolean
IsCertifiedInvention: boolean
IsRecRoomApproved: boolean
AllowTrial: boolean
Price: number | null
HideFromPlayer: boolean
DisplayMetadataJson: string | null
}
/**
* The `InventionVersion` inside a v9 save response — the client's 13-key
* `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a
* nullable `UgcAccessibility` the stored version has no field for, and notably NO
* `AICost`, which the request body still sends and this server still stores.
*
* Both `Referenced*` lists are emitted here even though the client's DTO has room for one:
* which of the two it is wasn't recovered, and an unknown member is dropped silently while
* a missing one would be the list the client asked for going astray.
*/
export interface InventionVersionV9Dto {
InventionId: number
ReplicationId: string
VersionNumber: number
HasBetaContent: boolean
InstantiationCost: number
LightsCost: number
ChipsCost: number
CloudVariablesCost: number
BlobName: string
BlobHash: string | null
CreatedAt: string
UgcAccessibility: number | null
ReferencedInventions: number[]
ReferencedUnityAssetIds: string[]
}
/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */
export interface InventionTagsV9Dto {
Result: number
Tags: string[]
}
/**
* The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success.
*
* `InventionVersion` and `TagsResponse` are nullable because the envelope is not the save
* route's alone: econ's `POST /api/storefronts/v3/buyInvention` answers in it too, and a
* BUY mints neither a version nor a tag result — it sends both as null, and the client
* (which reads only `Success` and `Value.Invention`) never looks. The keys stay present.
*/
export interface InventionSaveV9Value {
Status: number
Invention: InventionV9Dto
InventionVersion: InventionVersionV9Dto | null
TagsResponse: InventionTagsV9Dto | null
}
/**
* What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the
* result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`.
*
* The client's contract is two fields deep. It checks `Success`, then reads
* `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text
* that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status`
* is deserialized and never read on this route — the failure channel is the envelope, not
* the 55-member status enum — and so are `InventionVersion` and `TagsResponse`.
*
* The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it
* dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe —
* that branch reads only `Error` — which is why every refusal goes through
* {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A
* body that doesn't deserialize into this envelope at all is the same crash, so even the
* 401 answers it.
*/
export interface InventionSaveV9Result {
Value: InventionSaveV9Value | null
Success: boolean
Error: string | null
error_id: string | null
}
/**
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
* the two can never drift into serving one build two different inventions.
*
* Fields the stored record has no equivalent for are served as what they are here rather
* than guessed: nothing forces an invention not to publish, and nothing in this server
* approves one.
*/
export function toInventionV9(invention: SavedInvention): InventionV9Dto {
return {
InventionId: invention.InventionId,
ReplicationId: invention.ReplicationId,
CreatorPlayerId: invention.CreatorPlayerId,
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
FirstPublishedAt: invention.FirstPublishedAt,
CreationRoomId: invention.CreationRoomId,
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
NumDownloads: invention.NumDownloads,
CheerCount: invention.CheerCount,
CreatorPermission: invention.CreatorPermission,
GeneralPermission: invention.GeneralPermission,
IsAGInvention: invention.IsAGInvention,
IsCertifiedInvention: invention.IsCertifiedInvention,
IsRecRoomApproved: false,
AllowTrial: invention.AllowTrial,
Price: invention.Price,
HideFromPlayer: invention.HideFromPlayer,
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
}
}
/**
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
* never the save, because the save is the thing the player would have to redo.
*/
export function toSaveResultV9(
invention: SavedInvention,
tags: InventionTag[],
tagResult: number = INVENTION_TAG_RESULT.success
): InventionSaveV9Result {
const version = invention.CurrentVersion
return {
Value: {
Status: 0,
Invention: toInventionV9(invention),
InventionVersion: {
InventionId: version.InventionId,
ReplicationId: version.ReplicationId,
VersionNumber: version.VersionNumber,
HasBetaContent: version.HasBetaContent ?? false,
InstantiationCost: version.InstantiationCost,
LightsCost: version.LightsCost,
ChipsCost: version.ChipsCost,
CloudVariablesCost: version.CloudVariablesCost,
BlobName: version.BlobName,
BlobHash: version.BlobHash,
// The version is minted with the invention, so they share a timestamp.
CreatedAt: invention.CreatedAt,
UgcAccessibility: null,
ReferencedInventions: invention.ReferencedInventions,
ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [],
},
TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) },
},
Success: true,
Error: null,
error_id: null,
}
}
/**
* A refused v9 save. `Value` is null, which is safe precisely because `Success` is false:
* the client reads `Error` on that branch and nothing else. See
* {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would
* take the client down instead.
*/
export function inventionSaveV9Failure(message: string): InventionSaveV9Result {
return { Value: null, Success: false, Error: message, error_id: null }
}
/**
* Invention data blobs are named `<name>.inv`, and the client expects the extension
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
@@ -189,6 +449,19 @@ export interface NewInvention {
aiCost?: number
creationRoomId?: number | null
referencedInventions?: number[]
/**
* The rest of what `v9/save` sends. Every one is optional and is written onto the
* record only when the caller actually supplied it, so a `v6/save` — which sends
* none of them — stores and answers exactly the record it always did.
*/
ugcVersion?: number
hasBetaContent?: boolean
referencedUnityAssetIds?: string[]
longDescription?: string | null
displayMetadataJson?: string | null
convertedFromInventionId?: number | null
/** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */
tags?: InventionTag[]
}
/**
@@ -201,6 +474,11 @@ export interface NewInvention {
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
* they built it in, not a permission over the invention.
*
* The fields `v9/save` added over `v6/save` are written only when the caller supplies
* them, so the record a v6 client stores is byte-for-byte the one it always stored —
* the new keys appear on new records rather than being back-filled with defaults onto
* every old one.
*/
export async function createInvention(
db: D1Database,
@@ -233,6 +511,7 @@ export async function createInvention(
ChipsCost: input.chipsCost ?? 0,
CloudVariablesCost: input.cloudVariablesCost ?? 0,
AICost: input.aiCost ?? 0,
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
},
Accessibility: 0,
IsPublished: false,
@@ -252,6 +531,16 @@ export async function createInvention(
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: input.referencedInventions ?? [],
...(input.referencedUnityAssetIds === undefined
? {}
: { ReferencedUnityAssetIds: input.referencedUnityAssetIds }),
...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }),
...(input.longDescription ? { LongDescription: input.longDescription } : {}),
...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}),
...(typeof input.convertedFromInventionId === 'number'
? { ConvertedFromInventionId: input.convertedFromInventionId }
: {}),
...(input.tags?.length ? { Tags: input.tags } : {}),
}
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
return invention
@@ -379,7 +668,7 @@ export async function searchInventions(
const offset = Math.max(skip, 0)
if (limit === 0) return []
const where = ['is_published = 1', 'hide_from_player = 0']
const where = [...VISIBLE_IN_FEEDS]
const binds: Array<string | number> = []
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
const bind = (v: string | number): string => `?${binds.push(v)}`
@@ -421,8 +710,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE is_published = 1
AND hide_from_player = 0
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
@@ -499,10 +787,9 @@ export async function getFeaturedInventions(
/**
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
* client derives from the invention itself (Type 2); custom tags are the creator's
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
* when there's no such invention.
* own (Type 0). Both lists are replaced wholesale, normalized as
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
* there's no such invention.
*/
export async function setInventionTags(
db: D1Database,
@@ -513,6 +800,21 @@ export async function setInventionTags(
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const tags = normalizeInventionTags(autoTags, customTags)
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
/**
* The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) —
* the order they come back in — each trimmed, lowercased and de-duplicated across both
* lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client
* pads its lists with empties.
*
* Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its
* `tagsRequest` — a tag has to mean the same thing however it arrived.
*/
export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] {
const tags: InventionTag[] = []
const seen = new Set<string>()
for (const [list, type] of [
@@ -526,8 +828,6 @@ export async function setInventionTags(
tags.push({ Tag: tag, Type: type })
}
}
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
@@ -540,6 +840,9 @@ export async function setInventionTags(
export const INVENTION_PERMISSION = {
unassigned: 0,
limitedoneuseonly: 10,
// Recovered from the client's own ladder; nothing here sends it, and no name for it
// appears in `v1/update`'s picker.
disallowkeylock: 15,
useonly: 20,
editandsave: 40,
publish: 60,
@@ -547,6 +850,36 @@ export const INVENTION_PERMISSION = {
unlimited: 100,
} as const
/**
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
* did — every record written before that endpoint carries 0, the value a save mints.
*
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
* an ordinary publish.
*
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
* every invention published through `v3/publish` as private and empties the feeds.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
public: 1,
unlisted: 2,
} as const
/**
* The "anyone may come across this" test the browse feeds and search share: published, not
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
* unlisted means — so the by-id reads deliberately don't apply it.
*/
const VISIBLE_IN_FEEDS = [
'is_published = 1',
'hide_from_player = 0',
`COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`,
]
/**
* Parse a permission level the way the client sends it: a name (`useonly`,
* `edit_and_save`) or the raw number. Undefined when it's neither.
@@ -567,6 +900,13 @@ export interface InventionPatch {
imageName?: string
allowTrial?: boolean
generalPermission?: number
/**
* The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone,
* which is how both editors say "not this field" — `v1/update` by omitting the query
* param, `v2/metadata` by sending the key as null.
*/
longDescription?: string
tags?: InventionTag[]
}
/**
@@ -591,11 +931,27 @@ export async function updateInvention(
ImageName: patch.imageName ?? invention.ImageName,
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
// Both of these are optional ON the record, so an untouched one resolves to
// undefined and JSON.stringify drops the key — an invention that never had a long
// description doesn't acquire an empty one by being edited.
LongDescription: patch.longDescription ?? invention.LongDescription,
Tags: patch.tags ?? invention.Tags,
}
await writeInvention(db, updated)
return updated
}
/**
* What a publish decides. Each is optional and an omitted one keeps what the invention
* has — except the permission, which falls back to UseOnly, the level the older
* `v3/publish` has always defaulted to when its query string named none.
*/
export interface InventionPublish {
permissionLevel?: number
accessibility?: number
price?: number
}
/**
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
* Publishing sets the permission other players get (UseOnly unless the creator asks
@@ -605,8 +961,7 @@ export async function updateInvention(
export async function publishInvention(
db: D1Database,
inventionId: number,
permissionLevel: number | undefined,
price: number | undefined
publish: InventionPublish = {}
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
@@ -614,8 +969,13 @@ export async function publishInvention(
const updated: SavedInvention = {
...invention,
IsPublished: true,
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
Price: price ?? 0,
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
Accessibility: publish.accessibility ?? invention.Accessibility,
// An unmentioned price is the price it already has, not zero: a republish that says
// nothing about money must not quietly give away something that was for sale. A
// first publish is unaffected — a fresh invention's price is 0 either way.
Price: publish.price ?? invention.Price,
// The FIRST publish is the one that gets dated; re-publishing doesn't reset it.
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
}
await writeInvention(db, updated)
@@ -638,6 +998,110 @@ export async function setInventionPrice(
return updated
}
/**
* Delete an invention (`v2/delete`), returning the record that was removed, or null
* when there's no such row. The whole invention lives in the one JSON blob, so its
* versions, tags and referenced-invention lists go with it in a single DELETE.
*
* Two things are deliberately LEFT behind.
*
* The data blob in R2 stays: it is named by the file the creator uploaded through the
* `storage` worker, and nothing here knows whether another record still points at that
* name (a converted invention carries the same lineage, and a save that reuses a
* filename reuses the object). An orphan blob costs storage; a missing one breaks
* whatever still references it.
*
* The `inventory_invention` rows stay too — deleting a creator's invention must not
* rewrite what other players bought. They already fall out of every list on their own:
* `getMyInventions` resolves owned ids against this table and an id with no row left
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
* them would also erase the acquisition history that ranks the "top today" feed.
*/
export async function deleteInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
await db.batch([
db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId),
db.prepare('DELETE FROM invention_interaction WHERE invention_id = ?1').bind(inventionId),
])
return invention
}
/**
* Set or clear one player's cheer on an invention and resync the invention's denormalized
* `CheerCount`. Repeating either state is idempotent because the interaction row is keyed by
* `(player_id, invention_id)` and the public count is always derived from those rows.
*/
export async function setInventionCheer(
db: D1Database,
playerId: number,
inventionId: number,
cheer: boolean
): Promise<number> {
await db
.prepare(
`INSERT INTO invention_interaction (player_id, invention_id, cheered, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(player_id, invention_id) DO UPDATE SET cheered = ?3`
)
.bind(playerId, inventionId, cheer ? 1 : 0, new Date().toISOString())
.run()
const row = await db
.prepare(
'SELECT COUNT(*) AS n FROM invention_interaction WHERE invention_id = ?1 AND cheered = 1'
)
.bind(inventionId)
.first<{ n: number }>()
const count = row?.n ?? 0
await db
.prepare(
"UPDATE invention SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
)
.bind(inventionId, count)
.run()
return count
}
/** Whether one player currently cheers an invention. */
export async function isInventionCheered(
db: D1Database,
playerId: number,
inventionId: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS found FROM invention_interaction
WHERE player_id = ?1 AND invention_id = ?2 AND cheered = 1`
)
.bind(playerId, inventionId)
.first<{ found: number }>()
return row !== null
}
/**
* What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always NULL — the invention is
* gone, so there is nothing for the client to redraw from and it reads only `Success`
* (and `Error`, the one string that reaches a human). This is why the delete does not
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
* client dereferences, and a delete has none to give.
*/
export interface InventionDeleteResult {
Value: null
Success: boolean
Error: string | null
error_id: string | null
}
/** The delete envelope: a refusal when given a message, success when given null. */
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
return { Value: null, Success: error === null, Error: error, error_id: null }
}
/** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters {
PinnedFilters: string[]
@@ -714,8 +1178,7 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND is_published = 1
AND hide_from_player = 0`
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
)
.bind(roomId)
.all<InventionRow>()
+321 -28
View File
@@ -119,12 +119,14 @@ export const SuccessErrorEnvelope = z.object({
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
/** `GET /api/config/v1/amplitude` — the client's analytics keys (blank; RudderStack and StatSig off). */
export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(),
StatSigKey: z.string(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(),
RudderStackKey: z.string(),
UseStatSig: z.boolean(),
StatSigKey: z.string(),
StatSigEnvironment: z.number().int(),
})
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
@@ -398,9 +400,15 @@ export const CustomAvatarItemResponse = z.object({
error_id: z.string().nullable(),
})
/** The `Ids` form body the bulk POST endpoints take. */
/**
* The `Ids` form body the bulk POST endpoints take, in either of the two spellings the
* client sends: `Ids` REPEATED once per id (`Ids=101&Ids=102&Ids=103`) or a single
* comma-separated `Ids=1,2,3`. Both are read by `parseFormIds`.
*/
export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
Ids: z
.union([z.string(), z.array(z.string())])
.describe('Repeated (`Ids=101&Ids=102`) or comma-separated (`Ids=1,2,3`)'),
})
// ---- Inventions ------------------------------------------------------------
@@ -420,6 +428,10 @@ export const InventionVersionDto = z.object({
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
AICost: z.int(),
HasBetaContent: z
.boolean()
.optional()
.describe('Set from `v9/save` on — absent on a version saved through `v6/save`'),
})
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
@@ -456,10 +468,30 @@ export const InventionDto = z.object({
AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z
.array(z.string())
.optional()
.describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'),
UgcVersion: z
.int()
.optional()
.describe('An invention field, not a version one — set from `v9/save` on'),
LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'),
DisplayMetadataJson: z
.string()
.optional()
.describe('The clients own display state, stored as the opaque string it sent'),
ConvertedFromInventionId: z
.int()
.optional()
.describe('The invention this one was converted from, when `v9/save` named one'),
Tags: z
.array(InventionTagDto)
.optional()
.describe('Unset on save — the real RRInvention carries no Tags field'),
.describe(
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
'when its `tagsRequest` names at least one tag'
),
})
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
@@ -469,6 +501,88 @@ export const InventionSaveResult = z.object({
InventionVersion: InventionVersionDto,
})
/**
* The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is
* not the record this server stores or the read endpoints serve: no nested
* `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the
* version), no `IsPublished`.
*/
export const InventionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
CreatorPlayerId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'),
ModifiedAt: z.string(),
CreatedAt: z.string(),
FirstPublishedAt: z.string().nullable(),
CreationRoomId: z.int().nullable(),
NumPlayersHaveUsedInRoom: z.int(),
NumDownloads: z.int(),
CheerCount: z.int(),
CreatorPermission: z.int(),
GeneralPermission: z.int(),
IsAGInvention: z.boolean(),
IsCertifiedInvention: z.boolean(),
IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'),
AllowTrial: z.boolean(),
Price: z.int().nullable(),
HideFromPlayer: z.boolean(),
DisplayMetadataJson: z.string().nullable(),
})
/**
* The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt`
* of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request
* still sends and this server still stores.
*/
export const InventionVersionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
VersionNumber: z.int(),
HasBetaContent: z.boolean(),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
BlobName: z.string(),
BlobHash: z.string().nullable(),
CreatedAt: z.string(),
UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z.array(z.string()),
})
/**
* What `v9/save` answers — the enveloped result. The client checks `Success` and then reads
* `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`,
* `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true`
* with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`.
*/
export const InventionSaveV9Result = z.object({
Value: z
.object({
Status: z.int().describe('0 = success; the client never reads it on this route'),
Invention: InventionV9Dto,
InventionVersion: InventionVersionV9Dto,
TagsResponse: z.object({
Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'),
Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'),
}),
})
.nullable()
.describe('Null when Success is false — and only then'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; the only text the client shows'),
error_id: z.string().nullable().describe('Always null'),
})
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({
PinnedFilters: z.array(z.string()),
@@ -484,7 +598,13 @@ export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
export const InventionPersonalDetails = z.object({
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
IsCheering: z.boolean().describe('Whether the caller currently cheers this invention'),
})
/** `POST /api/inventions/v1/cheer` JSON body. */
export const InventionCheerRequest = z.object({
InventionId: z.int().describe('The invention whose cheer state is changing'),
Cheer: z.boolean().describe('True to cheer; false to remove the cheer'),
})
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
@@ -506,6 +626,76 @@ export const SetTagsResponse = z.object({
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})
/**
* `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is
* NULLABLE: the newer client sends the whole shape on every edit and marks the fields it
* isn't touching as null. An empty string is not a null — it clears the field.
*/
export const UpdateInventionMetadataRequest = z.object({
InventionId: z.int(),
Name: z
.string()
.nullable()
.optional()
.describe('324 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
TagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.nullable()
.optional()
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
})
/**
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
* `v2/metadata`'s is: a null field keeps what the invention already has.
*/
export const PublishInventionRequest = z.object({
InventionId: z.int(),
Permission: z
.int()
.nullable()
.optional()
.describe(
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
'Charge 80, Unlimited 100. Null publishes as UseOnly'
),
Accessibility: z
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
Price: z
.int()
.nullable()
.optional()
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
export const DeleteInventionRequest = z.object({
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
})
/**
* What `v2/delete` answers — the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always null. The invention is
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
* `Error` when that is false.
*/
export const InventionDeleteResult = z.object({
Value: z.null().describe('Always null — the invention no longer exists'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; null on success'),
error_id: z.string().nullable().describe('Always null'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
@@ -533,6 +723,34 @@ export const SaveInventionRequest = z.object({
aiCost: z.int().optional(),
creationRoomId: z.int().optional(),
referencedInventions: z.array(z.int()).optional(),
creatorAccountRole: z
.int()
.optional()
.describe('Accepted and ignored — a room role, not a permission over the invention'),
})
/**
* `POST /api/inventions/v9/save` JSON body — `v6`s fields plus what the invention
* points at, what it says about itself, and the tags that used to need a second
* `v1/settags` call.
*/
export const SaveInventionV9Request = SaveInventionRequest.extend({
ugcVersion: z.int().optional().describe('The UGC format the blob was written in'),
hasBetaContent: z.boolean().optional(),
referencedUnityAssetIds: z.array(z.string()).optional(),
longDescription: z.string().optional().describe('Stored when non-empty'),
displayMetadataJson: z
.string()
.optional()
.describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'),
convertedFromInventionId: z.int().nullable().optional(),
tagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.optional()
.describe('The same two lists `v1/settags` takes, folded into the save'),
})
// ---- Avatar / custom avatar items ------------------------------------------
@@ -807,13 +1025,23 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
})
/**
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
* nothing broadcasts an event yet).
* The client's BASE event, 17 keys — the stored record minus `State`, with `ImageName` as a
* string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null — nothing
* broadcasts an event yet). It is also what the v2 envelope carries once `Tags` is added.
*
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
* the two.
* THREE reads serve exactly this, through one generic helper on the client and one element
* type: the browse feed (`GET /api/playerevents/v1`), the room shelf
* (`GET /api/playerevents/v1/room/{roomId}`) and the bulk read
* (`POST|GET /api/playerevents/v1/bulk`). They are shape-identical by construction on the
* client side; keep them that way here.
*
* The remaining reads — by id, search, searchlive and the club feeds — serve the stored
* RECORD verbatim, `State` and nullable `ImageName` included. Two projections; don't unify
* them.
*
* `DefaultBroadcastPermissions` and `CanRequestBroadcastPermissions` are the client's
* broadcast-permission enum, whose members are NOT 0/1/2: None 0, RoomOwners 256, All
* 2147483647. Reading them as an ordinal is the classic way to break broadcast.
*/
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
ImageName: z.string().describe('Empty string when the event has no image, never null'),
@@ -1036,24 +1264,62 @@ export const VoteToKickReason = z.object({
})
/**
* `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|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, 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).
*
* Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter
* reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are
* block kinds and screen dressings this server never hands out, so they always carry their
* "none" value; they are sent so a decoder that wants the key present finds it.
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
Duration: z.int(),
ReportCategory: z
.int()
.describe(
'The category the bans report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
),
Duration: z
.int()
.describe(
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
GameSessionId: z.int(),
IsBan: z.boolean(),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable(),
PlayerIdReporter: z.int().nullable(),
TimeoutStartedAt: z.string().nullable(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
PlayerIdReporter: z
.int()
.nullable()
.describe('Always null — the reporter is not shown to the reported'),
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
IsVoiceModAutoban: z.boolean().describe('Always false'),
IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'),
IsWarning: z
.boolean()
.describe('Always false — warnings are delivered as notifications, not here'),
VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'),
TimeoutStartedAt: z
.string()
.nullable()
.describe(
'When the block began — the bans report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
),
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
TopMessageOverride: z
.string()
.nullable()
.describe('Always null — the clients default block-screen text stands'),
BottomMessageOverride: z
.string()
.nullable()
.describe('Always null — the clients default block-screen text stands'),
})
/**
@@ -1098,6 +1364,33 @@ export const CreateWarningRequest = z.object({
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/**
* `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on
* another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the
* labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the
* body — it's the bearer token's subject.
*/
export const VoteToKickRequest = z.object({
PlayerId: z.string().describe('Account id of the player being voted on'),
Response: z.string().describe('The callers own vote, e.g. `True`'),
Reason: z
.string()
.optional()
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
GameSessionId: z.string().describe('The room instance both players are standing in'),
})
/**
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
* controller. `GameSessionId` is the room INSTANCE id (`roomInstanceId`); the kick is
* scoped to it, so a player named here who is standing somewhere else is left alone.
*/
export const InstantKickRequest = z.object({
GameSessionId: z.int().describe('The room instance (game session) to eject them from'),
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
})
/** `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'),
+11 -11
View File
@@ -20,14 +20,14 @@
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
* the client reach the block screen — and reads this table only for ban EVASION (an
* account sharing a device or network with a banned one; see bans-db). This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
* of ONE room, this one out of the game.
*/
/**
@@ -199,9 +199,9 @@ export async function getActiveBan(
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
* caller that does, and reads `getActiveBan` itself.
*/
export async function isPlayerBanned(
db: D1Database,
+541 -70
View File
@@ -4,8 +4,10 @@ import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
getOutfits,
getOutfitsByAccounts,
inventionDescriptionRejection,
inventionLongDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS,
@@ -26,6 +28,7 @@ import {
import { authedId, unauthorized } from '../http'
import {
createInvention,
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByIds,
@@ -35,13 +38,20 @@ import {
getInventionVersion,
getMyInventions,
getTopInventions,
INVENTION_TAG_RESULT,
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
setInventionCheer,
setInventionPrice,
setInventionTags,
toSaveResult,
toSaveResultV9,
updateInvention,
} from '../inventions-db'
import {
@@ -54,17 +64,21 @@ import {
CustomAvatarItemReportRequest,
CustomAvatarItemResponse,
CustomAvatarItemsPage,
DeleteInventionRequest,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionCheerRequest,
InventionDeleteResult,
InventionDetails,
InventionDto,
InventionPersonalDetails,
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionVersionDto,
json,
JsonArray,
@@ -77,7 +91,9 @@ import {
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
PublishInventionRequest,
SaveInventionRequest,
SaveInventionV9Request,
SetTagsRequest,
SetTagsResponse,
stringParam,
@@ -87,13 +103,15 @@ import {
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
import type { SavedInvention } from '../inventions-db'
import type { InventionTag, SavedInvention } from '../inventions-db'
/**
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
@@ -133,24 +151,150 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
/**
* The gate every invention write runs through: the caller must be signed in, the
* invention must exist, and it must be theirs. Yields the loaded invention, or the
* error response to return as-is (401 / 404 / 403).
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
* as a reason and the status it maps to, so that a caller answering an envelope can put
* the reason where its client will read it instead of in a body that client can't parse.
* {@link creatorsInvention} is the rendering the older routes want.
*/
async function creatorsInventionResult(
c: Context<App>,
inventionId: number
): Promise<
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
> {
const playerId = await authedId(c)
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { rejection: 'No such invention', status: 404 }
if (invention.CreatorPlayerId !== playerId) {
return { rejection: 'Not your invention', status: 403 }
}
return { invention }
}
/**
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
* invention, or the response to return as-is (400 / 401 / 403 / 404).
*/
async function creatorsInvention(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
const playerId = await authedId(c)
if (playerId === null) return { response: unauthorized(c) }
if (Number.isNaN(inventionId)) {
return { response: c.json({ error: 'inventionId is required' }, 400) }
const gate = await creatorsInventionResult(c, inventionId)
if ('invention' in gate) return gate
if (gate.status === 401) return { response: unauthorized(c) }
if (gate.status === 404) return { response: c.notFound() }
return { response: c.json({ error: gate.rejection }, gate.status) }
}
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { response: c.notFound() }
if (invention.CreatorPlayerId !== playerId) {
return { response: c.json({ error: 'Not your invention' }, 403) }
/**
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
* Null when the client named no block at all, which each caller reads its own way: a save
* stores no tags, an edit leaves the stored ones alone.
*
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
* TAGS and not the write: both replies carry a tag result of their own precisely because
* the two outcomes are separate, and refusing a save would make the player redo a build
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
* skipped rather than counted against it; the client pads its lists with empties.
*/
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
if (typeof request !== 'object' || request === null) return null
const lists = request as Record<string, unknown>
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const autoTags = strings(lists.AutoTags)
const customTags = strings(lists.CustomTags)
const rejected = [...autoTags, ...customTags].some((raw) => {
const tag = raw.trim().toLowerCase()
return tag !== '' && inventionTagRejection(tag) !== null
})
return rejected
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
}
return { invention }
/**
* What an invention save produced: the stored record and how its tags fared, or the one
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
* two versions disagree about the shape of a reply, not about what a save is.
*/
type InventionSaveOutcome =
| { rejection: string }
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
/**
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
* them here changes nothing about the record a v6 save stores.
*/
async function createInventionFromBody(
c: Context<App>,
creatorPlayerId: number,
body: Record<string, unknown>
): Promise<InventionSaveOutcome> {
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
Array.isArray(v) ? v.filter(is) : undefined
const isString = (v: unknown): v is string => typeof v === 'string'
const isNumber = (v: unknown): v is number => typeof v === 'number'
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
// 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 { rejection: nameRejection }
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return { rejection: descriptionRejection }
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
const requested = requestedTags(body.tagsRequest) ?? {
tags: [],
tagResult: INVENTION_TAG_RESULT.success,
}
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId,
inventionDataFilename,
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: list(body.referencedInventions, isNumber),
ugcVersion: num(body.ugcVersion),
hasBetaContent: bool(body.hasBetaContent),
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
longDescription: str(body.longDescription),
displayMetadataJson: str(body.displayMetadataJson),
convertedFromInventionId: num(body.convertedFromInventionId),
tags: requested.tags,
})
return { invention, ...requested }
}
/**
@@ -315,6 +459,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
}),
async (c) => {
@@ -340,6 +485,31 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return fail('BaseAvatarItemColor is required')
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env)
// Each file gets the full per-file ceiling. Check both before either is copied into
// an ArrayBuffer or written, so a rejected request never leaves half an item in R2.
if (exceedsApiUploadLimit(body.thumbnailImage, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `thumbnailImage exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
if (exceedsApiUploadLimit(body.design, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `design exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
@@ -882,29 +1052,46 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// 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.
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
// both worker's write paths land in the shared `outfit` table, so both list endpoints
// serve the same rows.
//
// Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is
// also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set`
// 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a
// wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper
// mistake of the two.
//
// Rows are served exactly as they were stored, unprojected — see the note atop
// `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while
// `/outfits/me` holds the newer envelope, and neither is converted into the other.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar', '2025'],
summary: 'The callers 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.',
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
'ordered by slot, and `[]` when they have saved none. The same rows `econ`s ' +
'`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' +
'table.\n\n' +
'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' +
'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' +
'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' +
'Each outfit is served exactly as it was stored, unprojected: slots written through ' +
'`PUT /outfits/me` hold the newer envelope while `econ`s saved-set slots hold the ' +
'old flat shape, and neither is converted into the other.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
return c.json(await getOutfits(c.env.DB, id))
}
)
@@ -1035,23 +1222,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// just whether they're cheering it. We store no cheers (nothing can cheer an
// invention yet), so this is always false; it stays a 200 for signed-out callers
// too, since the client only reads the flag.
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// just whether they're cheering it. Signed-out callers read false: there is no player
// whose interaction could be looked up, and the client still needs a flag to render.
.get(
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own relation to an invention',
description:
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
'flag.',
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
'since there is no player interaction to look up.',
parameters: [idParam('inventionId', 'Invention id')],
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
responses: { 200: json(InventionPersonalDetails, 'The callers cheer state') },
}),
(c) => c.json({ IsCheering: false })
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json({ IsCheering: false })
const inventionId = Number.parseInt(c.req.param('inventionId'), 10)
return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) })
}
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
@@ -1216,12 +1406,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
price: Number.isNaN(price) || price < 0 ? undefined : price,
})
return published === null ? c.notFound() : c.json(toSaveResult(published))
}
)
@@ -1568,6 +1757,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Cheer or un-cheer an invention. The interaction row is per player and the stored
// invention's public CheerCount is derived from all active cheers.
.post(
'/api/inventions/v1/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention',
description:
'Persists the callers cheer state and resyncs the inventions `CheerCount`. ' +
'Repeating the same state is idempotent.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Invalid body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such invention'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return unauthorized(c)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = Number(body.InventionId)
if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') {
return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400)
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json({ success: false, error: 'No such invention' }, 404)
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json({ success: true, error: '' })
}
)
// Report an invention. Stored in the `report` table the player and event reports use —
// same fields, same moderation life — with `invention_id` set. See
// migrations/0016_report_invention.sql.
@@ -1670,43 +1895,289 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
// 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,
const outcome = await createInventionFromBody(c, id, body)
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
return c.json(toSaveResult(outcome.invention))
}
)
// The same save as the newer client sends it: v6's body plus the invention's
// references, its long description and display metadata, what the saved blob is, and
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
// takes it down rather than failing it, which is why every branch below answers one.
.post(
'/api/inventions/v9/save',
describeRoute({
tags: ['Inventions'],
summary: 'Save a new invention (v9)',
description:
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
'stored only when sent, so a body v6 would accept produces the same record here.' +
'\n\n' +
'The reply is where the two versions part: v9 is ENVELOPED as ' +
'`{ Value, Success, Error, error_id }`, with v6s ' +
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
'`TagsResponse`. The client reads `Success` and then ' +
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
'\n\n' +
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
'anything that isnt this envelope as a null one. Tags are held to the ' +
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
're-submits them through `v1/settags`.\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
'`Error` when the save was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const outcome = await createInventionFromBody(c, id, body)
return c.json(
'rejection' in outcome
? inventionSaveV9Failure(outcome.rejection)
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
)
}
)
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
// body where every field but the id is nullable, and NULL means "leave this alone" —
// the client sends the whole shape every time and marks the fields it isn't touching.
// The tags ride along the way they do on `v9/save`, and the reply is that same
// envelope: `v1/update` is the older client's version of this endpoint, query params
// and a bare body and all.
.put(
'/api/inventions/v2/metadata',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an inventions metadata (v2)',
description:
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
'description, long description or image. `Name` is the exception, since a nameless ' +
'invention isnt a thing the client can draw: it is held to the same 324 ' +
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
'unknown invention and someone elses alike — are `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
'`Error` when the edit was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
// The id rides in the body here, not the query string.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// Only a missing token is answered as a transport failure. An unknown invention
// or someone else's is a domain answer the client is meant to read — its own
// status enum has DoesNotExist and NotCreator members — so it goes in the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// Null is "leave it"; a string, empty or not, is an edit.
const edited = (key: string): string | undefined =>
typeof body[key] === 'string' ? body[key] : undefined
const name = edited('Name')?.trim()
const description = edited('Description')
const longDescription = edited('LongDescription')
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined
? null
: inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
// A null TagsRequest leaves the stored tags alone, and the reply still reports
// them: the client reads the list back as the tags the invention now has, not as
// the ones this call changed.
const requested = requestedTags(body.TagsRequest)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
longDescription,
imageName: edited('ImageName'),
tags: requested?.tags,
})
return c.json(toSaveResult(invention))
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(
toSaveResultV9(
updated,
updated.Tags ?? [],
requested?.tagResult ?? INVENTION_TAG_RESULT.success
)
)
}
)
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
// the published flag, this decides whether the result can be FOUND. Same enveloped
// reply as `v9/save`, carrying the published invention.
.post(
'/api/inventions/v4/publish',
describeRoute({
tags: ['Inventions'],
summary: 'Publish an invention (v4)',
description:
'What puts an invention into search and the feeds. Creator only.\n\n' +
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
'than zeroing it, so re-publishing something that was for sale doesnt give it ' +
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
'the invention has.\n\n' +
'Publishing is not undone here, and re-publishing doesnt re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the published invention under `Value`, or `Success: false` ' +
'with `Error` when the publish was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
// answers the client is meant to read out of the envelope.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// Null is "leave it". The permission and accessibility are taken as sent rather
// than checked against the ladder, the way `parsePermissionLevel` already accepts
// a raw number: the ladders are the client's, and a level this server hasn't heard
// of is better stored than swapped for one the creator didn't pick.
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
accessibility: int('Accessibility'),
// A negative price is dropped rather than stored, as it is on `v3/publish`.
price: price !== undefined && price < 0 ? undefined : price,
})
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(published, published.Tags ?? []))
}
)
// Delete an invention. The newer client's shape: a POST with a PascalCase body
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
// remove an invention is the account that made it, not a co-owner and not a buyer.
//
// The record and everything inside it (versions, tags, referenced-invention lists)
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
// players who bought it are left alone. See `deleteInvention` for why.
.post(
'/api/inventions/v2/delete',
describeRoute({
tags: ['Inventions'],
summary: 'Delete an invention',
description:
'Creator only — a buyer or a co-owner cannot delete someone elses invention. ' +
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
'in the same row.\n\n' +
'What survives is deliberate. The data blob stays in storage, because nothing ' +
'here knows whether another record still points at that filename. The ownership ' +
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
'else paid for — and they fall out of every list on their own, since an owned id ' +
'with no invention row behind it is skipped.\n\n' +
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
'unknown invention and someone elses alike — are `Success: false` with a ' +
'message, not a bare error body that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
responses: {
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on those two: only a missing token is a transport failure. An unknown
// invention or someone else's is a domain answer the client reads out of the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionDeleteResult(gate.rejection), 401)
: c.json(inventionDeleteResult(gate.rejection))
}
// The gate already loaded the row, so a null here is a race — someone deleted it
// between the two reads — and lands where the client would put it anyway: gone.
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
return c.json(
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
)
}
)
+8 -6
View File
@@ -29,17 +29,19 @@ export const configRoutes = new Hono<App>({ strict: false })
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
'is off — but the client needs the object to finish loading.',
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
'StatSig are off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}),
(c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
AmplitudeKey: '',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
)
.get(
+70 -18
View File
@@ -35,9 +35,10 @@ import {
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BulkIdsRequest,
form,
idParam,
intQuery,
@@ -383,9 +384,12 @@ export const eventRoutes = new Hono<App>({ strict: false })
)
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
// room's shelf is small enough that there is nothing to page.
// A bare array, no envelope: the single-club form's `{ ContinuationToken, Events }` is
// the odd one out, and a room's shelf is small enough that there is nothing to page.
//
// The BASE event, not the stored record. The client reads this through the same generic
// helper and the same element type as the browse feed and the bulk read, so those three
// are shape-identical on its side — `toEventBase` is what keeps them identical here.
.get(
'/api/playerevents/v1/room/:roomId{[0-9]+}',
describeRoute({
@@ -393,17 +397,25 @@ export const eventRoutes = new Hono<App>({ strict: false })
summary: 'Player events in one room',
description:
'The events scheduled in a room — the shelf on the rooms page — soonest first. A ' +
'bare array of the stored record, the same projection `/searchlive` and the ' +
'multi-club shelf serve.\n\n' +
'bare array of the clients BASE event (17 keys — no `State`, `ImageName` as `""` ' +
'rather than null, plus `BroadcastingRoomInstanceId`), the same projection the ' +
'browse feed and the bulk read serve: the client decodes all three through one ' +
'generic helper and one element type. `/searchlive` and the club shelves serve the ' +
'stored record instead.\n\n' +
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
'listed until it is over rather than vanishing the moment it starts, and an event ' +
'that has finished is dropped — this answers what someone can still turn up to. A ' +
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
'empty array; the shelf is about events, not about whether the room is real.',
parameters: [idParam('roomId', 'Room id')],
responses: { 200: json(PlayerEventDto.array(), 'The rooms current and upcoming events') },
responses: {
200: json(PlayerEventBaseDto.array(), 'The rooms current and upcoming events'),
},
}),
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
async (c) => {
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return c.json(events.map(toEventBase))
}
)
// Live player-event search (the "happening now" browse query) — events that have
@@ -457,22 +469,62 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// 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(
// Bulk fetch — the events behind a list of ids the client already holds. What the
// client actually calls is the POST, with the ids in a form body
// (`Ids=101&Ids=102&Ids=103`, or `Ids=13` for one); the GET below is the same read with
// the ids in the query, kept for hand-written calls.
//
// The BASE event, like the browse feed and the room shelf: one generic helper and one
// element type decode all three on the client, so this is "the feed, filtered to these
// ids" and must not drift into the stored-record shape the by-id read serves.
//
// Answers in the order 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 either way: no envelope, no `{ ContinuationToken, Events }`.
.post(
'/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') },
'The events behind a list of ids the client already holds, as a form body: `Ids` ' +
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
'A bare array of the clients BASE event the same projection the browse feed and ' +
'the room shelf serve, this one filtered to the requested ids.\n\n' +
'Answers in the order the ids were asked for and skips ids with no event rather ' +
'than leaving a hole, so the result may be shorter than the request. No ids at all ' +
'is an empty array, not a 400.',
requestBody: form(BulkIdsRequest, 'The event ids to look up'),
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
async (c) => {
const events = await getEventsByIds(c.env.DB, await parseFormIds(c))
return c.json(events.map(toEventBase))
}
)
// The same read with the ids in the query (`?id=1&id=2`) — not what the client sends,
// but the shape stays identical to the POST's so the path can't answer two things.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id (query form)',
description:
'The same read as the POST on this path, with the ids in the query (`?id=1&id=2`) ' +
'rather than a form body — the client sends the POST. Identical response: a bare ' +
'array of the BASE event, in request order, skipping ids with no event.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, queryIds(c))
return c.json(events.map(toEventBase))
}
)
// RSVP. One row per player per event, so responding again replaces the previous
+8
View File
@@ -46,6 +46,7 @@ import {
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
@@ -210,6 +211,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
}),
async (c) => {
@@ -221,6 +223,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
const limit = maxApiUploadBytes(c.env)
// parseBody has already materialized the multipart part. Reject it before arrayBuffer()
// creates another full-size allocation and before the object can consume R2 storage.
if (exceedsApiUploadLimit(file, limit)) {
return c.json({ error: `image exceeds the ${limit}-byte upload limit` }, 413)
}
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
+465 -34
View File
@@ -1,6 +1,22 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canModerateRoom,
deletePresence,
getPlayerIdsInInstance,
getPresences,
getRoomById,
getStoredRoomInstance,
MessageType,
refreshInstanceFullness,
} 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 { KickReportCategory } from '../../../notify/src/notification-payloads'
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
@@ -9,18 +25,23 @@ import {
CreateWarningRequest,
DeviceIdRequest,
form,
InstantKickRequest,
json,
JsonArray,
jsonBody,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
VoteToKickRequest,
} from '../openapi'
import { createReport } from '../reports-db'
import { createReport, getActiveBan } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { ReportRow } from '../reports-db'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
@@ -82,16 +103,209 @@ const VOTE_TO_KICK_REASONS = [
{ Reason: 'Not following game rules', ReportCategory: 6 },
] as const
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Eject players from the instance they're standing in — the `ModerationKick` frame (id 22)
* the client acts on to leave a room, the same one a room ban sends (`rooms`:
* `pushRoomBan`). This one only kicks: `IsBan` is false, so nothing keeps them from walking
* straight back in, and no ban row exists to lift.
*
* Sent EPHEMERALLY, unlike the ban's frame, and to the whole batch in one round-trip. A
* kick is only true of the moment it happened: queued and delivered on the player's next
* connect it would throw them out of some unrelated session hours later. A recipient who
* has already gone offline needs no kick anyway.
*
* `GameSessionId` is the instance they're being removed from — every recipient is in it,
* which is what the caller checked before this runs. `IsHostKick` is always true: this
* endpoint is the room's own staff acting, never the room majority vote-kicking (that path
* would carry `VoteKick` and false). Built against the client's recovered payload interface
* so a renamed key fails the build rather than vanishing on the wire.
*
* Best-effort — presence is already deleted by the time this runs, so a hub hiccup must
* not fail the request.
*/
async function pushInstantKick(
c: Context<App>,
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number
): Promise<void> {
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: true,
Message: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
playerIds,
NotificationType.ModerationKick,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ModerationKick notification', {
playerIds,
gameSessionId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
* string on the wire like every other Message's, and the client's decoder rejects an
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
*
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
* straight through, and this mirrors it verbatim.
*
* `Response` is the empty string even though the caller posted their own vote: the frame
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
* is theirs alone and is not relayed.
*/
interface VoteToKickData {
PlayerId: string
Response: string
GameSessionId: number
}
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
/**
* The Message a vote-to-kick frame carries — the same four fields as every other Message
* this server sends (see the `social` routes' `Message`), `Data` string included. A type
* alias rather than an interface: the hub's send takes an index-signature record, which
* only aliases satisfy implicitly.
*/
type VoteToKickMessage = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type
* 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when
* the hub could not be reached, which the caller reports honestly: nothing stores a vote,
* so the notification is the whole delivery.
*
* EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it
* was called: queued for an offline player, it would raise a prompt on their next connect
* about a session that ended hours ago, and there would be nothing left to vote on.
*/
async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
message.ToPlayerId,
NotificationType.MessageReceived,
message
)
return true
} catch (err) {
logger.error('failed to push VoteToKick MessageReceived notification', {
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
/**
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
* end gets the largest value the field holds, 68 years past its start.
*/
const PERMANENT_BAN_DURATION = 2_147_483_647
/**
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
* kinds and screen dressings this server never uses — a device ban, a warning, the
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
* message overrides — so they carry their "none" values on every answer.
*/
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
/**
* The block details for a ban in force — the `report` row a moderator set `banned` on.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is the report's `created_at` — nothing records when
* the ban itself was handed down, and the report is the record the ban rests on — and the
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
* violation" rather than the report's `details` — those are the REPORTER's words, and the
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
* reporter is not a host who kicked them, and naming them would tell the banned player who
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
* screen dressings, none of which this server hands out.
*/
function banBlockDetails(ban: ReportRow) {
const startedAt = Date.parse(ban.created_at)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: ban.created_at,
}
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not 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.
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
// kind of block this server has is the account-wide ban — a `report` row with `banned`
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
// force gets it described here, and everyone else gets the "not blocked" answer of the
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
// player is shown, which is why `auth` still issues them a token: without one the client
// never gets here, and the ban reads as a failed sign-in.
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
// at matchmake and login: this screen explains a ban handed to this account, and a
// player blocked for sharing a network with a banned one has no report row to show.
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
// is a real category, and `Message` is null, not the empty string that stub sends —
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
// defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
@@ -101,29 +315,40 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference servers stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `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. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
'matchmake refuses on (login still issues a token, so the client can reach this ' +
'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'`Message` “Rule violation”, and the blocks span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the reports `created_at` and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the callers own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
'Everyone else gets the reference servers stub “not blocked” answer: ' +
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
'`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, as do the seven keys past the stubs nine that the 2025 clients decoder ' +
'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' +
'`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' +
'block kinds and screen dressings this server never uses. Answers GET or POST: ' +
'the newer client POSTs it with no body.',
security: AUTHED,
responses: {
200: json(ModerationBlockDetails, 'The callers block, or “not blocked”'),
401: UNAUTHORIZED_RESPONSE,
},
}),
(c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ban = await getActiveBan(c.env.DB, id)
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
}
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
// the client renders them in the order they arrive — and the list is grouped by the
@@ -209,10 +434,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
'`banned` on the row, which is what matchmaking refuses on and what ' +
'`moderationBlockDetails` describes to the banned player.\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 ' +
@@ -257,6 +482,212 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// A player calling a vote to kick another. Ungated by role — anyone may start one —
// but both players have to be standing in the session the vote is called in, which is
// what stops a client putting a vote to a room it isn't in, about someone who isn't
// there. Nothing tallies the votes yet: this relays the prompt and no more.
.post(
'/api/PlayerReporting/v3/voteToKick',
describeRoute({
tags: ['Moderation'],
summary: 'Call a vote to kick a player',
description:
'Puts a vote-to-kick to the room instance. Open to any player — no role is ' +
'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' +
'the instance `GameSessionId` names, or the call is refused with a 403. That is ' +
'the whole gate: without it a client could raise a vote in a session it is not ' +
'in, or against a player who is not there.\n\n' +
'Everyone else in that instance — the player being voted on included, since a ' +
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
'already, and their own `Response` is what they posted.\n\n' +
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
'object. A Messages `Data` is a string on the wire, and an object there fails the ' +
"clients decoder outright (`expected:'String Begin Token', actual:'{'`), " +
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
'empty — the frame is the question, not an answer.\n\n' +
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
'offline player gets nothing rather than a prompt about a dead session on their ' +
'next connect.\n\n' +
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
'with nothing behind it the frame is the whole delivery.',
security: AUTHED,
requestBody: form(VoteToKickRequest, 'The vote'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const voterId = await authedId(c)
if (voterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
// reference relays — but parsed here to check it against presence.
const playerIdField = formField(body, c, 'PlayerId')
const playerId = asInt(playerIdField)
if (playerIdField === undefined || playerId === null) {
return c.json({ success: false, error: 'PlayerId is required' }, 400)
}
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
// One read for both players. A vote may only be called by someone standing in the
// session, about someone standing in the same one — the session is read from live
// presence, never from the body, so neither side can be asserted by the client.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [
voterId,
playerId,
])
const isHere = (id: number) =>
presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId
if (!isHere(voterId)) {
return c.json({ success: false, error: 'You are not in that game session!' }, 403)
}
if (!isHere(playerId)) {
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
}
// The room votes, so the audience is everyone standing there — the player being
// voted on included; a vote is called in front of them. The caller is dropped:
// their vote is the one they just posted.
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter(
(id) => id !== voterId
)
// Every recipient is attempted even if an earlier one fails, so the reachable
// players still get the vote.
const results = await Promise.all(
audience.map((toPlayerId) =>
pushVoteToKick(c, {
FromPlayerId: voterId,
ToPlayerId: toPlayerId,
Type: MessageType.VoteToKick,
// An escaped JSON STRING, not a nested object — see VoteToKickData.
Data: voteToKickData({
PlayerId: playerIdField,
Response: '',
GameSessionId: gameSessionId,
}),
})
)
)
if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver vote' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// The kick a room's own staff hand out from the moderation menu: eject named players
// from ONE live instance. Two gates, and both matter — the caller must be able to
// moderate the room the instance belongs to, and each named player must actually be
// standing in that instance. Without the second, a creator could name any account id
// and kick a stranger out of somebody else's room.
.post(
'/api/PlayerReporting/v1/instantKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick players out of a room instance',
description:
'Ejects the named players from one live room instance. `GameSessionId` is that ' +
'instance (`roomInstanceId`); the body is JSON, unlike the form posts elsewhere in ' +
'this controller.\n\n' +
'Gated to the instances room: the caller must be its creator or hold a role of ' +
'Moderator (20) or above on it — anyone else with a valid token gets a 403. ' +
'Nobody who can moderate the room can be kicked out of it, and a caller cannot ' +
'kick themselves.\n\n' +
'A player is only kicked if their live `presence` row puts them in **that** ' +
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
'in silence, so naming an account id cannot reach into a session the caller has ' +
'no authority over.\n\n' +
'Each kicked player loses their presence row (they read offline at once and the ' +
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
'false: this only removes them from the session they are in, and nothing stops ' +
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
'happened, and queueing one would eject the player from an unrelated session on ' +
'their next connect.\n\n' +
'Answers the same lowercase `{ success, error }` envelope the report write uses, ' +
'and says nothing about who was actually kicked — the response shape is ' +
'unverified against the real service.',
security: AUTHED,
requestBody: jsonBody(InstantKickRequest, 'The instance and the players to eject'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Unparseable body, no `GameSessionId` or no `PlayerIds`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller cannot moderate the instances room'),
404: json(SuccessErrorEnvelope, 'No such game session'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
GameSessionId?: unknown
PlayerIds?: unknown
} | null
if (body === null) return c.json({ success: false, error: 'Invalid request body' }, 400)
const gameSessionId = typeof body.GameSessionId === 'number' ? body.GameSessionId : Number.NaN
if (!Number.isInteger(gameSessionId)) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerIds = Array.isArray(body.PlayerIds)
? body.PlayerIds.filter((id): id is number => Number.isInteger(id))
: []
if (playerIds.length === 0) {
return c.json({ success: false, error: 'PlayerIds is required' }, 400)
}
// The instance names the room, and the room carries the roles this is gated on —
// a game session with no room behind it can't authorise anything.
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
if (!canModerateRoom(room, moderatorId)) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
// One read for the batch. A player is kicked only when their LIVE presence puts
// them in this very instance: offline, expired or standing elsewhere are all the
// same "not here", and are skipped rather than refused — the client sends a list
// and one stale id in it must not sink the rest.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, playerIds)
const kicked: number[] = []
for (const playerId of playerIds) {
// The room's own staff are not kickable out of their room — otherwise a
// moderator could throw the creator out of it. Nor is the caller themselves.
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
await deletePresence(c.env.DB, playerId)
kicked.push(playerId)
}
if (kicked.length > 0) {
// The instance just lost players — recompute its fullness so a full room opens
// back up, exactly as the `match` worker does when someone logs out.
await refreshInstanceFullness(c.env.DB, gameSessionId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
}
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(
+34
View File
@@ -60,6 +60,40 @@ export function containsSwears(value: string): boolean {
return value !== '' && filter.exists(value)
}
/**
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
* hop, the last capital of a run before a capitalised word, and either side of a run of
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
* apart at the seam a player wrote them with.
*/
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
[/([a-z0-9])([A-Z])/g, '$1 $2'],
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
[/([A-Za-z])([0-9])/g, '$1 $2'],
]
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
* name without being able to say why is worse than missing `Bitchy`.
*/
export function nameContainsSwears(value: string): boolean {
const spaced = NAME_WORD_BOUNDARIES.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value
)
return containsSwears(spaced)
}
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
export const DEFAULT_REPLACEMENT_CHAR = '*'
+1459 -47
View File
@@ -20,6 +20,7 @@ import {
PRESENCE_TTL_SECONDS,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -63,7 +64,11 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
import type {
InventionSaveResult,
InventionSaveV9Result,
SavedInvention,
} from '../../inventions-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
@@ -90,6 +95,19 @@ const TEST_ROOMS = [
SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
},
{
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
// Moderator (20) and 44 only Host (10) — the tier just below that gate.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
CreatorAccountId: 42,
SubRooms: [{ SubRoomId: 4 }],
Roles: [
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
],
},
]
beforeAll(async () => {
@@ -128,6 +146,10 @@ beforeAll(async () => {
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room instances (owned by the rooms worker) — the instant kick resolves the game
// session it is given to the room whose staff may kick from it.
for (const stmt of ROOM_INSTANCE_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()
@@ -209,10 +231,12 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
AmplitudeKey: '',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
})
@@ -726,32 +750,6 @@ describe('public endpoints', () => {
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
})
// 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,
})
}
)
// A fixed list, in render order — the client shows the buttons in the order they
// arrive, so the order is part of the contract, not just the contents.
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
@@ -1568,15 +1566,58 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
test('GET /outfits/me/saved lists every saved slot, ordered by slot', 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.
// A distinct account, so this doesn't depend on what the tests above saved for 42.
const saved = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(),
headers: await bearer(sub),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
return (await res.json()) as Array<Record<string, unknown>>
}
// A player who has never saved gets [], not the empty-outfit envelope `/outfits/me`
// serves — an empty wardrobe is an empty list.
expect(await saved('4242')).toEqual([])
const outfit = (slot: number, name: string | null) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2: '{"selections":[]}',
FaceFeatures: '{"ver":7}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: slot,
Name: name,
Accessibility: 1,
ThumbnailFileName: null,
})
// Saved out of order, to prove the list is ordered by slot rather than by write time.
for (const [slot, name] of [
[2, 'two'],
[0, null],
] as Array<[number, string | null]>) {
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer('4242')), 'content-type': 'application/json' },
body: JSON.stringify(outfit(slot, name)),
})
}
// Slot 0 is in the list: it is the outfit being worn, but it is a saved slot too, and
// the client picks the slot it writes. Each row comes back verbatim.
expect(await saved('4242')).toEqual([outfit(0, null), outfit(2, 'two')])
// Another account's wardrobe is its own.
expect(await saved('4343')).toEqual([])
})
test('POST /outfits/bulk serves each accounts worn outfit, keyed by id', async () => {
@@ -1808,6 +1849,701 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => {
const body = {
name: '082926 13:42:46',
description: 'No description yet',
imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg',
hasBetaContent: false,
instantiationCost: 101,
lightsCost: 0,
chipsCost: 0,
cloudVariablesCost: 0,
aiCost: 0,
ugcVersion: 1,
creationRoomId: 398,
inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv',
referencedInventions: [],
referencedUnityAssetIds: [],
creatorAccountRole: 255,
convertedFromInventionId: null,
displayMetadataJson: '{"0":0,"99":0}',
longDescription: '',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
// v9 is ENVELOPED where v6 is bare. The client checks Success and then reads
// Value.Invention.InventionId — unguarded, so a true Success with a null Value is
// the one shape that takes it down.
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
expect(result.Error).toBeNull()
expect(result.error_id).toBeNull()
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Status).toBe(0)
expect(Object.keys(value).sort()).toEqual([
'Invention',
'InventionVersion',
'Status',
'TagsResponse',
])
const saved = value.Invention
expect(saved.InventionId).toBeGreaterThan(0)
expect(saved.CreatorPlayerId).toBe(5151)
expect(saved.Name).toBe(body.name)
expect(saved.CreationRoomId).toBe(398)
expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}')
// UgcVersion is an INVENTION field here, next to the version numbers — not a
// version one, where its twin HasBetaContent lives.
expect(saved.UgcVersion).toBe(1)
expect(saved.CurrentVersionNumber).toBe(1)
expect(saved.LatestVersionNumber).toBe(1)
// The v9 RRInvention has no nested version, no Referenced* and no IsPublished —
// the client reads publication from FirstPublishedAt.
expect(saved).not.toHaveProperty('CurrentVersion')
expect(saved).not.toHaveProperty('ReferencedInventions')
expect(saved).not.toHaveProperty('IsPublished')
expect(saved.FirstPublishedAt).toBeNull()
// Costs, the blob and the beta flag ride on the version beside it. No AICost: the
// request sends one and this DTO has nowhere to put it.
expect(value.InventionVersion).toMatchObject({
InventionId: saved.InventionId,
VersionNumber: 1,
InstantiationCost: 101,
HasBetaContent: false,
BlobName: body.inventionDataFilename,
UgcAccessibility: null,
ReferencedInventions: [],
ReferencedUnityAssetIds: [],
})
expect(value.InventionVersion).not.toHaveProperty('AICost')
// The tagsRequest is applied as `v1/settags` would have applied it, and answered
// the way settags answers: a result code and the bare tag NAMES.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] })
// Stored once, read by every version: the older lookup still serves the record it
// always did, nested CurrentVersion and all.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: saved.InventionId,
IsPublished: false,
CurrentVersion: { BlobName: body.inventionDataFilename },
})
})
test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => {
// The same body a v6 client sends, posted at v9: nothing is back-filled, so the
// record is the one v6 has always stored. The response still carries the full v9
// projection — those fields have defaults there, not absences.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }),
})
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Invention.UgcVersion).toBe(0)
expect(value.Invention.DisplayMetadataJson).toBeNull()
// A save always mints a version; the key is nullable only because econ's
// `v3/buyInvention` answers in this same envelope and a buy mints none.
expect(value.InventionVersion).not.toBeNull()
expect(value.InventionVersion?.HasBetaContent).toBe(false)
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
const stored = (await one.json()) as SavedInvention
expect(stored).not.toHaveProperty('Tags')
expect(stored).not.toHaveProperty('UgcVersion')
expect(stored).not.toHaveProperty('ReferencedUnityAssetIds')
expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent')
})
test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => {
// A refusal the client can show is Success:false with a null Value — the branch
// that reads Error and nothing else. A bare `{ error }` body would deserialize to
// a null envelope and take the client down instead of failing the save.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'No Blob' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Value: null,
Success: false,
Error: 'inventionDataFilename is required',
error_id: null,
})
// Even the 401 answers the envelope: an empty body is a null envelope to the
// client, which is the crash, not a refusal.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
})
test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => {
// The reply carries a tag result of its own, so the two outcomes are separate: a
// hyphen in a tag must not cost the player the build they just saved.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tagged Badly',
inventionDataFilename: 'tagged-badly.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('a refused tag must not refuse the save')
expect(value.Invention.InventionId).toBeGreaterThan(0)
// Non-zero result, and the whole list dropped rather than the offending tag alone —
// the creator re-submits it through `v1/settags` and sees what took.
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
// The invention is on the creator's shelf regardless.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5154'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([
value.Invention.InventionId,
])
})
test('PUT /api/inventions/v2/metadata edits only the fields that arent null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Before Edit',
description: 'the original description',
imageName: 'invention/before.jpg',
inventionDataFilename: 'before-edit.inv',
longDescription: 'the original blurb',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
// The client sends the whole shape every time and marks what it isn't touching as
// null — so a null Name must not blank the name.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: 'devin test No description yet',
LongDescription: null,
ImageName: null,
TagsRequest: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful edit')
// The edit answers the UPDATED invention — the client re-renders the detail page
// from it — in the same envelope the save answers.
expect(value.Invention.Description).toBe('devin test No description yet')
expect(value.Invention.Name).toBe('Before Edit')
expect(value.Invention.ImageName).toBe('invention/before.jpg')
expect(value.Invention.InventionId).toBe(inventionId)
// A null TagsRequest leaves the stored tags alone, and they are still reported: the
// list is what the invention HAS, not what this call changed.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
// And it stuck — including the long description, which the v9 Invention DTO has no
// key for but the record keeps.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
Name: 'Before Edit',
Description: 'devin test No description yet',
LongDescription: 'the original blurb',
Tags: [{ Tag: 'small', Type: 2 }],
})
})
test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Clear Me',
description: 'to be cleared',
imageName: 'invention/clear-me.jpg',
inventionDataFilename: 'clear-me.inv',
longDescription: 'blurb to be cleared',
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: '',
LongDescription: '',
ImageName: '',
TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful edit')
expect(value.Invention.Description).toBe('')
expect(value.Invention.ImageName).toBe('')
// TagsRequest replaces both lists wholesale, auto first, then custom.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] })
// An empty name is not how a name is cleared — nothing can draw a nameless
// invention, so it fails the same rule a save holds it to and nothing is written.
const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Name: '' }),
})
expect(named.status).toBe(200)
expect((await named.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
})
test('PUT /api/inventions/v2/metadata refuses another creators invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// Someone else's invention and an unknown one are domain answers, not transport
// ones — the client's own status enum has NotCreator and DoesNotExist members — so
// they come back 200 in the envelope, where the message reaches a human.
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }),
})
expect(missing.status).toBe(200)
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Error: 'No such invention',
})
// A missing token is the one refusal that stays a transport failure — but it still
// answers the envelope, because an unparseable body crashes the client.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
// Untouched throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Description).toBe('No description yet')
})
test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tag Trouble',
inventionDataFilename: 'tag-trouble.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Description: 'edited anyway',
TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('a refused tag must not refuse the edit')
// The metadata edit lands; the tags are what didn't.
expect(value.Invention.Description).toBe('edited anyway')
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
})
test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }),
})
const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention
expect(saved?.FirstPublishedAt).toBeNull()
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: saved?.InventionId,
Permission: 20,
Accessibility: 1,
Price: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful publish')
// Publishing narrows what everyone else gets down to what the sheet sent, and dates
// the invention — the client reads publication from FirstPublishedAt, not a flag.
expect(value.Invention.GeneralPermission).toBe(20)
expect(value.Invention.Accessibility).toBe(1)
expect(typeof value.Invention.FirstPublishedAt).toBe('string')
expect(value.Invention.Price).toBe(0)
// And it's findable now: the record the older reads serve says published, and it
// turns up in search.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true })
const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`)
expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain(
value.Invention.InventionId
)
// Taken back out: the browse tests below assert the exact published catalogue, and
// a test that publishes something publicly is a test that changes it.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1')
.bind(value.Invention.InventionId)
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Quietly Published',
inventionDataFilename: 'quietly-published.inv',
creationRoomId: 4171,
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.Accessibility).toBe(2)
// Unlisted is published — it is reachable by id, which is the whole point of it —
// but it is not something anyone comes across.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: inventionId,
IsPublished: true,
})
const found = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=Quietly Published`
)
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const publish = async (body: Record<string, unknown>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, ...body }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful publish')
return value.Invention
}
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
expect(first.Price).toBe(250)
// A republish that says nothing about money must not give away something that was
// for sale, and must not re-date the first publish.
const again = await publish({ Permission: 20, Accessibility: 1, Price: null })
expect(again.Price).toBe(250)
expect(again.GeneralPermission).toBe(20)
expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt)
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// Out of the published catalogue again — see the note in the publish test above.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
})
test('POST /api/inventions/v4/publish refuses another creators invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null })
// Still unpublished throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
IsPublished: false,
FirstPublishedAt: null,
})
})
test('POST /api/inventions/v2/delete removes the creators invention', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Delete Me',
inventionDataFilename: 'delete-me.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(res.status).toBe(200)
// `Value` is null even on success — there is no invention left to redraw from.
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
// Gone from the read and from the creator's shelf.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(404)
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5180'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
// And the row itself, tags and all, rather than a hidden record still taking the id.
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
.bind(inventionId)
.first<{ n: number }>()
expect(row?.n).toBe(0)
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(await again.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
})
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
await grantInvention(env.DB, 5182, inventionId as number)
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987_655 }),
})
expect(missing.status).toBe(200)
expect(await missing.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
// A missing token is the one refusal that stays a transport failure — and it still
// answers the envelope rather than a bare error body.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
// Still there throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(200)
})
test('POST /api/inventions/v2/delete leaves a buyers ownership row behind', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
.InventionId as number
await grantInvention(env.DB, 5184, inventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
// The purchase record is not rewritten by someone else's delete...
const owned = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
)
.bind(inventionId)
.first<{ n: number }>()
expect(owned?.n).toBe(1)
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5184'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
@@ -2488,17 +3224,71 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
// No cheer storage yet, so nobody is ever cheering — signed in or not.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
headers: await bearer('42'),
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ IsCheering: false })
const invention = ((await saved.json()) as InventionSaveResult).Invention
const path = `${ORIGIN}/api/inventions/v1/cheer`
const cheer = async (value: boolean, sub = '42') =>
exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: value }),
})
const personal = async (sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${invention.InventionId}`,
sub ? { headers: await bearer(sub) } : undefined
)
const storedCount = async (): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM invention WHERE id = ?1')
.bind(invention.InventionId)
.first<{ data: string }>()
return (JSON.parse(row!.data) as SavedInvention).CheerCount
}
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({ IsCheering: false })
// The write requires a player; the read remains useful to signed-out callers.
expect(
(
await exports.default.fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: true }),
})
).status
).toBe(401)
expect(await (await personal()).json()).toEqual({ IsCheering: false })
expect((await cheer(true)).status).toBe(200)
expect(await (await personal('42')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
// Repeating a state is idempotent, and a second player counts separately.
await cheer(true)
expect(await storedCount()).toBe(1)
await cheer(true, '43')
expect(await storedCount()).toBe(2)
await cheer(false)
expect(await (await personal('42')).json()).toEqual({ IsCheering: false })
expect(await (await personal('43')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
const unknown = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 999999, Cheer: true }),
})
expect(unknown.status).toBe(404)
const malformed = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: 'yes' }),
})
expect(malformed.status).toBe(400)
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
@@ -3278,6 +4068,53 @@ describe('custom avatar items', () => {
expect(await res.json()).toMatchObject({ Success: false, Value: null })
})
test('POST rejects either oversized file before writing anything to R2', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: Uint8Array, design: Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'bounded', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'thumb.png', { type: 'image/png' }))
form.set('design', new File([design], 'design.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const oversizedThumbnail = await upload(new Uint8Array(4), new Uint8Array(3))
expect(oversizedThumbnail.status).toBe(413)
expect(await oversizedThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage exceeds the 3-byte upload limit',
})
const oversizedDesign = await upload(new Uint8Array(3), new Uint8Array(4))
expect(oversizedDesign.status).toBe(413)
expect(await oversizedDesign.json()).toMatchObject({
Success: false,
Error: 'design exceeds the 3-byte upload limit',
})
// Neither rejected request may create metadata or leave one of its two objects behind.
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
objectsBefore
)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -3286,6 +4123,374 @@ describe('custom avatar items', () => {
})
})
describe('instant kick', () => {
// The game session the kick names, and one belonging to the same room that must be
// left out of it.
const SESSION = 1013781
const OTHER_SESSION = 1013782
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
// Room instances are written by the `match` worker; seeded straight into the table
// here, the way the presence rows below are.
const seedInstance = async (roomInstanceId: number, maxCapacity = 0, isFull = false) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId: 4,
subRoomId: 4,
location: '',
dataBlob: '',
eventId: 0,
photonRegionId: 'us',
photonRoomId: `photon-${roomInstanceId}`,
name: '',
maxCapacity,
isFull,
isPrivate: false,
isInProgress: false,
roomCode: '',
roomInstanceType: 0,
clubId: 0,
EncryptVoiceChat: false,
matchmakingPolicy: 0,
allowNewUsers: true,
joinDisabled: false,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ n: number }>())!.n === 1
const isFull = async (roomInstanceId: number) =>
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
.bind(roomInstanceId)
.first<{ full: number }>())!.full === 1
const kick = async (body: unknown, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
test('the rooms creator kicks a player out of the session they name', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full two-player instance: 205 is kicked, 206 stays.
await seedInstance(SESSION, 2, true)
await standIn(205, SESSION)
await standIn(206, SESSION)
const res = await kick({ GameSessionId: SESSION, PlayerIds: [205] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Presence is deleted, so they read offline at once — and only theirs is.
expect(await isPresent(205)).toBe(false)
expect(await isPresent(206)).toBe(true)
// The instance lost a player, so it is no longer full.
expect(await isFull(SESSION)).toBe(false)
// One EPHEMERAL ModerationKick, addressed to the kicked player only. `IsBan` is
// false — this ejects them from the session and nothing more.
expect(await frames()).toEqual([
{
playerIds: [205],
ephemeral: true,
notificationType: 22, // NotificationType.ModerationKick
data: {
ReportCategory: -1, // KickReportCategory.Moderator
Duration: 0,
GameSessionId: SESSION,
IsHostKick: true,
Message: 'You have been kicked from KickRoom.',
PlayerIdReporter: 42,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
},
},
])
})
// The gate that stops a creator kicking a stranger out of somebody else's session by
// naming their account id.
test('a player who is not in that session is skipped in silence', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await seedInstance(OTHER_SESSION)
await standIn(207, OTHER_SESSION)
// 207 stands in another instance; 208 is offline entirely.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [207, 208] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await isPresent(207)).toBe(true)
expect(await frames()).toEqual([])
})
test('a room moderator may kick; a host, a stranger and no token may not', async () => {
await seedInstance(SESSION)
await standIn(209, SESSION)
// 43 holds Moderator (20) on the room.
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
expect(await isPresent(209)).toBe(false)
await standIn(209, SESSION)
// 44 is only a Host (10), and 99 holds nothing at all.
for (const sub of ['44', '99']) {
const res = await kick({ GameSessionId: SESSION, PlayerIds: [209] }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await isPresent(209)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ GameSessionId: SESSION, PlayerIds: [209] }),
})
expect(anon.status).toBe(401)
})
// Otherwise a moderator could throw the room's own creator out of it.
test('the rooms staff — and the caller — cannot be kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await standIn(42, SESSION)
await standIn(43, SESSION)
// 43 (a moderator) names the creator, a fellow moderator and themselves.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
expect(res.status).toBe(200)
expect(await isPresent(42)).toBe(true)
expect(await isPresent(43)).toBe(true)
expect(await frames()).toEqual([])
})
test('an unknown session 404s, and the body must name a session and players', async () => {
await seedInstance(SESSION)
const unknown = await kick({ GameSessionId: 999999, PlayerIds: [205] })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [body, error] of [
[{ PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: 'nope', PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: SESSION }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: [] }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: ['205'] }, 'PlayerIds is required'],
] as Array<[unknown, string]>) {
const res = await kick(body)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
// A body that isn't JSON at all is the same shape, not a crash.
const broken = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: 'not json',
})
expect(broken.status).toBe(400)
expect(await broken.json()).toEqual({ success: false, error: 'Invalid request body' })
})
})
describe('vote to kick', () => {
// Two live sessions, so a vote called in one can be checked against a player in the
// other. Nothing reads `room_instance` here — the gate is presence alone.
const SESSION = 1014079
const OTHER_SESSION = 1014080
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
const vote = async (fields: Record<string, string>, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
const FIELDS = {
PlayerId: '205',
Response: 'True',
Reason: 'Inactive in games (AFK)',
GameSessionId: String(SESSION),
}
test('the vote goes to everyone in the session except the caller', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere.
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
await standIn(207, OTHER_SESSION)
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
// called in front of them), the caller does not, and 207 is in another session.
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
// and `Response` is empty because the frame is the question, not an answer.
const message = {
ephemeral: true,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 42,
Type: MessageType.VoteToKick,
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
},
}
const sent = await frames()
expect(sent).toHaveLength(2)
expect(sent).toContainEqual({
...message,
playerId: 205,
data: { ...message.data, ToPlayerId: 205 },
})
expect(sent).toContainEqual({
...message,
playerId: 206,
data: { ...message.data, ToPlayerId: 206 },
})
})
test('both players have to be standing in the session', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, OTHER_SESSION)
await standIn(205, SESSION)
// The caller is somewhere else — a vote can't be called into a session you're not in.
const away = await vote(FIELDS)
expect(away.status).toBe(403)
expect(await away.json()).toEqual({
success: false,
error: 'You are not in that game session!',
})
// And with the caller present, the player voted on has to be there too — offline,
// or standing elsewhere, both refuse.
await standIn(42, SESSION)
await standIn(205, OTHER_SESSION)
const elsewhere = await vote(FIELDS)
expect(elsewhere.status).toBe(403)
expect(await elsewhere.json()).toEqual({
success: false,
error: 'That player is not in that game session!',
})
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403)
// Nothing was put to the room on any of those.
expect(await frames()).toEqual([])
})
test('the body must name a player and a session, and the call needs a token', async () => {
await standIn(42, SESSION)
await standIn(205, SESSION)
for (const [fields, error] of [
[{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await vote(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(FIELDS),
})
expect(anon.status).toBe(401)
})
// A vote called with nobody else there is a no-op rather than an error: the caller and
// the player voted on are both here, so the gate passes, and there is simply no room
// to put it to.
test('a session holding only the two of them sends nothing', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run()
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// 205 is still in the session, so they still hear it — only the caller is dropped.
expect((await frames()).map((f) => f.playerId)).toEqual([205])
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
@@ -3444,6 +4649,111 @@ describe('player reports', () => {
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// What the banned player is TOLD. The block screen reads this; it's the same row
// matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => {
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
const details = async (method: string, sub: string) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method, headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return res.json()
}
// 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 reports "not blocked" for an unbanned player',
async (method) => {
// A report against them that nobody acted on is not a block.
await submit({ PlayerIdReported: '220' }, await bearer())
// 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 details(method, '220')).toEqual(NOT_BLOCKED)
}
)
test('401s without a bearer token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(401)
})
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
// start for the duration. The start is the report's created_at (nothing records when
// the ban itself landed), and a permanent ban runs for the largest span the int
// holds.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
ReportCategory: 102,
Duration: 2_147_483_647,
IsBan: true,
// A fixed message — the report's `details` are the reporter's words, and
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
})
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
// sums to `ban_expires` — not the seconds left as of the request.
test('describes a timed ban as its reports created_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
expect(await details('GET', '222')).toEqual({
...NOT_BLOCKED,
ReportCategory: 103,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
})
// A ban that has served its time is not a block, even though the row still says
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
test('reports "not blocked" once a ban has expired', async () => {
await submit({ PlayerIdReported: '223' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 223)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
})
})
})
describe('player warnings', () => {
@@ -4094,6 +5404,26 @@ describe('images', () => {
expect(res.status).toBe(400)
})
test('POST /api/images/v4/uploadsaved rejects an oversized image before storing it', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('image', new File([new Uint8Array(4)], 'large.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
expect(res.status).toBe(413)
expect(await res.json()).toEqual({ error: 'image exceeds the 3-byte upload limit' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => {
// Seed images in room 54: two public (one with more cheers, of different
// types), one private (hidden), and one in another room (excluded).
@@ -4934,6 +6264,16 @@ describe('player events', () => {
return { ...rest, ImageName: imageName, State: 0 }
}
/**
* The client's BASE event behind an envelope's event what the browse feed, the room
* shelf and the bulk read all serve. The envelope minus `Tags`, plus a null
* `BroadcastingRoomInstanceId`; `ImageName` is already `""` on the envelope.
*/
const asBase = (event: PlayerEventEnvelope): Record<string, unknown> => {
const { Tags: _tags, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
}
// 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: PlayerEventEnvelope
@@ -5334,7 +6674,61 @@ describe('player events', () => {
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
test('POST /api/playerevents/v1/bulk answers the requested ids as base events', async () => {
// What the client sends: `Ids` repeated once per id, form-urlencoded.
const body = new URLSearchParams()
for (const id of [clubEvent.PlayerEventId, 999999, upcoming.PlayerEventId]) {
body.append('Ids', String(id))
}
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
})
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,
])
// A bare array — no envelope — of the BASE event, the same projection the browse feed
// and the room shelf serve. Not the stored record: no `State`.
const entry = events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
expect(entry).toEqual(asBase(upcoming))
expect(Object.keys(entry)).toHaveLength(17)
expect(Object.hasOwn(entry, 'State')).toBe(false)
})
test('POST /api/playerevents/v1/bulk reads a single id and the comma-separated form', async () => {
const bulk = async (raw: string): Promise<PlayerEvent[]> => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: raw,
})
expect(res.status).toBe(200)
return (await res.json()) as PlayerEvent[]
}
// The raw one-id body the client sends for a single event.
expect((await bulk(`Ids=${upcoming.PlayerEventId}`)).map((e) => e.PlayerEventId)).toEqual([
upcoming.PlayerEventId,
])
// …and the comma-separated spelling the other bulk POSTs take.
expect(
(await bulk(`Ids=${clubEvent.PlayerEventId},${upcoming.PlayerEventId}`)).map(
(e) => e.PlayerEventId
)
).toEqual([clubEvent.PlayerEventId, upcoming.PlayerEventId])
// Nothing to look up is an empty array, not every event and not a 400.
expect(await bulk('')).toEqual([])
expect(await bulk('Ids=')).toEqual([])
})
test('GET /api/playerevents/v1/bulk answers the same shape as the POST', async () => {
const res = await get(
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
)
@@ -5345,6 +6739,10 @@ describe('player events', () => {
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(
asBase(upcoming)
)
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
@@ -5536,9 +6934,15 @@ describe('player events', () => {
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
// A bare array of the STORED record, like `/searchlive` and the multi-club shelf —
// not the base projection the browse feed serves, and not the single-club envelope.
expect(events[0]).toEqual(asRecord(running, null))
// The BASE event, 17 keys — the same projection the browse feed and the bulk read
// serve, since the client decodes all three through one helper and one element type.
// Not the stored record (`/searchlive` and the club shelves keep that), and not the
// single-club envelope.
expect(events[0]).toEqual(asBase(running))
expect(Object.keys(events[0]!)).toHaveLength(17)
expect(Object.hasOwn(events[0]!, 'State')).toBe(false)
// An event created with no banner reads `""` here, never the record's null.
expect(events[0]!.ImageName).toBe('')
// A room with nothing scheduled, and a room id nothing knows about, are both empty.
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
@@ -6300,9 +7704,11 @@ describe('openapi', () => {
'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
@@ -6313,17 +7719,22 @@ describe('openapi', () => {
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
'POST /api/inventions/v1/report',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
'POST /api/messages/v1/friendOnlineStatus',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/messages/v3/delete',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
@@ -6350,6 +7761,7 @@ describe('openapi', () => {
'POST /outfits/bulk',
'POST /statsigUserProperties',
'PUT /api/customAvatarItems/v1/{id}',
'PUT /api/inventions/v2/metadata',
'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name',
+21
View File
@@ -0,0 +1,21 @@
import { intVar } from '@repo/hono-helpers'
import type { App } from './context'
/** Safe fallback when the deployment does not configure an API upload ceiling. */
export const DEFAULT_MAX_API_UPLOAD_BYTES = 64 * 1024 * 1024
/**
* Resolve the per-file ceiling shared by API-owned image uploads. A non-positive
* setting does not disable the protection: public upload routes must always remain
* bounded, so invalid values fall back to the safe default.
*/
export function maxApiUploadBytes(env: App['Bindings']): number {
const configured = intVar(env.RECFLARE_MAX_API_UPLOAD_BYTES, DEFAULT_MAX_API_UPLOAD_BYTES)
return configured > 0 ? configured : DEFAULT_MAX_API_UPLOAD_BYTES
}
/** Whether a parsed multipart file is safe to copy into memory and persist to R2. */
export function exceedsApiUploadLimit(file: File, limit: number): boolean {
return file.size > limit
}
+1 -1
View File
@@ -925,7 +925,7 @@
},
{
"Key": "Statsig.Enabled",
"Value": "true",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
+10 -4
View File
@@ -26,10 +26,12 @@ export default defineConfig({
// GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions.
//
// notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and
// carrying `playerIds` rather than `playerId` the two sends differ in
// whether an offline recipient gets the frame later, which is a thing worth
// asserting (a cheer's effect is broadcast to a room this way).
// The ephemeral sends land in the same list, tagged `ephemeral`:
// notifyPlayerEphemeral carries `playerId` like the durable send,
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
// and ephemeral differ in whether an offline recipient gets the frame
// later, which is a thing worth asserting (a cheer's effect is broadcast to
// a room this way, a vote-to-kick is put to each player in it).
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
@@ -38,6 +40,10 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async notifyPlayerEphemeral(playerId, notificationType, data) {
this.sent.push({ playerId, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 }
+5 -1
View File
@@ -74,6 +74,10 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
// Per-file ceiling for saved images and custom-avatar-item files. Multipart parsing
// happens first, but this prevents oversized files from being copied into another
// ArrayBuffer and persisted to R2. Invalid/non-positive values fall back to 64 MiB.
"RECFLARE_MAX_API_UPLOAD_BYTES": "67108864"
}
}
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+62 -48
View File
@@ -71,19 +71,16 @@ 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 `error_description` a banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
* this exact string to put a real sentence in front of a player — anything varying would
* fall through to the generic "you could not be signed in". Keep the two in sync.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The refusal when it is not THIS account that is banned but one it shares an identity
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
* account being refused may be an innocent housemate of a banned player, so telling them
* "this account is banned" would be a lie, and naming the account we matched them to
* would hand out somebody else's moderation record.
* The `error_description` a grant is refused with when the caller's account is not itself
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* real sentence in front of a player — anything varying would fall through to the generic
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
* being refused may be an innocent housemate of a banned player, so telling them "this
* account is banned" would be a lie, and naming the account we matched them to would hand
* out somebody else's moderation record.
*
* A DIRECTLY banned account is not refused here at all — see the token grant.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
@@ -608,19 +605,22 @@ const app = new Hono<App>()
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null.',
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'is what lets the client reach `api`s `/api/PlayerReporting/v1/moderationBlockDetails`',
'and show the player the block screen that explains the ban; the ban itself is',
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
'gets them as far as that screen and no further.',
'',
'The refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operators `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
'an account has no ban of its own for the block screen to describe, so there is',
'nothing to let it in for. Those two arms are the operators `BAN_EVASION_MATCH`',
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'account refused may belong to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -633,7 +633,7 @@ const app = new Hono<App>()
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or a banned account',
'or an account sharing a banned ones device or network',
].join(' ')
),
500: json(
@@ -789,9 +789,9 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A banned player's next move is a new account, so the ban is checked BEFORE
// one is minted — against the only identity a signup has, the IP it came from
// and the platform identity it just proved. Refusing after the fact (as the
// A banned player's next move is a new account, so the evasion arms are checked
// BEFORE one is minted — against the only identity a signup has, the IP it came
// from and the platform identity it just proved. Refusing after the fact (as the
// shared check below would) still refuses the token, but leaves the account
// row behind and burns a slot off both signup caps, so the evader gets to keep
// making them.
@@ -987,14 +987,19 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
// A DIRECTLY banned account still gets its token. The client needs one to reach
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// (category, time left, "Rule violation") — refused here, they would only ever see
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
// matchmake for a banned player, so the token gets them as far as the block screen
// and no further. Logged, so the operator can see a banned player signing in.
//
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. Deliberately AFTER the credential checks —
// Ban EVASION is still refused here: an account that merely shares a device or
// network with a banned one has no ban of its own for that screen to describe, so
// there is nothing to let it in for — and letting it in is exactly what the evader
// wants. Checked once here, after the grant has resolved an account, so it covers
// every grant: password, cached_login and a refresh_token redeemed by a client that
// has been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
//
@@ -1007,8 +1012,8 @@ const app = new Hono<App>()
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token refused: player banned', {
if (ban && ban.via !== 'account') {
logger.info('token refused: ban evasion', {
accountId,
grantType,
via: ban.via,
@@ -1016,13 +1021,15 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json(
{
error: 'invalid_grant',
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
},
400
)
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
if (ban) {
logger.info('token issued to banned account', {
accountId,
grantType,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
@@ -1040,8 +1047,9 @@ const app = new Hono<App>()
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
// account serves both; they thus refresh on every login and every refresh_token
// grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
// A refresh grant posts no platform of its own, so the identity comes off the
// account — the same read, and the only place the bound identity is authoritative.
@@ -1056,7 +1064,13 @@ const app = new Hono<App>()
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount),
version
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
// CampusCard and the subscriber discount from this claim alone, so it never has to
// load the account. It therefore refreshes on every login and every refresh_token
// grant, and only then: a player who claims Plus on the website keeps a token that
// says otherwise until they sign in again.
roleAccount?.hasPlus === true
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
+8 -17
View File
@@ -1,6 +1,8 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import { PlatformType } from '@repo/domain/src/enums'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -49,24 +51,13 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
}
/**
* PlatformType, the client's platform enum. Declaration order is wire order, and is
* the single source for the schema and description below. The `platform` form field
* is posted as the integer; the token's `platform` claim carries the name.
* PlatformType, the client's platform enum the single source for the schema and
* description below. It lives in `@repo/domain` rather than here because the link table
* (`platform-db`) and the website's benefits claim both need the values, and neither has
* any business importing this module's zod/hono-openapi dependencies. Re-exported so
* `import { PlatformType } from './openapi'` keeps working alongside the schemas.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
export { PlatformType } from '@repo/domain/src/enums'
/**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified
+53 -2
View File
@@ -19,8 +19,18 @@
* 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.
*
* Not every link is a LOGIN, though. The table is the account's set of external
* identities, and some are stored for what they entitle the player to rather than for
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
* `cached_login` naming platform 101 is refused and the picker must not offer it
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
*/
import { PlatformType } from '@repo/domain/src/enums'
/** 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 (
@@ -64,6 +74,38 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/**
* The platforms a cached login can actually be redeemed for the ones
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
* (a Meta-validated nonce).
*
* This is the picker's filter, and it exists to keep a promise the picker's own API
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
* (both read the same table)". Once the table began holding identities that are NOT
* credentials Discord, from the website's benefits claim listing every row would have
* broken that promise in two ways at once. The client would be offered an account it can
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
* a shared server, so that is a deanonymisation of every player who claimed benefits.
*
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
* happens in the two picker reads only {@link isPlatformIdentityLinked},
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
* every link, because they answer "is this identity taken / whose is it", which is exactly
* the question the benefits claim's once-only guard asks about a Discord id.
*/
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
/**
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
* as ?1 explicit indices rather than bare `?`, which SQLite would number by position
* and quietly renumber the moment another parameter is added ahead of it.
*/
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Asking about a platform nobody can log in from yields nothing at all, rather than a
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
@@ -138,9 +183,15 @@ export async function getLinksForPlatformId(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Matches on any platform a cached login can be redeemed for — but only those. This is
// the route a bare id takes, so without the filter a Discord snowflake posted here
// would resolve its account even though naming platform 101 explicitly would not.
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.prepare(
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
ORDER BY linked_at, account_id`
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.all<LinkRow>()
return results
}
+140 -44
View File
@@ -21,7 +21,9 @@ import {
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import { PlatformType } from '../../openapi'
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
@@ -87,8 +89,8 @@ beforeAll(async () => {
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
@@ -421,6 +423,52 @@ describe('auth worker routes', () => {
])
})
// A Discord link is an external identity, not a credential — `www`'s benefits claim
// writes one so a claimed Discord user can't claim again on a second account. It must
// stay invisible to BOTH picker routes, for two independent reasons:
//
// - Every entry the picker lists is promised to be redeemable by a `cached_login`
// grant, and that grant refuses platform 101 outright (verifyPlatformProof answers
// `unsupported`). Listing one offers the client an account it can never log into.
// - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by
// anyone sharing a server with its owner. Answering here would turn the login picker
// into a lookup from "Discord user" to "their RecFlare account", for every player who
// ever claimed benefits.
//
// The bare-id route is checked too, and it is the easier one to miss: it matches on ANY
// platform, so it would resolve the snowflake even though naming 101 explicitly did not.
test('never lists a Discord link in either cached-login picker', async () => {
const discordId = '308994132968210433'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true }))
.run()
await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId)
// Named explicitly…
const named = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}`
)
expect(named.status).toBe(200)
expect(await named.json()).toEqual([])
// …and via the bare id, which matches across platforms.
const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`)
expect(await bare.json()).toEqual([])
// …and through the bulk friends-resolution route, which takes bare ids only.
const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: discordId }).toString(),
})
expect(await bulk.json()).toEqual([])
// The link is still really there — this is a filtered READ, not a failed write.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
@@ -577,6 +625,9 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
// byte-for-byte what it was before `rn.plus` existed.
expect(payload['rn.plus']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
// subscriber discount from this claim ALONE and never reads the account, so if this
// stops being stamped, Plus silently stops existing for everyone.
//
// It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is
// ours. And it is not a role — the `developer` role does not confer Plus.
test('POST /connect/token stamps rn.plus for a hasPlus account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 93,
username: 'PlusPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
hasPlus: true,
})
)
.run()
const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`)
expect(payload['rn.plus']).toBe(true)
expect(payload.scope).not.toContain('rn.plus')
// Plus is not an elevated role, and does not come with one.
expect(payload.role).not.toContain('developer')
})
// The flag is read at LOGIN, so signing in again is what activates it — the website's
// claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it.
//
// This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as
// `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and
// no `platform_account` link anywhere. An operator must be able to grant Plus outright.
test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 94,
username: 'LateClaimer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
// Before claiming: no Plus.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined()
// The website's claim writes the flag…
await env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94"
).run()
// …and the NEXT token carries it. The one already in the player's hands does not,
// which is exactly why they have to sign in again.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true)
// Nothing linked a Discord identity to this account, and Plus does not care.
const links = await getLinksForAccount(env.DB, 94)
expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([])
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
@@ -1327,38 +1436,35 @@ describe('CORS', () => {
})
})
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
// A banned account is still issued a token: the game client needs one to reach the api
// worker's moderationBlockDetails, which is where the player is shown WHY they are
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
// and is enforced by matchmaking, which refuses every matchmake for a banned player — so
// the token gets them to the block screen and no further.
describe('banned accounts', () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
test('POST /connect/token issues a token to a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
})
test('POST /connect/token refuses a username login from a banned account', async () => {
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
})
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned accounts session', async () => {
// A client that was already signed in when the ban landed refreshes as normal — its
// next matchmake is what refuses it, and moderationBlockDetails says why.
test('POST /connect/token refreshes a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
@@ -1368,13 +1474,12 @@ describe('banned accounts', () => {
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
})
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
// A ban does not loosen the credential check: a wrong password on a banned account is
// the ordinary bad-credential refusal.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
@@ -1384,23 +1489,13 @@ describe('banned accounts', () => {
expect(res.json.error_description).toBe('invalid account_id or password')
})
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
test('a ban that has not expired yet still issues a token', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
})
// A report is not a ban until a moderator converts it.
@@ -1425,8 +1520,10 @@ describe('banned accounts', () => {
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
// signup carrying either is refused before it mints anything. Unlike the banned account
// itself, such an account has no ban of its own for the block screen to describe, so
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
// the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
@@ -1506,7 +1603,7 @@ describe('ban evasion at the token endpoint', () => {
})
// The knob an operator reaches for when the IP arm locks out real players.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
@@ -1524,10 +1621,9 @@ describe('ban evasion at the token endpoint', () => {
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says.
const banned = await login(6320)
expect(banned.status).toBe(400)
expect(banned.json.error_description).toBe('this account is banned')
// The banned account itself signs in whatever the knob says — its ban is
// enforced at matchmake, and the knob only governs the linked arms.
expect((await login(6320)).status).toBe(200)
} finally {
env.BAN_EVASION_MATCH = original
}
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
-- Give a thread its kind. `chat_thread_type` is the client's ChatThreadType enum
-- (0 Player · 1 Club · 2 Party), which 0002 left off because every thread this server
-- served was a plain player conversation and src/thread-db.ts answered a constant 0.
-- Party threads (`POST /thread/party`) are type 2, so the value now varies per row and
-- has to be stored. Generated from src/thread-db.ts (THREAD_SCHEMA_DDL) — keep in sync.
--
-- Defaulted 0 (Player), which is what every existing row is: the column is backfilled by
-- the default, not by an UPDATE.
--
-- No index: it is read alongside the thread row that is already being fetched by primary
-- key or by the membership join, never selected on.
ALTER TABLE message_thread ADD COLUMN chat_thread_type INTEGER NOT NULL DEFAULT 0;
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+321 -20
View File
@@ -16,6 +16,7 @@ import {
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreatePartyChatResponse,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
@@ -23,8 +24,8 @@ import {
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyChatThread,
PartyInviteSettings,
PartyThread,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
@@ -36,11 +37,16 @@ import {
} from './openapi'
import {
addThreadMember,
ChatThreadType,
createThread,
getOrCreateThreadWithMembers,
getThreadForPlayer,
getThreadMemberIds,
getPartyThreadForPlayer,
getThreadMeta,
getThreadsForPlayer,
isThreadMember,
joinedChatContents,
leftChatContents,
markThreadRead,
postMessage,
@@ -54,6 +60,7 @@ import {
import type { Context } from 'hono'
import type { App, Env } from './context'
import type { ChatMessage } from './message-db'
import type { ChatThread } from './thread-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
@@ -107,6 +114,25 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4
*/
const PARTY_INVITE_LIFETIME_MINUTES = 60
/**
* Whether a party is still open to newcomers `GET /thread/party` joins the caller only
* inside this window, measured from the thread's `created_at`.
*
* It is the invite lifetime above, deliberately the same number rather than a second one:
* a player joins a party by holding its id in `LatestPartyChat`, which is what an invite
* puts there, so the join is the redemption of that invite and can't outlive it. A party
* older than the window still belongs to the people already on it this gates JOINING,
* not reading, so nobody's own party expires out from under them.
*
* Fails CLOSED on a `created_at` that won't parse: no timestamp, no join. Nothing writes
* one that can't, and the alternative is an unbounded join window on a corrupt row.
*/
function isPartyJoinable(createdAt: string, now = Date.now()): boolean {
const opened = Date.parse(createdAt)
if (Number.isNaN(opened)) return false
return now - opened <= PARTY_INVITE_LIFETIME_MINUTES * 60_000
}
/**
* Who may start a chat with a player the client's `ChatPrivacy` enum, served numerically
* like every other enum on this build. `Friends` is what a fresh account reports, and what
@@ -134,6 +160,20 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
/**
* Where a player's CURRENT party lives: the thread id of the party they most recently
* opened, written by `POST /thread/party` and read back by the GET on the same path.
*
* It is a player setting rather than a column because the party is a property of the
* PLAYER, not of the thread "which party am I in" has one answer per person, and a
* player is in exactly one at a time. The settings bag is already read and written per
* player here, the same way the two privacy settings are.
*
* Nothing clears it: a party the player has left, or one that no longer exists, is
* filtered out on the read instead, which also covers an id written by something else.
*/
const LATEST_PARTY_CHAT_KEY = 'LatestPartyChat'
/**
* A `ChatPrivacy` out of whatever was stored or posted the member name as the client
* sends it (case-insensitively), or the ordinal as the GET serves it, since a value that
@@ -200,16 +240,87 @@ async function writeChatPrivacy(
accountId: number,
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
const patch: Record<string, string> = {}
for (const [key, value] of Object.entries(settings)) {
if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value]
if (value !== undefined) patch[key] = CHAT_PRIVACY_NAMES[value]
}
await mergePlayerSettings(env, accountId, patch)
}
/**
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
* tutorial mask, ) and storing one key on its own would wipe the rest. Values are strings,
* which is what that worker stores and what its GET serves back.
*/
async function mergePlayerSettings(
env: Env,
accountId: number,
patch: Record<string, string>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
}
/**
* The thread id in the player's `LatestPartyChat` setting, or null when they have no party
* nothing stored, or something stored that isn't a positive integer (the bag's values are
* strings, and this one could have been written by hand).
*/
async function readLatestPartyChatId(env: Env, accountId: number): Promise<number | null> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
const id = Number.parseInt(String(stored[LATEST_PARTY_CHAT_KEY] ?? ''), 10)
return Number.isNaN(id) || id <= 0 ? null : id
}
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push a thread's most recent message to everyone on it. This is how a NEW thread
* announces itself.
*
* The client has exactly two chat channels, `ChatMessageReceived` and `PlayerLeftChat`,
* and both carry a MESSAGE: there is no "a thread was opened" or "you were added" frame to
* send. So a conversation someone gains access to stays invisible on their client until a
* message arrives on it which is why `/thread/withmembers` used to go unnoticed until the
* sender typed something, the thread having been created with only its "started a chat"
* notice and that notice never having left the database.
*
* Sending the notice fixes that without inventing anything: the message being pushed is one
* that genuinely exists on the thread. A no-op for a thread with nothing in it.
*/
async function pushThreadLatestMessage(c: Context<App>, chatThreadId: number): Promise<void> {
const [latest] = await getThreadMessages(c.env.DB, chatThreadId, { limit: 1 })
if (latest === undefined) return
await pushChatMessage(c, latest)
}
/**
* Announce that a player is now on a thread: post the `Player <@U…> joined` notice and push
* it to the whole thread.
*
* The exact shape of the leave route's goodbye, and for the same reasons. Everyone is told,
* not just the player who joined: a roster change is the thread's business the others
* need to know who they are talking to and there is no roster channel to say it on, so
* the notice is the message AND the signal. The new member is a member by the time this
* runs, so the same push is what puts the conversation on their screen.
*
* Call it AFTER the membership row exists, or the joiner is left out of the fan-out.
*/
async function announceJoin(
c: Context<App>,
chatThreadId: number,
playerId: number
): Promise<void> {
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(playerId),
})
await pushChatMessage(c, notice)
}
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
@@ -303,6 +414,33 @@ function toSentChatMessage(message: ChatMessage) {
}
}
/**
* A thread in the PascalCase shape `POST /thread/party` answers the client's
* CreatePartyChat formatter, which is its own and reads none of the camelCase keys the
* thread payloads carry. Ten wire keys; see {@link PartyChatThread} for why the CLR
* type's other three never appear.
*
* `Messages` and `LatestMessage` are both present here, unlike the camelCase pair which
* carries one or the other, and `ChatThreadName` goes out NULL when unnamed rather than
* as the empty string the camelCase projections must send.
*/
function toPartyChatThread(thread: ChatThread, messages: ChatMessage[]) {
return {
ChatThreadId: thread.chatThreadId,
ChatThreadType: thread.chatThreadType,
LastReadMessageId: thread.lastReadMessageId,
Messages: messages.map(toSentChatMessage),
LatestMessage: thread.latestMessage === null ? null : toSentChatMessage(thread.latestMessage),
PlayerIds: thread.playerIds,
// This formatter takes the null; only the camelCase projections have to send ''.
ChatThreadName: thread.chatThreadName === '' ? null : thread.chatThreadName,
SnoozedUntil: thread.snoozedUntil,
IsFavorited: thread.isFavorited,
// No thread on this table carries a club — club chat lives in the `clubs` worker.
ClubId: null,
}
}
/**
* Send a message to a thread that already exists every message after the one that
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
@@ -607,7 +745,12 @@ const app = new Hono<App>()
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// A thread nobody has been told about is a thread nobody sees. Push its opening
// notice the moment it exists, rather than leaving the conversation to surface on
// whatever message happens to follow — there may not be one: this route is also
// called with an empty `messageContents`.
if (created) await pushThreadLatestMessage(c, chatThreadId)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
@@ -663,21 +806,74 @@ const app = new Hono<App>()
}
)
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
// unknown — it hasn't been observed off a live client — so this answers an empty
// object, which parses as "no party" rather than failing the client's deserializer the
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
// Replace the body once the real shape is captured.
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
// GetPartyChat.
//
// TWO paths, cheapest first:
//
// 1. ALREADY IN A PARTY — one D1 query (`getPartyThreadForPlayer`: the membership join,
// filtered to party threads, newest first) answers it outright. This is the common
// case, every read after the first, and it touches the settings KV not at all.
// 2. NOT IN ONE YET — only then is the caller's `LatestPartyChat` player setting read.
// The POST below writes that key for the player who OPENED the party; the client
// writes it (through `playersettings`) for a player pulled into someone else's. Such
// a player holds the key but no membership row — nothing has added them — so a
// membership-only read answered them "no party", which is the bug the join fixes.
// The read puts them on the thread and then serves it: `GET /thread/party` is how
// you enter a party, not merely how you look at one.
//
// Reaching path 2 means the caller is in NO party, so they cannot already be a member of
// the thread the key names — which is why the join here needs no membership check of its
// own, and why the age gate below can be unconditional.
//
// On path 2 the thread is checked to exist, to be a party, and to be YOUNGER THAN THE
// INVITE LIFETIME before anyone is added to it, so a key naming a DM, a thread that is
// gone, or an hours-old party can't produce a membership row in it.
//
// The age check gates JOINING only — path 1 never reaches it, so a party keeps being
// served to the players already on it however old it is, rather than going dark on them
// after an hour. `LatestPartyChat` is the caller's own player setting, which the client
// can PUT to anything through the `playersettings` worker, so the key IS the invite here
// and the window is what keeps it from being a permanent one. Tighten further (an invite
// the party actually issued) if parties ever need to be closed outright.
//
// SAME PATH, DIFFERENT BODY from the POST: this one is the BARE thread, with no
// `{ ChatThread, ChatResult }` wrapper around it. Same ten-key PascalCase projection
// inside, so the two share `toPartyChatThread` — but nothing else, which is why these
// are two handlers rather than one verb-agnostic one.
//
// No party answers `{}`: the client parses that as a thread with everything at its
// default, which reads as "no party", where a 404 or a null body would fail its
// deserializer.
//
// `maxCount` and `mode` are accepted and ignored. `maxCount=1` is most likely the
// number of party chats wanted, which is already what a single-thread body serves; if
// it turns out to size `Messages` instead, ignoring it only ever serves MORE history
// than asked for, where guessing wrong the other way would truncate the party's
// messages to one. `mode` is unknown.
.get(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'The callers party thread (stub)',
summary: 'The callers current party thread (GetPartyChat)',
description: [
'STUB — the response shape has not been observed off a live client, so this answers an',
'empty object `{}`, which parses as "no party" rather than failing the clients',
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
'ignored. Replace the body once the real shape is captured.',
'The party the caller is currently in: the newest party thread they are a member of,',
'answered from a single query. Failing that, the thread named by their own',
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
'who opened the party and the client writes for a player who joins someone elses.',
'',
'That second path JOINS: a caller who is not on any party yet is ADDED to the thread',
'their key names and then served it, which is how a player pulled into someone elses',
'party enters it — they hold the key but no membership row, and a membership-only read',
'answers them "no party". The thread must exist, be a party, and be younger than the',
'60-minute invite lifetime before anyone is added, so a key naming a DM, a deleted',
'thread or a stale party answers `{}` and writes nothing. The age gate is on JOINING',
'only — a player already on a party is served it however old it is. A join posts a',
'"Player <@U…> joined" notice and pushes it to the party, so the people already in it',
'see who arrived.',
'',
'The BARE thread, unlike the POST on the same path, which wraps the same projection in',
'`{ ChatThread, ChatResult }`. `maxCount` and `mode` are accepted and ignored.',
].join(' '),
security: AUTHED,
parameters: [
@@ -685,26 +881,121 @@ const app = new Hono<App>()
name: 'maxCount',
in: 'query',
required: false,
description: 'Page size the client sends (1). Ignored by the stub',
description: 'Page size the client sends (1). Accepted and ignored',
schema: { type: 'integer' },
},
{
name: 'mode',
in: 'query',
required: false,
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
description: 'Unknown mode selector the client sends (0). Accepted and ignored',
schema: { type: 'integer' },
},
],
responses: {
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
200: json(PartyChatThread, 'The callers party, or `{}` when they have none'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({})
// Path 1: already in a party. One query, no settings read.
let thread = await getPartyThreadForPlayer(c.env.DB, id)
// Path 2: not in one — the key is the only thing that can name a party to join.
if (thread === null) {
const chatThreadId = await readLatestPartyChatId(c.env, id)
if (chatThreadId === null) return c.json({})
// Checked BEFORE the join: a gone thread, one of some other type, or a party
// too old to still be taking people isn't something to put anybody on —
// whoever wrote the key.
const meta = await getThreadMeta(c.env.DB, chatThreadId)
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
if (!isPartyJoinable(meta.createdAt)) return c.json({})
await addThreadMember(c.env.DB, chatThreadId, id)
// The same announcement the add-member route makes: the party learns someone
// walked in. It is also usually a party's FIRST message — one opens empty.
await announceJoin(c, chatThreadId, id)
thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after join`)
}
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json(toPartyChatThread(thread, messages))
}
)
// Open a party — the client's CreatePartyChat. A thread of type 2
// (`ChatThreadType.Party`) holding only the caller, which the client then fills by
// inviting people onto it (`POST /thread/{id}/member/{playerId}`). The one place a
// thread is opened with a single member: every other create refuses a roster of just
// yourself, because a DM with nobody in it is a mistake, whereas a party you are so far
// the only member of is exactly what starting one looks like.
//
// Takes NO query params and NO body — the client posts to the bare path.
//
// Always a NEW party, never a fetch-or-create: a party is a session, not a standing
// conversation with a set of people, so resolving to the one you left this morning
// would hand the invitees its history.
//
// It opens EMPTY — no system "started a chat" notice, unlike every other new thread
// here. The observed response carries `Messages: []` with a null `LatestMessage`, so a
// notice would be a message the reference doesn't post.
//
// The body is the PascalCase `{ ChatThread, ChatResult }` wrapper, bare — no
// `{ success, error, value }` envelope — and the thread inside it is its own
// projection: ten keys, both `Messages` and `LatestMessage`, a null `ChatThreadName`,
// and a `ClubId` that exists nowhere else. See `toPartyChatThread`.
.post(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'Open a party thread for the caller (CreatePartyChat)',
description: [
'The clients CreatePartyChat. Opens a thread of type 2 (Party) whose only member is',
'the caller — the client fills it by inviting players on afterwards. No query params',
'and no body. Always a new party, never a fetch-or-create: a party is a session rather',
'than a standing conversation, so an old one would hand the invitees its history. The',
'only create that accepts a roster of just the caller, and the only one that opens with',
'no messages at all — no “started a chat” notice, matching the observed',
'`Messages: []`. Records the new thread as the callers `LatestPartyChat` player',
'setting, which is where `GET /thread/party` looks for it. Answers the bare PascalCase',
'`{ ChatThread, ChatResult }` wrapper, whose thread is a projection of its own — not the',
'camelCase shape the other thread routes serve.',
].join(' '),
security: AUTHED,
responses: {
200: json(CreatePartyChatResponse, 'The new party thread, empty, with ChatResult 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = await createThread(c.env.DB, [id], null, undefined, ChatThreadType.Party)
// This is what makes the party findable: the GET resolves the caller's current
// party through this key and nothing else. Written before the response, so a
// client that opens the party and immediately re-reads it can't miss it.
await mergePlayerSettings(c.env, id, { [LATEST_PARTY_CHAT_KEY]: String(chatThreadId) })
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after creation`)
// Read the messages back rather than assuming []: the party is empty as it is
// created, but the projection shouldn't be the thing that says so.
const messages = await getThreadMessages(c.env.DB, chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json({
ChatThread: toPartyChatThread(thread, messages),
ChatResult: CHAT_SUCCESS,
})
}
)
@@ -887,7 +1178,11 @@ const app = new Hono<App>()
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// The reported bug: this opened the thread silently, so the other player saw
// nothing until the first message landed. The opening notice is what tells them.
if (created) await pushThreadLatestMessage(c, chatThreadId)
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
@@ -1102,7 +1397,10 @@ const app = new Hono<App>()
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
'Idempotent — re-adding an existing member changes nothing. On success a',
'"Player <@U…> joined" system notice is posted and pushed to the whole thread: the',
'existing members because the roster changed, the new one because that push is what',
'puts the conversation on their screen.',
].join(' '),
{
parameters: [
@@ -1132,6 +1430,9 @@ const app = new Hono<App>()
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
// Everyone hears about it — the existing members because the roster changed under
// them, the new one because this is what puts the conversation on their screen.
await announceJoin(c, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
+52 -5
View File
@@ -86,7 +86,9 @@ const threadBase = {
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
chatThreadType: z
.int()
.describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
@@ -233,11 +235,56 @@ export const ChatPrivacySettings = z.object({
})
/**
* `GET /thread/party` STUB. The real shape hasn't been observed off a live client, so
* the route answers an empty object and this schema says so rather than guessing at
* fields. Fill both in together once the real response is captured.
* A thread in the PascalCase spelling the two `/thread/party` routes serve the client's
* CreatePartyChat and GetPartyChat the THIRD projection of a thread in this worker, and deliberately not
* unified with the two camelCase ones ({@link ChatThreadDto}, {@link
* ChatThreadWithMessagesDto}): the client has a separate formatter for this response, and
* a camelCase body decodes to a thread with every field at its default.
*
* Ten wire keys, off the client's own formatter. Its CLR type declares thirteen fields:
* two are `[IgnoreDataMember]` and one is a plain field rather than an auto-property, so
* none of the three ever serialises don't add them back.
*
* Differences from the camelCase DTOs beyond the casing:
* - `Messages` and `LatestMessage` are BOTH present, where the camelCase pair carries one
* or the other. A party opens empty, so they come back `[]` and null.
* - `ChatThreadName` is NULL for an unnamed thread, not the empty string the camelCase
* projections have to send (the client dereferences that one unchecked; this formatter
* takes the null).
* - `ClubId` exists only here null for a party, and for everything this worker serves:
* club chat lives in the `clubs` worker and nothing on this table carries a club.
*
* `GET /thread/party` serves this BARE; the POST wraps it in {@link
* CreatePartyChatResponse}. The GET also answers `{}` for a caller with no party, which
* decodes to a thread with every field at its default the client reads that as no party,
* where a 404 or a null body would fail its deserializer.
*/
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
export const PartyChatThread = z.object({
ChatThreadId: z.int(),
ChatThreadType: z.int().describe('The ChatThreadType enum: 0 Player · 1 Club · 2 Party'),
LastReadMessageId: z.int().describe('0 for a party that was just opened'),
Messages: z
.array(SentChatMessage)
.describe('Empty for a party just opened — nothing is posted into it'),
LatestMessage: SentChatMessage.nullable().describe('Null while the thread has no messages'),
PlayerIds: z.array(z.int()).describe('Just the caller, until players are invited on'),
ChatThreadName: z.string().nullable().describe('NULL when unnamed — not the empty string'),
SnoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
IsFavorited: z.boolean(),
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
})
/**
* `POST /thread/party` the client's CreatePartyChat. A bare two-key wrapper, PascalCase
* like the thread inside it, with no `{ success, error, value }` envelope around it.
*
* `ChatResult` is the same twenty-member enum {@link ChatResult} records, served
* numerically; the create either works or fails the request, so it is always 0 here.
*/
export const CreatePartyChatResponse = z.object({
ChatThread: PartyChatThread,
ChatResult: ChatResult,
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
+588 -5
View File
@@ -12,7 +12,10 @@ import {
SCHEMA_DDL,
} from '../../message-db'
import {
addThreadMember,
ChatThreadType,
createThread,
joinedChatContents,
findThreadWithMembers,
getThreadForPlayer,
getThreadsForPlayer,
@@ -394,8 +397,107 @@ describe('GET /settings/partyinvite', () => {
})
})
// The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat`
// player setting — as the BARE thread, where the POST wraps the same projection.
describe('GET /thread/party', () => {
it('answers an empty object', async () => {
async function settings(playerId: number): Promise<Record<string, string>> {
return (
(await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${playerId}`,
'json'
)) ?? {}
)
}
it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => {
const caller = 883201
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(caller),
})
expect(res.status).toBe(200)
// The whole body is the thread: no `ChatThread`/`ChatResult` keys around it.
expect(await res.json()).toEqual({
ChatThreadId: ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
})
})
it('POST records the thread id in the callers LatestPartyChat setting', async () => {
const caller = 883202
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ OobeState: 'Complete' })
)
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
// Merged, not overwritten: the bag holds every other setting the player has.
expect(await settings(caller)).toEqual({
OobeState: 'Complete',
LatestPartyChat: String(ChatThread.ChatThreadId),
})
})
it('follows the setting to the newest party after a second one is opened', async () => {
const caller = 883203
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as { ChatThreadId: number }
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
})
it('carries the partys members and messages once it has them', async () => {
const caller = 883204
const guest = 883205
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, guest)
const posted = await postMessage(env.DB, {
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: caller,
contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as {
PlayerIds: number[]
Messages: Array<{ ChatMessageId: number; Contents: string }>
LatestMessage: { ChatMessageId: number } | null
}
expect(body.PlayerIds).toEqual([caller, guest])
// PascalCase messages here too — the camelCase spelling is the other projections'.
expect(body.Messages).toHaveLength(1)
expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId)
expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId)
})
it('answers {} for a player who has never opened one', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(883001),
})
@@ -403,12 +505,339 @@ describe('GET /thread/party', () => {
expect(await res.json()).toEqual({})
})
it('JOINS a caller who holds the key but isnt on the thread yet', async () => {
// How a player enters someone else's party: the client points their
// LatestPartyChat at it, and they have no membership row until this read.
const host = 883210
const guest = 883211
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] }
// They're on the thread now, and the body they get back says so.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true)
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
expect(body.PlayerIds).toEqual([host, guest])
// The host sees them too — one thread, one roster.
expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([
host,
guest,
])
})
it('re-joining is a no-op — the roster doesnt grow on every read', async () => {
const caller = 883212
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
for (let i = 0; i < 3; i++) {
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
}
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller])
})
it('re-joins a caller who left, while their key still names the party', async () => {
// Leaving doesn't clear the key, so the next read walks them back in. Ending a
// party is what drops the key (`match`'s POST /player/logout).
const caller = 883206
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true)
})
it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => {
// The fast path: a player already on a party is answered from the membership join,
// so the settings KV is never read. Proven by deleting the key the POST wrote.
const caller = 883230
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('serves the NEWEST party when the caller is a member of several', async () => {
// Membership outlives a party — nothing removes the row when one ends — so the
// party you are in is the most recent one you are on.
const caller = 883231
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('prefers the party the caller is ON over one their key merely names', async () => {
// The consequence of checking D1 first: while a membership row survives, a key
// pointing somewhere else is not consulted. Switching parties means leaving the old
// one (DELETE /thread/{id}/leave), not just repointing the key.
const caller = 883232
const host = 883233
const mine = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const other = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } }
const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
own.ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false)
})
/** Age a party by rewriting its `created_at` — what the join window is measured from. */
async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise<void> {
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString())
.run()
}
it('refuses to join a party older than the 60-minute invite lifetime', async () => {
const host = 883220
const latecomer = 883221
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 61)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${latecomer}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) })
expect(await res.json()).toEqual({})
// Refused, not quietly joined: no membership row was written.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false)
})
it('still joins a party inside the window', async () => {
const host = 883222
const guest = 883223
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 59)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest])
})
it('keeps serving an aged party to the players already on it', async () => {
// The window gates JOINING only — a party doesn't go dark on its own members.
const host = 883224
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 240)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('refuses to join a party whose created_at wont parse', async () => {
// Fails closed: no timestamp, no join.
const host = 883225
const stranger = 883226
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(ChatThread.ChatThreadId, 'not-a-timestamp')
.run()
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${stranger}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false)
})
it('answers {} when the setting names a thread that isnt a party, joining nobody', async () => {
// The type check runs BEFORE the join, so a key pointed at someone else's DM can't
// put the caller in it.
const caller = 883207
const dm = await createThread(env.DB, [883208, 883213])
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(dm) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, dm, caller)).toBe(false)
})
it('answers {} when the setting names a thread that doesnt exist', async () => {
const caller = 883214
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: '99999' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, 99999, caller)).toBe(false)
})
it('answers {} for an unparseable stored id', async () => {
const caller = 883209
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: 'not-an-id' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
expect(res.status).toBe(401)
})
})
// The POST on the same path is not a stub: it opens a real thread, of type Party, with
// only the caller on it — and answers the client's own PascalCase CreatePartyChat shape,
// which is neither of the two camelCase thread projections.
describe('POST /thread/party', () => {
it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => {
const caller = 883101
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number }
// Every wire key, exactly — a party opens empty, unnamed and with no club, and the
// client reads none of the camelCase spellings the other thread routes serve.
expect(body).toEqual({
ChatThread: {
ChatThreadId: body.ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
},
ChatResult: 0,
})
// And it's a real thread: it reads back through the normal thread routes.
const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller)
expect(stored?.chatThreadType).toBe(ChatThreadType.Party)
expect(stored?.playerIds).toEqual([caller])
})
it('posts no “started a chat” notice into the party', async () => {
const caller = 883105
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([])
})
it('opens a NEW party every call rather than resolving to the last one', async () => {
const caller = 883102
const first = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const a = (await first.json()) as { ChatThread: { ChatThreadId: number } }
const b = (await second.json()) as { ChatThread: { ChatThreadId: number } }
expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId)
})
it('keeps party threads out of the DM fetch-or-create', async () => {
// A party the caller invited someone onto has the same roster as their DM would.
const caller = 883103
const other = 883104
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, other)
const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ids: String(other) }),
})
const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number }
expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId)
expect(opened.chatThreadType).toBe(ChatThreadType.Player)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' })
expect(res.status).toBe(401)
})
})
describe('GET /thread/chatPrivacySetting', () => {
it('reports Friends for both settings by default, keyed to the caller', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
@@ -991,11 +1420,28 @@ describe('ChatMessageReceived push', () => {
}
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
// TWO waves over one channel, because that channel is all the client has: the
// thread's opening notice, which is what makes the new conversation appear at all,
// and then the message itself. Each goes to all three members.
const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5)
const message = sent.filter(
(n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller
)
for (const wave of [notice, message]) {
expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
}
expect(notice[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId: chatThread.chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
expect(message[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
senderPlayerId: caller,
@@ -1005,8 +1451,144 @@ describe('ChatMessageReceived push', () => {
})
})
it('pushes nothing when there is no message to push', async () => {
await send(886004, 'ids=886005&messageContents=')
it('pushes the opening notice even when no message is sent', async () => {
// The thread is real whether or not anything was said in it, and a thread nobody
// was told about is one nobody sees — the client has no "thread opened" channel.
const caller = 886004
await send(caller, 'ids=886005&messageContents=')
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005])
expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID)
expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller))
})
it('pushes nothing when the thread already existed and nothing was said', async () => {
// Second call on the same pair: no new thread, no message — nothing to announce.
await send(886006, 'ids=886007&messageContents=')
await hub.getByName('global').takeSent()
await send(886006, 'ids=886007&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
async function withMembers(caller: number, body: string): Promise<Response> {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('POST /thread/withmembers announces the thread it opens', async () => {
// The reported bug: this opened the conversation silently, so the other player saw
// nothing until the first message arrived.
const caller = 886010
const other = 886011
const res = await withMembers(caller, `ids=${other}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
})
it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => {
// Fetch-or-create: the second call opens nothing, so there is nothing to announce
// — re-announcing would ping both players every time the screen is opened.
const caller = 886012
await withMembers(caller, 'ids=886013')
await hub.getByName('global').takeSent()
await withMembers(caller, 'ids=886013')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
it('adding a member announces the join to the WHOLE thread', async () => {
const caller = 886014
const existing = 886015
const added = 886016
const res = await withMembers(caller, `ids=${existing}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, {
method: 'POST',
headers: await bearer(caller),
})
// Everyone: the two who were already there because the roster changed under them,
// and the new member because this is what puts the thread on their screen.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toMatchObject({
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(added),
})
// The notice is a real message on the thread, not just a frame — the mirror of the
// "left" one, and it is the thread's newest.
const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 })
expect(newest?.contents).toBe(joinedChatContents(added))
})
it('joining a party announces it to the party', async () => {
const host = 886017
const guest = 886018
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
// The host hears about it too — that is the point of announcing a join.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest])
expect(sent[0]!.data).toMatchObject({
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(guest),
})
})
it('announces a party join once, not on every subsequent read', async () => {
// The join happens once; reading your own party afterwards is not a roster change.
const host = 886019
const guest = 886020
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
expect(await hub.getByName('global').takeSent()).toEqual([])
})
})
@@ -1629,6 +2211,7 @@ describe('openapi', () => {
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/party',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
+145 -52
View File
@@ -31,6 +31,7 @@ export const THREAD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_name TEXT,
chat_thread_type INTEGER NOT NULL DEFAULT 0,
latest_message_id INTEGER,
created_at TEXT NOT NULL
)`,
@@ -70,23 +71,36 @@ export interface ChatThread {
*/
chatThreadName: string
/**
* Which kind of conversation this is. Every thread the reference serves here comes back
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant
* but the field itself has to be present: the client deserializes it as a non-nullable
* int and drops the whole response when it's missing.
* Which kind of conversation this is the client's `ChatThreadType`: 0 Player,
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
* the two. The field has to be present whatever its value: the client deserializes it
* as a non-nullable int and drops the whole response when it's missing.
*/
chatThreadType: number
chatThreadType: ChatThreadTypeValue
snoozedUntil: string | null
isFavorited: boolean
}
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
const CHAT_THREAD_TYPE_DEFAULT = 0
/**
* The client's `ChatThreadType` enum, stored on the thread and served numerically like
* every other enum on this build. A plain conversation DM or group is `Player`;
* `Party` is what `POST /thread/party` opens. Nothing here serves `Club` yet: club chat
* lives in the `clubs` worker, and the member is here so a stored 1 renders as itself
* rather than being read as a player thread.
*/
export const ChatThreadType = {
Player: 0,
Club: 1,
Party: 2,
} as const
export type ChatThreadTypeValue = (typeof ChatThreadType)[keyof typeof ChatThreadType]
/** The joined row backing a rendered thread, before it's shaped for the client. */
interface ThreadRow {
chat_thread_id: number
chat_thread_name: string | null
chat_thread_type: number
player_ids: string | null
last_read_message_id: number | null
snoozed_until: string | null
@@ -119,30 +133,24 @@ function toThread(row: ThreadRow): ChatThread {
lastReadMessageId: row.last_read_message_id ?? 0,
// Null in the column means "unnamed"; the client dereferences it unchecked.
chatThreadName: row.chat_thread_name ?? '',
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
snoozedUntil: row.snoozed_until,
isFavorited: row.is_favorited !== 0,
}
}
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Reads only threads the player is a member of, so the membership join is the
* authorization check as well as the query. The inner ordered subquery around
* The shared projection behind every rendered thread the list, the single read and the
* party read. Membership LEADS the join, so it is the authorization check as well as the
* query: a row only exists for a thread `me` is in. The inner ordered subquery around
* group_concat is what makes `playerIds` come back sorted rather than in row order.
*
* Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`SELECT
const THREAD_SELECT = `SELECT
t.chat_thread_id,
t.chat_thread_name,
t.chat_thread_type,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
@@ -157,7 +165,22 @@ export async function getThreadsForPlayer(
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id`
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Membership scopes the query see {@link THREAD_SELECT}.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`${THREAD_SELECT}
WHERE me.player_id = ?1
ORDER BY t.latest_message_id DESC
LIMIT ?2`
@@ -175,24 +198,7 @@ export async function getThreadForPlayer(
): Promise<ChatThread | null> {
const row = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
`${THREAD_SELECT}
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
)
.bind(chatThreadId, playerId)
@@ -200,6 +206,65 @@ export async function getThreadForPlayer(
return row === null ? null : toThread(row)
}
/**
* The party a player is already in, or null the newest party thread carrying a
* membership row for them, rendered exactly as {@link getThreadForPlayer} renders one.
*
* The fast path behind `GET /thread/party`: a player already on a party is answered from
* ONE D1 query, with no player-settings read at all. `LatestPartyChat` is consulted only
* when this comes back null that is, only for a player who has yet to JOIN a party.
*
* Newest first (by thread id, which is monotonic) because a player can hold membership in
* parties they never formally left: the one they are in is the most recent one they are on.
*/
export async function getPartyThreadForPlayer(
db: D1Database,
playerId: number
): Promise<ChatThread | null> {
const row = await db
.prepare(
`${THREAD_SELECT}
WHERE me.player_id = ?1 AND t.chat_thread_type = ?2
ORDER BY t.chat_thread_id DESC
LIMIT 1`
)
.bind(playerId, ChatThreadType.Party)
.first<ThreadRow>()
return row === null ? null : toThread(row)
}
/** What a thread IS, without any of what's in it. See {@link getThreadMeta}. */
export interface ThreadMeta {
chatThreadType: ChatThreadTypeValue
/** ISO-8601 UTC, as `created_at` stores it. */
createdAt: string
}
/**
* A thread's kind and age, or null when there is no such thread the one read here that
* does NOT go through membership.
*
* It exists for the party join (`GET /thread/party`), which has to know a thread is real,
* is a party, and is still young enough to join BEFORE it puts the caller on it; every
* other read is membership-scoped, and a caller joining a party is by definition not a
* member yet. It answers these two fields and nothing else no name, no roster, no
* messages precisely so it can't become a way to read a thread you aren't in.
*/
export async function getThreadMeta(
db: D1Database,
chatThreadId: number
): Promise<ThreadMeta | null> {
const row = await db
.prepare(
'SELECT chat_thread_type, created_at FROM message_thread WHERE chat_thread_id = ?1'
)
.bind(chatThreadId)
.first<{ chat_thread_type: number; created_at: string }>()
return row === null
? null
: { chatThreadType: row.chat_thread_type as ChatThreadTypeValue, createdAt: row.created_at }
}
/**
* Whether a player may read or post to a thread. Every thread-scoped route gates on
* this before touching messages.
@@ -243,6 +308,19 @@ export function leftChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
}
/**
* The counterpart notice when someone is pulled onto a thread or walks into a party:
* `Player <@U14922080> joined`. Same `<@U…>` mention token as the other two.
*
* It carries the roster change as a MESSAGE because that is the only way to carry one: the
* client has no join/leave channel, only `ChatMessageReceived` and `PlayerLeftChat`, both
* of which take a message. So the notice is both what the thread shows and what tells
* everyone the new member's client included that the roster moved.
*/
export function joinedChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> joined` })
}
/**
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
* list rather than a blank title.
@@ -269,19 +347,24 @@ export async function setThreadName(
*
* Every call opens a *distinct* thread, even for a member set that already has one
* threads are not keyed by their membership, and the same pair may hold several.
*
* `type` is the thread's kind and defaults to `Player`, which covers DMs and group chats
* alike; a party opens as `Party`.
*/
export async function createThread(
db: D1Database,
playerIds: number[],
name: string | null = null,
startedBy?: number
startedBy?: number,
type: ChatThreadTypeValue = ChatThreadType.Player
): Promise<number> {
const row = await db
.prepare(
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
`INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at)
VALUES (?1, ?2, ?3)
RETURNING chat_thread_id`
)
.bind(name, new Date().toISOString())
.bind(name, type, new Date().toISOString())
.first<{ chat_thread_id: number }>()
if (row === null) throw new Error('failed to create chat thread')
@@ -322,27 +405,33 @@ export async function createThread(
* thread is gone are ignored rather than resolved to: matching one would hand back an id
* that nothing else in the worker can render, and since the oldest match wins it
* would keep winning on every subsequent call.
*
* Matching is also scoped to one `type`: a party whose roster happens to be the people
* you are opening a DM with is a different conversation, and handing it back would drop
* the DM into the party.
*/
export async function findThreadWithMembers(
db: D1Database,
playerIds: number[]
playerIds: number[],
type: ChatThreadTypeValue = ChatThreadType.Player
): Promise<number | null> {
const members = [...new Set(playerIds)]
if (members.length === 0) return null
// ?1 is the member count; ?2… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
const row = await db
.prepare(
`SELECT m.chat_thread_id FROM thread_member m
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
WHERE t.chat_thread_type = ?2
GROUP BY m.chat_thread_id
HAVING COUNT(*) = ?1
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
ORDER BY m.chat_thread_id
LIMIT 1`
)
.bind(members.length, ...members)
.bind(members.length, type, ...members)
.first<{ chat_thread_id: number }>()
return row?.chat_thread_id ?? null
}
@@ -352,16 +441,20 @@ export async function findThreadWithMembers(
* simultaneous first-messages to the same set can still race into two threads; the
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
* them afterwards.
*
* `created` says which happened. The caller needs it: a thread that was just opened has to
* be PUSHED to its members, or it sits on the server unseen until somebody posts to it
* the client has no "you were added to a thread" channel, so the opening notice going out
* over the socket is the only thing that makes a new conversation appear.
*/
export async function getOrCreateThreadWithMembers(
db: D1Database,
playerIds: number[],
startedBy: number
): Promise<number> {
return (
(await findThreadWithMembers(db, playerIds)) ??
(await createThread(db, playerIds, null, startedBy))
)
): Promise<{ chatThreadId: number; created: boolean }> {
const existing = await findThreadWithMembers(db, playerIds)
if (existing !== null) return { chatThreadId: existing, created: false }
return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true }
}
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+3
View File
@@ -5,6 +5,9 @@
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
"upload_source_maps": true,
"cache": {
"enabled": true
},
"observability": {
"logs": {
"enabled": true,
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+24 -39
View File
@@ -14,13 +14,15 @@ const PAGE_SOURCES = [
'StoreConsumables',
]
interface Section {
id: string
sectionType: number
sectionSubType: string
source: string
sourceMetadata: string | null
displayMetadata: string | null
/**
* A section as published. The reference captures are camelCase and the hand-authored
* store pages PascalCase; the client's decoder is case-insensitive, so both are served
* as-is and the tests read either spelling.
*/
type Section = Record<string, unknown>
function field(section: Section, name: string): unknown {
return section[name] ?? section[name[0].toUpperCase() + name.slice(1)]
}
/** Fetch a page source and return its parsed body. */
@@ -44,16 +46,17 @@ describe('GET /sections/pagesource/:type', () => {
// is reachable without the worker knowing its name.
it.each(PAGE_SOURCES)('serves %s', async (type) => {
const sections = await pageSource(type)
expect(sections.length).toBeGreaterThan(0)
expect(Array.isArray(sections)).toBe(true)
for (const section of sections) {
expect(typeof section.id).toBe('string')
expect(typeof section.sectionType).toBe('number')
expect(typeof section.source).toBe('string')
expect(typeof field(section, 'id')).toBe('string')
expect(typeof field(section, 'sectionType')).toBe('number')
expect(typeof field(section, 'source')).toBe('string')
// An embedded JSON *string* the client parses itself, not an object — or null,
// which several store and play-highlight sections use.
if (section.displayMetadata !== null) {
expect(typeof section.displayMetadata).toBe('string')
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
const display = field(section, 'displayMetadata')
if (display !== null && display !== undefined) {
expect(typeof display).toBe('string')
expect(() => JSON.parse(display as string)).not.toThrow()
}
}
})
@@ -81,35 +84,17 @@ describe('GET /sections/pagesource/:type', () => {
// that this builder would drop — that is the reference's data, not a mistake to fix here.
it('StoreCategories only carries sections the store page builder keeps', async () => {
for (const section of await pageSource('StoreCategories')) {
expect([4, 13]).toContain(section.sectionType)
expect(section.displayMetadata).toBeTruthy()
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
if (section.sectionType === 13) {
expect(['CuratedList', 'PageSource']).toContain(section.source)
expect(section.sourceMetadata).toBeTruthy()
expect([4, 13]).toContain(field(section, 'sectionType'))
const display = field(section, 'displayMetadata')
expect(display).toBeTruthy()
expect(() => JSON.parse(display as string)).not.toThrow()
if (field(section, 'sectionType') === 13) {
expect(['CuratedList', 'PageSource']).toContain(field(section, 'source'))
expect(field(section, 'sourceMetadata')).toBeTruthy()
}
}
})
it('serves the StoreCategories page', async () => {
const sections = await pageSource('StoreCategories')
expect(sections[0]).toEqual({
id: 'store-featured',
// StoreItemsSection: a store CATEGORY is drawn as the product carousel.
sectionType: 4,
sectionSubType: 'StoreCategory_Featured',
source: 'CuratedList',
// The curated list the `lists` worker serves from /curatedlists/bulk.
sourceMetadata: '17859340',
displayMetadata: expect.stringContaining('"DisplayTitle":"Featured"'),
})
// displayMetadata must be non-empty and parse, or the builder drops the section.
const display = JSON.parse(sections[0].displayMetadata as string) as {
categoryUriNames: string
}
expect(display.categoryUriNames).toBe('featured,new')
})
// The asset manifest is case-sensitive and there is no index to fold case against, so
// the name has to match the file exactly.
it('404s a name whose case does not match the file', async () => {
+1
View File
@@ -0,0 +1 @@
[]
+3
View File
@@ -16,6 +16,9 @@
// nothing documents and the client never asks for. With it, the Worker sees every
// request and the files are reachable only through the binding, i.e. only under
// `/sections/pagesource/:type`.
"cache": {
"enabled": true
},
"assets": {
"binding": "ASSETS",
"directory": "./static",
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+449 -157
View File
@@ -17,14 +17,14 @@ import {
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
import {
getCustomAvatarItems,
toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db'
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
import { getInventionById, toInventionV9, toSaveResult } from '../../api/src/inventions-db'
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
// so a gift note is masked by the very same word list every other player-typed string is.
import { censorSwears } from '../../api/src/sanitize'
@@ -40,6 +40,7 @@ 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 questRewards from '../static/quest-rewards.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
@@ -51,6 +52,7 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCatalogItem } from './catalog-db'
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
// which store FILE a caller is served here, and which ITEMS go in that file there. The two must
// name the same moment or a build gets a store built to a different cutoff.
@@ -79,7 +81,9 @@ import {
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionRequest,
BuyInventionResponse,
BuyInventionV3Response,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
@@ -124,6 +128,7 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -165,16 +170,6 @@ 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, so a plain player's token is just `['gameClient']`. `null` when
* the request carries no valid token; an empty array means a valid token with no roles.
* Shaped to mirror {@link authedId}.
*/
async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
@@ -413,20 +408,32 @@ async function pushBalancePurchase(
*/
const NOT_AN_INFLUENCER = 0
/** The operator-granted role that comes with a complimentary subscription. */
const DEVELOPER_ROLE = 'developer'
/**
* Whether the caller currently holds a Rec Room Plus subscription the ONE definition,
* shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which
* price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the
* `developer` role IS the subscription; if a real subscription store ever lands, this is
* the only place that has to learn about it. Read from the token's `role` claim, never the
* body; no or an invalid token is "not subscribed".
* Whether the caller holds a Rec Room Plus subscription the ONE definition, shared by
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
* applied the discount itself and then had the buy refused as a price mismatch is exactly
* what one definition prevents.
*
* Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by
* proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`),
* and it reaches this worker as the token's `rn.plus` claim stamped by `auth` at login
* from that flag. So this is a pure token read: no database, no binding, nothing to load.
*
* The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token,
* which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims
* on the website has to sign in again and restart the game before Plus applies. The
* website's claim page says so.
*
* The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else
* could confer it; now that the Discord claim exists, Plus is one thing with one source.
* An operator who wants a developer to have it sets `hasPlus` on their account like
* anyone else's.
*
* Never read from the body. No token, or an invalid one, is "not subscribed".
*/
async function isSubscriber(c: Context<App>): Promise<boolean> {
const roles = await authedRoles(c)
return roles?.includes(DEVELOPER_ROLE) ?? false
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
}
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
@@ -446,20 +453,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
const STUB_SUBSCRIPTION_ID = 1
/**
* The complimentary subscription a `developer` account reports Rec Room Plus, which the
* client's API calls a `CampusCard`.
* The complimentary subscription a subscriber reports Rec Room Plus, which the client's
* API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a
* player who claimed `hasPlus` with a Discord role on the website.
*
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
* paid-tier surfaces get exercised without a store. Every field is computed per call and
* none of it is persisted, so this is not a record of anything revoking the role revokes
* the subscription, and no expiry sweep or renewal exists.
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
* field is computed per call and none of it is persisted, so this is not a record of
* anything dropping the role or the flag drops the subscription, and no expiry sweep or
* renewal exists.
*
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
* lapses on a day nobody is expecting, and the client would start showing an expired
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
*/
function developerSubscription(accountId: number) {
function plusSubscription(accountId: number) {
const now = new Date()
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
// year whether or not a leap day falls in between.
@@ -496,24 +504,45 @@ function toAvatarV2Dto(avatar: Avatar) {
}
/**
* The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem`
* reads: each store item carries the `GiftDrop` describing what you get, a list of
* `Prices` per currency, and optionally `SubscriberPrices` the discounted list a Rec Room
* Plus subscriber is shown and pays. The catalogs hold more fields (IsFeatured, ) that
* the purchase path doesn't need.
* A storefront catalog entry's `GiftDrop` (`static/storefronts/sf{N}.json`) what a store
* item hands over. Field-for-field the client's own `GiftDrop` class, in its declared
* order, so a name here is a name the client reads.
*
* REQUIRED vs OPTIONAL is about what this server produces, not what the client declares:
* the required eleven are the ones every drop-building helper here sets (a game reward, a
* level-up box, a challenge gift see {@link toGameRewardDrop} and friends), and they are
* the only ones the purchase and roll paths read. The rest are optional because nothing
* here synthesizes one, whether or not a captured catalog carries it `GiftDropId`,
* `Unique`, `SubscribersOnly`, `ItemSetId` and `ItemSetFriendlyName` are on all 5,875
* captured entries, while `TagList`, `CustomAvatarItemId`, `AvatarItemId`,
* `EquipmentItemId` and `ThumbnailImageName` are on none of them.
*
* The store item around it carries `Prices` per currency and optionally `SubscriberPrices`
* the discounted list a Rec Room Plus subscriber is shown and pays. Both hold more
* fields (IsFeatured, ) the purchase path doesn't need.
*/
interface StoreGiftDrop {
/**
* The drop's own id. Every captured entry has it equal to the item's
* `PurchasableItemId`, which is why the paths that need one (a weekly gift, a skin)
* take it off there instead of from here.
*/
GiftDropId?: number
FriendlyName: string
Tooltip: string
/**
* NULL on 23 captured entries the client's field is a plain string, but the catalogs
* keep null and `""` apart, so a reader passing it on has to collapse it (`?? ''`).
*/
Tooltip: string | null
/** Not on any captured entry; nothing here reads or sets one. */
TagList?: string
ConsumableItemDesc: string
AvatarItemDesc: string
/** A UGC item's guid. Not on any captured entry — the captures predate them. */
CustomAvatarItemId?: string | null
AvatarItemType: number | null
EquipmentPrefabName: string
EquipmentModificationGuid: string
Rarity: number
Context: number
Currency: number
CurrencyType: number
/**
* A QUERY drop a loot box rather than an item. Its item fields are all empty on
* purpose: what the player gets is rolled at grant time from everything of the target
@@ -522,16 +551,37 @@ interface StoreGiftDrop {
* random 4-star item that you don't have."
*/
IsQuery?: boolean
/** Whether the player may hold only one. Nothing here enforces it. */
Unique?: boolean
/** Whether only a Rec Room Plus subscriber may buy it. Nothing here enforces it. */
SubscribersOnly?: boolean
Rarity: number
CurrencyType: number
Currency: number
Context: number
/** The set the item belongs to; null on 92 captured entries. */
ItemSetId?: number | null
ItemSetFriendlyName?: string
/** Catalog ids for the item the drop carries. Not on any captured entry. */
AvatarItemId?: number | null
EquipmentItemId?: number | null
/** Not on any captured entry; the client falls back to the item's own thumbnail. */
ThumbnailImageName?: string
// ---- Not part of the client's class -------------------------------------
/**
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
* to `Rarity`.
* to `Rarity`. The client's `GiftDrop` has no such field — it is the catalog's, and
* only this server reads it.
*/
QueryRedirectRarity?: number
/**
* XP the drop pays out. No storefront catalog sets it a bought item is an item but a
* game reward is XP in a gift box, so the box and its notification carry the amount from
* here. The XP itself is banked in `progression`, not read back off the box.
* XP the drop pays out. Ours, not the client's and not any catalog's a bought item is
* an item, but a game reward is XP in a gift box, so the box and its notification carry
* the amount from here. The XP itself is banked in `progression`, not read back off the
* box.
*/
Xp?: number
}
@@ -807,7 +857,7 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
AvatarItemDesc: giftDrop.AvatarItemDesc,
PlatformMask: -1,
FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip,
Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity,
}
}
@@ -818,7 +868,7 @@ function toEquipment(giftDrop: StoreGiftDrop): Equipment {
ModificationGuid: giftDrop.EquipmentModificationGuid,
PrefabName: giftDrop.EquipmentPrefabName,
FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip,
Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity,
PlatformMask: -1,
Favorited: false,
@@ -1723,6 +1773,87 @@ function toGameRewardDrop(): StoreGiftDrop {
}
}
/**
* One row of `static/quest-rewards.json`: the reward table of the live game's activities,
* keyed by the `giftContext` the client posts with a game-reward ask (`Dodgeball`,
* `Quest_Goblin_S`, `Paintball_Dam`, ). Each row is the gift-drop as the game's own reward
* server shaped it a comma-laden `AvatarItemDesc` (the catalog's `item_key`), or for the
* Laser Tag entry a currency payout with `GiftRarity` and the activity's own `Context`
* (8000 for dodgeball, 4003 for the goblin quest's S rank) spelled the way the client's box
* reads them. Untyped fields (`Id`, `Level`, `Message`) are carried but unused.
*/
interface QuestReward {
AvatarItemDesc: string
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
CurrencyType: number
Currency: number
Xp: number
GiftRarity: number
Context: number
}
const QUEST_REWARDS: Record<string, QuestReward[]> = questRewards
/**
* The reward an activity pays, when `giftContext` names an entry in `quest-rewards.json`:
* one row drawn at random from that key's list, among the rows the player DOESN'T ALREADY
* OWN the table is "what this activity can give you", and handing over a duplicate gives
* nothing (the inventory is a set). A currency row is never "owned", so it always stays in
* the pool.
*
* Null for a context the table doesn't know (or one whose every reward the player already
* has), which the caller pays as the plain XP box the cooldown key is the same string
* either way, so an unknown or exhausted context is still rate-limited.
*/
async function pickQuestReward(
db: D1Database,
accountId: number,
giftContext: string
): Promise<QuestReward | null> {
if (!Object.hasOwn(QUEST_REWARDS, giftContext)) return null
const rows = QUEST_REWARDS[giftContext] ?? []
if (rows.length === 0) return null
const ownedItems = new Set((await getInventory(db, accountId)).map((i) => i.AvatarItemDesc))
const ownedGuids = new Set((await getEquipment(db, accountId)).map((e) => e.ModificationGuid))
const pool = rows.filter(
(r) =>
!(r.AvatarItemDesc !== '' && ownedItems.has(r.AvatarItemDesc)) &&
!(r.EquipmentModificationGuid !== '' && ownedGuids.has(r.EquipmentModificationGuid))
)
if (pool.length === 0) {
logger.info('quest rewards exhausted for player', { accountId, giftContext })
return null
}
return pool[Math.floor(Math.random() * pool.length)] ?? null
}
/**
* A quest reward as the gift-drop `grantGiftDrop` hands over. The item fields come off the
* row, so the item IS granted unlike {@link toGameRewardDrop}'s empty box. The catalog
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
* The XP is the flat game-reward amount, not the row's (always 0): the reward is the item,
* and the XP is the same pat on the back every claim gets.
*/
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
return {
FriendlyName: catalog?.friendly_name ?? '',
Tooltip: catalog?.tooltip ?? '',
ConsumableItemDesc: reward.ConsumableItemDesc,
AvatarItemDesc: reward.AvatarItemDesc,
AvatarItemType: catalog?.avatar_item_type ?? null,
EquipmentPrefabName: reward.EquipmentPrefabName,
EquipmentModificationGuid: reward.EquipmentModificationGuid,
Rarity: reward.GiftRarity,
Context: reward.Context,
Currency: reward.Currency,
CurrencyType: reward.CurrencyType,
Xp: GAME_REWARD_XP,
}
}
/**
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
@@ -2011,6 +2142,120 @@ function listRoute(summary: string, description: string, auth = false) {
})
}
/**
* A completed invention purchase: the invention that changed hands and the buyer's
* RESULTING token balance (not the change see the envelopes both routes build from it).
*/
interface SettledInventionPurchase {
invention: SavedInvention
balance: number
}
/**
* Settle an invention purchase the whole of buyInvention except the envelope it is
* announced in, shared by the `v2` GET and the `v3` POST. Every refusal is a `Response`
* (the `{ error }` body both routes answer with); a sale is the bought invention and the
* buyer's resulting balance, which each route then wraps in ITS OWN shape the two
* clients want different ones, so the money is shared and the projection is not.
*
* 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 cant 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 inventions `NumDownloads` counter is deliberately NOT
* bumped: that column lives on the `invention` table the `api` worker owns, and this
* worker only reads it.
*/
async function settleInventionPurchase(
c: Context<App>,
id: number,
inventionId: number,
requestedPrice: number
): Promise<SettledInventionPurchase | Response> {
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)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// 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 buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return { invention, balance }
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
// posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false })
@@ -3014,8 +3259,8 @@ const app = new Hono<App>({ strict: false })
summary: 'Buy a storefront item',
description: [
'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice`',
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as',
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
'client applies the discount itself and not to every item — debits the buyer atomically,',
'grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
@@ -3425,22 +3670,11 @@ 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.
// Buy an invention. [Authorize]. A GET, despite being a purchase — the 2023 client sends
// `?inventionId=…&requestedPrice=…` with no body, so thats what we answer, in the v6 save
// envelope that build reads. The 2025 build posts to `v3/buyInvention` below and wants a
// different envelope back; the two share {@link settleInventionPurchase}, which is where
// the money and the rules live, and build their own bodies from what it returns.
.get(
'/api/storefronts/v2/buyInvention',
describeRoute({
@@ -3495,91 +3729,116 @@ const app = new Hono<App>({ strict: false })
// 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)
}
const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
if (settled instanceof Response) return settled
// 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)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// 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 buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
Balance: settled.balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
BalanceUpdates: [{ UpdateResponse: 0, Data: settled.invention }],
},
// The bare `{ Status, Invention, InventionVersion }` the v6 save serves — this
// build's invention endpoints answer in it, and the client re-renders from it.
InventionResponse: toSaveResult(settled.invention),
})
}
)
// Buy an invention, the way the 2025 client asks for it. [Authorize]. A POST carrying
// `{ InventionId, RequestedPrice }` as JSON, where the v2 GET takes query params.
//
// The PURCHASE is identical — both settle through `settleInventionPurchase` — but the
// RESPONSE is not, and that is the whole reason this route exists rather than an alias:
// this build wraps the invention in the v9 save envelope and names its balance bucket
// `Platform`. See `BuyInventionV3Response`. Both routes stay served: the 2023 build still
// sends the GET, and it would not parse this body.
.post(
'/api/storefronts/v3/buyInvention',
describeRoute({
tags: ['Storefront'],
summary: 'Buy an invention (JSON body)',
description: [
'The same purchase as `GET /api/storefronts/v2/buyInvention` — confirms the clients',
'`RequestedPrice` still matches the inventions stored `Price`, debits the buyer and',
'pays the creator that price in RecCenterTokens (a free invention moves nothing),',
'records ownership in `inventory_invention`, and pushes both players a socket frame',
'carrying their RESULTING total — but answered in a DIFFERENT envelope, which is why',
'the route exists at all: `InventionResponse` is the v9 saves',
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2s',
'`BalanceType`.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BuyInventionRequest, 'The invention id and the price rendered'),
responses: {
200: json(BuyInventionV3Response, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Invalid body, missing 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 body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Invalid request body' }, 400)
}
const inventionId = body.InventionId
if (!Number.isInteger(inventionId)) {
return c.json({ error: 'InventionId is required' }, 400)
}
// Read the same way as v2s query param: an absent or non-integer RequestedPrice is 0,
// which only matches a free invention — a priced one then fails the confirmation rather
// than selling for nothing.
const requestedPrice = Number.isInteger(body.RequestedPrice)
? (body.RequestedPrice as number)
: 0
const settled = await settleInventionPurchase(c, id, inventionId as number, requestedPrice)
if (settled instanceof Response) return settled
return c.json({
// The v9 SAVE envelope, not v6's bare `{ Status, Invention, InventionVersion }`:
// `Value` under `{ Success, Error, error_id }`, with `Invention` the client's 28-key
// `RRInvention`. A buy mints no version and takes no tags, so both of those keys are
// present and NULL — which is safe here for the same reason it is on the save: the
// client reads `Success` and `Value.Invention` and nothing else. `Value` itself must
// never be null under `Success: true` — that dereference is what crashes it.
InventionResponse: {
Value: {
Status: 0,
Invention: toInventionV9(settled.invention),
InventionVersion: null,
TagsResponse: null,
},
Success: true,
Error: null,
error_id: null,
},
// `BalanceResponseDTO`, the same one the bulk purchase answers in — so the bucket key
// is `Platform`, NOT the `BalanceType` the v2 body sends. The client's member IS named
// `BalanceType`, but it carries a [DataMember] rename to `Platform` and its decoder
// drops what it doesn't know, so spelling it `BalanceType` here would land this balance
// in bucket 0 beside the real one. `Balance` is the RESULTING total, as in v2.
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: 0, Data: toInventionV9(settled.invention) }],
Balance: settled.balance,
CurrencyType: CurrencyType.RecCenterTokens,
// The capture says 0 (SteamPurchased) because the reference server kept a wallet per
// platform. This one keeps ONE bucket and the client SUMS them, so naming 0 here
// while every socket frame names -2 is exactly the phantom second balance that
// doubled players' tokens twice before. -2, like every other surface.
Platform: ALL_PLATFORMS,
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
@@ -3737,6 +3996,11 @@ const app = new Hono<App>({ strict: false })
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
// owed another reward while a second Soccer match inside the hour is not. An ask that
// sends no context keys on `''`.
//
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
.post(
'/api/gamerewards/v1/request',
describeRoute({
@@ -3746,8 +4010,10 @@ const app = new Hono<App>({ strict: false })
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. The reward rides in a gift box, so a claim and a rejected',
'(on-cooldown) ask both answer `[]`.',
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activitys rewards and grants it;',
'any other claim pays XP only. The reward rides in a gift box, so a claim and a',
'rejected (on-cooldown) ask both answer `[]`.',
].join(' '),
security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
@@ -3774,7 +4040,30 @@ const app = new Hono<App>({ strict: false })
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
// A failure here must not leave a box promising XP that was never credited.
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
// An activity the reward table knows pays one of ITS rewards the player lacks — the
// item rides in the box and is granted with it. Anything else gets the plain XP box.
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
const drop =
questReward === null
? toGameRewardDrop()
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null)
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
c.env.DB,
id,
drop.CurrencyType,
drop.Currency,
startingTokens
)
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
}
const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
@@ -3834,26 +4123,29 @@ const app = new Hono<App>({ strict: false })
)
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
// buy one from, so the `developer` role stands in for a paid subscription: a developer
// reports an active Gold year, everyone else reports none. Nothing is stored — see
// `developerSubscription`.
// buy one from: Plus is claimed on the website by proving a Discord role, and reaches
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
// Gold year; everyone else reports none. Nothing about the subscription itself is
// stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`.
//
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
// 401: the client posts this while loading, so an error here can stall its load
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
// caller anyway. The role is read from the token's `role` claim, never from the body.
// caller anyway. Never read from the body.
.post(
'/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({
tags: ['Econ'],
summary: 'Subscription lookup',
description: [
'The callers Rec Room Plus subscription. Nothing sells subscriptions here, so the',
'operator-granted `developer` role stands in for one: a developers token reports an',
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
'the role IS the subscription, so revoking it revokes this.',
'The callers Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
'claimed on the website by proving a qualifying role in the community Discord, and',
'arrives as the tokens `rn.plus` claim. A token carrying it reports an active Gold',
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,',
'not 401. The subscription itself is not persisted, and because the claim is stamped',
'at login, a player who has just claimed must sign in again before it appears.',
].join(' '),
responses: {
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
@@ -3864,7 +4156,7 @@ const app = new Hono<App>({ strict: false })
const id = await authedId(c)
if (id === null) return c.json({})
return c.json({
Subscription: developerSubscription(id),
Subscription: plusSubscription(id),
PlatformAccountSubscribedPlayerId: null,
})
}
+72 -7
View File
@@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({
/**
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
* so this is the complimentary subscription a `developer` account reports see
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
* so this is the complimentary subscription reported by a caller whose token carries
* `rn.plus` stamped from `account.hasPlus`, which the website's Discord benefits claim
* sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does.
*/
export const SubscriptionDto = z.object({
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
@@ -374,10 +375,24 @@ export const BulkPurchaseResponse = z.object({
})
/**
* `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.
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body
* that is the only difference between the two routes.
*/
export const BuyInventionRequest = z.object({
InventionId: z.int().describe('The invention to buy; missing or non-integer is 400'),
RequestedPrice: z
.int()
.optional()
.describe('The price the client rendered; a mismatch is 409. Absent reads as 0'),
})
/**
* `GET /api/storefronts/v2/buyInvention` and `POST /api/storefronts/v3/buyInvention` the
* purchase result, identical for both. 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({
@@ -400,6 +415,54 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/**
* `POST /api/storefronts/v3/buyInvention` the purchase result the 2025 client wants,
* which is NOT v2's despite settling the identical purchase. Two differences, both
* recovered from a capture of the real response:
*
* - `InventionResponse` is the v9 SAVE envelope (`{ Value, Success, Error, error_id }`)
* rather than v6's bare `{ Status, Invention, InventionVersion }`, and its `Invention`
* is the client's 28-key `RRInvention`. A buy mints no version and takes no tags, so
* `InventionVersion` and `TagsResponse` are present and null.
* - The balance half is `BalanceResponseDTO`, so the bucket key is `Platform` the
* client's `BalanceType` member under a [DataMember] rename, the same one the bulk
* purchase answers in. v2 spells it `BalanceType`; do not unify them.
*/
export const BuyInventionV3Response = z.object({
InventionResponse: z
.object({
Value: z
.object({
Status: z.int().describe('0 on success'),
Invention: JsonObject.describe('The bought invention as the 28-key `RRInvention`'),
InventionVersion: z.null().describe('Always null — a buy mints no version'),
TagsResponse: z.null().describe('Always null — a buy takes no tags'),
})
.describe('Never null under `Success: true` — the client dereferences it unguarded'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success — not `""`'),
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
})
.describe('The same envelope `POST /api/inventions/v9/save` returns'),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention, the same `RRInvention` as above'),
})
),
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z
.int()
.describe(
'The balance bucket — the clients `BalanceType` under a [DataMember] rename. -2, ' +
'account-wide: the capture said 0 (SteamPurchased) because the reference server ' +
'kept a wallet per platform; this one keeps a single bucket, and the client SUMS them'
),
}),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({
@@ -592,7 +655,9 @@ export const GameRewardRequest = z.object({
giftContext: z
.string()
.optional()
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
.describe(
'The activity it came from, e.g. `Soccer` — part of the cooldown key. A key of `quest-rewards.json` (`Dodgeball`, `Quest_Goblin_S`, …) also picks the prize from that activitys table'
),
})
/**
+320 -8
View File
@@ -33,6 +33,7 @@ import avatarItemsJson from '../../../static/db/avatar-items.json'
// caller's build, and these assertions are about the file's CONTENTS.
import carriedItems from '../../../static/db/consumables.json'
import skinsJson from '../../../static/db/skins.json'
import questRewards from '../../../static/quest-rewards.json'
import sf32025 from '../../../static/storefronts/sf3-2025.json'
import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db'
@@ -284,12 +285,21 @@ async function bearer(
sub = '42',
roles?: string[],
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
version?: string
version?: string,
/**
* Stamp `rn.plus`, as auth does for an account with `hasPlus`. This is the ONLY thing
* that makes a caller a Rec Room Plus subscriber the `developer` role does not so
* every subscriber-priced test passes it.
*/
plus = false
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles !== undefined) claims.role = roles
if (version !== undefined) claims['rn.ver'] = version
// Omitted when false, exactly as generateToken omits it — so these tokens match the
// shape of a real non-subscriber's.
if (plus) claims['rn.plus'] = true
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims)
)}`
@@ -1769,7 +1779,7 @@ describe('econ endpoints', () => {
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
await drainFrames()
const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85)
const res = await buy2263(await bearer('322', ['gameClient'], undefined, true), 85)
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
@@ -1784,7 +1794,7 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: {
...(await bearer('325', ['gameClient', 'developer'])),
...(await bearer('325', ['gameClient'], undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -1806,7 +1816,7 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: {
...(await bearer('326', ['gameClient', 'developer'])),
...(await bearer('326', ['gameClient'], undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -1827,10 +1837,10 @@ describe('econ endpoints', () => {
})
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84)
const res = await buy2263(await bearer('323', ['gameClient'], undefined, true), 84)
expect(res.status).toBe(409)
// …and above it: a made-up price is a mismatch in either direction.
const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96)
const over = await buy2263(await bearer('323', ['gameClient'], undefined, true), 96)
expect(over.status).toBe(409)
})
@@ -2646,6 +2656,168 @@ describe('econ endpoints', () => {
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
// The 2025 client posts the same purchase as a JSON body — and wants a DIFFERENT response
// back: the v9 save envelope, and a balance bucket keyed `Platform`. The settlement is
// shared with v2, so these pin the envelope and the money moving, not the rules v2 covers.
const buyInventionV3 = async (sub: string, body: unknown) =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
test('POST /api/storefronts/v3/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 8, RequestedPrice: 0 }),
})
expect(res.status).toBe(401)
})
test('POST /api/storefronts/v3/buyInvention answers the v9 envelope, not v2s', async () => {
const res = await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })
expect(res.status).toBe(200)
const body = (await res.json()) as {
InventionResponse: {
Value: {
Status: number
Invention: Record<string, unknown>
InventionVersion: unknown
TagsResponse: unknown
} | null
Success: boolean
Error: string | null
error_id: string | null
}
BalanceUpdateResponse: {
Balance: number
CurrencyType: number
Platform: number
BalanceType?: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
}
// The v9 SAVE envelope: `Value` under `{ Success, Error, error_id }`. `Value` is never
// null under `Success: true` — the client dereferences `Value.Invention` unguarded.
expect(body.InventionResponse).toMatchObject({ Success: true, Error: null, error_id: null })
expect(body.InventionResponse.Value?.Status).toBe(0)
expect(body.InventionResponse.Value?.Invention.InventionId).toBe(8)
expect(body.InventionResponse.Value?.Invention.Name).toBe('Invention 8')
// The 28-key `RRInvention`, not the stored record: the version rides nowhere here, and
// `IsPublished` is a stored field this projection drops.
expect(body.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.IsPublished).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.LatestVersionNumber).toBe(1)
// A buy mints no version and takes no tags — present and null, not absent.
expect(body.InventionResponse.Value).toHaveProperty('InventionVersion', null)
expect(body.InventionResponse.Value).toHaveProperty('TagsResponse', null)
// `BalanceResponseDTO`: the bucket key is `Platform`. Spelling it `BalanceType` (which is
// what v2 sends) would be dropped by the client's decoder and default this balance into
// bucket 0, beside the -2 the socket frames set — a phantom second balance.
expect(body.BalanceUpdateResponse.Platform).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceType).toBeUndefined()
// Nothing was debited, so `Balance` is the resulting total, not a change.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(await getOwnedInventionIds(env.DB, 55)).toEqual([8])
// Owning it is boolean here too — the route shares v2's settlement.
expect((await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })).status).toBe(409)
})
test('GET v2 and POST v3 buyInvention answer the SAME buy in different envelopes', async () => {
// The one thing that must not drift: two builds buying the same invention get the same
// invention back, shaped for each. v2 serves the stored record under a bare status
// envelope; v3 serves the 28-key projection under the v9 one. Don't unify them.
const v2 = (await (await buyInvention('58', 8)).json()) as {
InventionResponse: { Status: number; Invention: Record<string, unknown> }
BalanceUpdateResponse: { BalanceType: number; Platform?: number }
}
const v3 = (await (
await buyInventionV3('59', { InventionId: 8, RequestedPrice: 0 })
).json()) as {
InventionResponse: { Value: { Invention: Record<string, unknown> } | null }
BalanceUpdateResponse: { Platform: number; BalanceType?: number }
}
expect(v2.InventionResponse.Invention.InventionId).toBe(8)
expect(v3.InventionResponse.Value?.Invention.InventionId).toBe(8)
// v2 keeps the nested version; v3's projection lifts it away entirely.
expect(v2.InventionResponse.Invention.CurrentVersion).toBeDefined()
expect(v3.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
// The bucket is spelled differently on each, and each spells exactly one.
expect(v2.BalanceUpdateResponse).toMatchObject({ BalanceType: -2 })
expect(v2.BalanceUpdateResponse.Platform).toBeUndefined()
expect(v3.BalanceUpdateResponse).toMatchObject({ Platform: -2 })
expect(v3.BalanceUpdateResponse.BalanceType).toBeUndefined()
})
test('POST /api/storefronts/v3/buyInvention pays the creator and pushes both sides', async () => {
await drainFrames()
// Creator 999 has already been paid by the v2 tests above, so their resulting total is
// read rather than assumed — it is the payout ADDED to whatever they had.
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const res = await buyInventionV3('56', { InventionId: 9, RequestedPrice: 250 })
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 56, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore + 250)
expect(await getOwnedInventionIds(env.DB, 56)).toEqual([9])
// Same two frames as the v2 buy, each carrying its player's RESULTING total into the -2
// bucket: the creator sold (a plain update), the buyer bought (a purchase frame).
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: {
Balance: creatorBefore + 250,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 56,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -250,
Balance: DEFAULT_STARTING_TOKENS - 250,
Platform: -2,
CurrencyType: CurrencyType.RecCenterTokens,
},
},
])
})
test('POST /api/storefronts/v3/buyInvention rejects a stale price and a bad body', async () => {
// A body is the only difference from v2, so the price check reads it the same way: an
// absent RequestedPrice is 0, which does not match the 250-token invention 9.
expect((await buyInventionV3('57', { InventionId: 9 })).status).toBe(409)
expect((await buyInventionV3('57', { InventionId: 9, RequestedPrice: 0 })).status).toBe(409)
// No InventionId, and no body at all.
expect((await buyInventionV3('57', { RequestedPrice: 0 })).status).toBe(400)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer('57')), 'Content-Type': 'application/json' },
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 57)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
// Buy an item for account 24, then consume the box the way the client does: on the
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
@@ -3113,6 +3285,7 @@ describe('econ endpoints', () => {
AvatarItemDesc: string
ConsumableItemDesc: string
GiftRarity: number
GiftContext: number
}>
}
@@ -3512,6 +3685,94 @@ describe('econ endpoints', () => {
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
})
test('a giftContext naming a quest-rewards.json key pays one of that activitys rewards', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('83')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
await drainFrames()
// Quest_Goblin_S: forty avatar-item rewards, all at the goblin quest's S-rank context.
const goblin = await request(
'rewardType=PostGameActivity&Message=Quest%20complete&giftContext=Quest_Goblin_S'
)
expect(goblin.status).toBe(200)
expect(await goblin.json()).toEqual([])
const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(1)
const box = boxes[0]
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
expect(box?.AvatarItemDesc).not.toBe('')
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
expect(row).toBeDefined()
expect(box?.GiftRarity).toBe(row?.GiftRarity)
// …and the item is in the inventory, not just on the box.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('83'),
})
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(box?.AvatarItemDesc)
// The box announces the activity's context, not the generic GameRewards one.
const frames = await drainFrames()
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toMatchObject({
GiftContext: 4003,
AvatarItemDesc: box?.AvatarItemDesc,
})
// Lasertag's single reward is 50 Laser Tag tickets: credited to the balance, no item.
const before = await getBalance(
env.DB,
83,
CurrencyType.LaserTagTickets,
DEFAULT_STARTING_TOKENS
)
expect((await request('rewardType=PostGameActivity&giftContext=Lasertag')).status).toBe(200)
expect(
await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS)
).toBe(before + 50)
const ticketBox = (await giftBoxes('83'))[1]
expect(ticketBox).toMatchObject({
Currency: 50,
CurrencyType: CurrencyType.LaserTagTickets,
AvatarItemDesc: '',
GiftContext: 9000,
})
const ticketFrames = await drainFrames()
expect(ticketFrames.map((f) => f.notificationType)).toContain(
NotificationType.StorefrontBalanceUpdate
)
// An activity the table doesn't know pays the plain XP box, as before. (The LAST box:
// the two claims above also crossed level 1, and that level-up box sits in between.)
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
const plain = (await giftBoxes('83')).at(-1)
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
// A reward the player already owns is never drawn again: Dodgeball has three rows, so
// three claims hand over all three, and a fourth — nothing left to give — pays the
// plain XP box rather than a duplicate.
const dodgeball = questRewards.Dodgeball.map((r) => r.AvatarItemDesc)
const handed: string[] = []
for (let i = 0; i < 4; i++) {
await env.DB.prepare(
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
).run()
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
const latest = (await giftBoxes('83')).findLast(
(b) => b.GiftContext === 8000 || b.GiftContext === 50
)
if (i < 3) handed.push(latest?.AvatarItemDesc as string)
else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 })
}
expect(handed.toSorted()).toEqual(dodgeball.toSorted())
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
@@ -3657,7 +3918,7 @@ describe('econ endpoints', () => {
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
const res = await getSubscription(await bearer('205', ['gameClient'], undefined, true))
expect(res.status).toBe(200)
const body = (await res.json()) as {
Subscription: Record<string, unknown>
@@ -3689,7 +3950,7 @@ describe('econ endpoints', () => {
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
// A plain player's token: valid, but no elevated role.
// A plain player's token: valid, no elevated role, and no `hasPlus` on the account.
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
// A token with no `role` claim at all.
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
@@ -3699,6 +3960,56 @@ describe('econ endpoints', () => {
expect(await anon.json()).toEqual({})
})
// Plus reaches this worker as the token's `rn.plus` claim, which `auth` stamps from
// `account.hasPlus` at login. Nothing here reads the account, so this is the whole
// mechanism — and the reason a player who claims on the website has to sign in again.
//
// The token carries only `gameClient`, exactly as a game client's does.
test('POST /api/CampusCard/v1/UpdateAndGetSubscription honours the rn.plus claim', async () => {
const res = await getSubscription(await bearer('9208', ['gameClient'], undefined, true))
expect(res.status).toBe(200)
const body = (await res.json()) as { Subscription: Record<string, unknown> }
expect(body.Subscription).toMatchObject({
SubscriptionId: 1,
RecNetPlayerId: 9208,
PlatformType: -1,
Level: 0,
Period: 1,
IsAutoRenewing: true,
})
})
// The `developer` role used to BE the subscription, as a stand-in while nothing else
// could confer one. Now that Plus has a real source it is one thing with one source, and
// an elevated account is not a subscriber unless it also holds `rn.plus`. Pinned because
// nothing else would fail if the old shortcut came back: it would silently hand Plus (and
// the 10% discount) to every operator account.
test('the developer role alone is not a Rec Room Plus subscription', async () => {
const dev = await getSubscription(await bearer('9210', ['gameClient', 'developer']))
expect(dev.status).toBe(200)
expect(await dev.json()).toEqual({})
// …and it buys nothing at the subscriber price either, so the report and the buy path
// agree. 85 is the SubscriberPrices entry for sf300's 2263; 95 is the list price.
const discounted = await buy2263(await bearer('9211', ['gameClient', 'developer']), 85)
expect(discounted.status).toBe(409)
})
// Plus is priced, not just displayed: the same claim gates the subscriber discount band
// on a buy. A subscriber whose client applied the discount itself and then had the
// purchase refused as a price mismatch is exactly what one definition prevents, so the
// CampusCard report and the buy must never disagree.
test('an rn.plus token is charged the subscriber price', async () => {
const res = await buy2263(await bearer('9326', ['gameClient'], undefined, true), 85)
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
// The same request without the claim is refused, so the discount really comes from
// `rn.plus` and not from the band being open to everyone.
const plain = await buy2263(await bearer('9327', ['gameClient']), 85)
expect(plain.status).toBe(409)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
@@ -3786,6 +4097,7 @@ describe('econ endpoints', () => {
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
])
+2 -2
View File
@@ -20,7 +20,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -32,6 +32,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
-- Server statistics sampled over time — one row per sample, as the presence cron
-- writes them. Generated from packages/domain/src/stats-db.ts (STAT_SCHEMA_DDL) — keep
-- in sync.
--
-- `stat_type` names what was measured (currently just `online`: the number of live
-- `presence` rows, i.e. players online, taken right after the expired ones are swept).
-- `value` is the measurement and `datetime` is when it was taken, as an ISO-8601 UTC
-- string so it reads directly and sorts lexically.
CREATE TABLE IF NOT EXISTS stat (
stat_type TEXT NOT NULL,
value INTEGER NOT NULL,
datetime TEXT NOT NULL
);
-- For "the `online` series over a time range".
CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime);
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+13 -9
View File
@@ -53,17 +53,21 @@ export type Env = SharedHonoEnv & {
/** The Photon Chat application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
PHOTON_CHAT_APP_ID?: string
/**
* The Tachyon voice server the client is handed as `voiceConnectionInfo` on
* `GET /player/connection-info`, as `host:port` (e.g. `66.228.47.217:7777`).
* Optional, and EMPTY when unset no separate voice server. Not a secret (the
* client receives it in the clear), so a plain var like the Photon ids.
* The pool of Tachyon servers sessions are spread across a COMMA-SEPARATED list of
* `host:port` entries (e.g. `66.228.47.217:7777,66.228.47.217:7778,45.79.2.10:7777`).
* One entry is a single server, which is the common case. Optional, and EMPTY when
* unset no separate voice server, and the connection info's voice fields stay empty.
* Not a secret (the client receives it in the clear), so a plain var like the Photon
* ids.
*
* A room instance is assigned one entry for its lifetime and every player in it is
* handed that one, derived from the instance id rather than stored see
* `tachyonServerFor` in match.app.ts, which also explains what changing this list does
* to sessions already running. The `voiceServerId` the client displays is GENERATED
* from an entry's position (`tachyon-1`, `tachyon-2`, ), so the same address may be
* listed twice to model two server slots on one box.
*/
TACHYON_HOST_PORT?: string
/**
* The id of that voice server, handed to the client as `voiceServerId` (e.g.
* `server-1`). Optional; see {@link Env.TACHYON_HOST_PORT} set both or neither.
*/
TACHYON_NAME?: string
/**
* The Photon region every session is pinned to both the region named in the connection
* info and the one stamped on every room instance, which must agree. Optional; unlike the
+333 -32
View File
@@ -6,6 +6,7 @@ import {
Accessibility,
areFriends,
canManageRoom,
countOnlinePlayers,
createRoomInstance,
createRoomInvite,
deleteEmptyRoomInstances,
@@ -17,6 +18,8 @@ import {
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
deleteRoomInvite,
getLatestRoomInviteBetween,
getMostActiveClubhouses,
getOrCreateDormRoom,
getPresence,
@@ -35,6 +38,7 @@ import {
MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT,
recordRoomVisit,
recordStat,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
@@ -163,6 +167,66 @@ function instancePhotonRegion(env: Env): string {
return varOr(env.PHOTON_REGION, DEFAULT_PHOTON_REGION)
}
/**
* One Tachyon server this deployment can put a session on: where the client connects
* (`host:port`) and the cosmetic id it displays for it.
*/
interface TachyonServer {
hostPort: string
serverId: string
}
/**
* The Tachyon servers sessions are spread across, from `TACHYON_HOST_PORT` a
* comma-separated list of `host:port` entries. EMPTY when the var is unset: recflare
* runs no Tachyon server of its own, and a client handed an address that answers
* nothing is worse off than one told there is no voice server at all.
*
* An entry's POSITION in the list is its identity: `voiceServerId` is generated from it
* (`tachyon-1`, `tachyon-2`, ) rather than configured, so the same host may appear
* twice and count as two servers which is what one box running several server slots
* looks like from here. The id is cosmetic (the client connects to the address and
* never sends the id anywhere), but it is positional, so inserting an entry renames
* every server after it.
*/
function tachyonPool(env: Env): TachyonServer[] {
return varOr(env.TACHYON_HOST_PORT, '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '')
.map((hostPort, i) => ({ hostPort, serverId: `tachyon-${i + 1}` }))
}
/** What the connection info carries when there is no Tachyon server to name. */
const NO_TACHYON_SERVER: TachyonServer = { hostPort: '', serverId: '' }
/**
* The Tachyon server a room instance runs on the whole of the distributed selection,
* and deliberately a pure function of the instance id rather than a stored assignment.
*
* The point of a server assignment is that everyone in one instance is handed the SAME
* one: the player whose matchmake created the instance and everyone who joins it later
* each call `GET /player/connection-info` separately, so an assignment made per REQUEST
* (random, round-robin over a counter, least-loaded) would scatter one session across
* the pool. Deriving it from the instance id instead makes every caller compute the same
* answer without coordinating, needs no column to persist and no cleanup when the
* instance is swept, and answers for instances created before this existed.
*
* Instance ids are sequential ({@link createRoomInstance} allocates `MAX(id) + 1`), so
* the modulo hands successive instances to successive servers: a plain round-robin over
* instances, which is the spread a real allocator would aim for anyway. Changing the
* pool DOES move live instances the list is the assignment so add entries to the
* end and expect a session mid-flight to be told a different server when you don't.
*
* `roomInstanceId` 0 means the caller resolved to no instance at all (they're in no
* room, or named one that doesn't exist); they get no server rather than server one.
*/
function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer {
const pool = tachyonPool(env)
if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER
return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER
}
/**
* 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
@@ -595,6 +659,63 @@ function crossBuildRefusal(
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
}
/**
* The 2023 client build, as its token's `rn.ver` date stamps it (`20230414`, or a point
* release of it). See {@link persistenceVersionRefusal}.
*/
const BUILD_2023 = 20230414
/**
* The first scene persistence version the 2023 client cannot load. A room whose published
* scene was saved at this version or later was built on a newer client; the old one fails
* to deserialize it.
*/
const MIN_UNLOADABLE_PERSISTENCE_VERSION_2023 = 227
/**
* The persistence version of the scene a subroom LOADS the published `CurrentSave`'s,
* the same save {@link subRoomDataBlob} serves, falling back to the flat legacy field.
* `null` when nothing recorded one (a fresh subroom, or one saved before the field
* existed): unknown is not "new".
*/
function subRoomPersistenceVersion(sub: Record<string, unknown> | undefined): number | null {
const save = sub?.CurrentSave
if (save && typeof save === 'object') {
const v = (save as Record<string, unknown>).PersistenceVersion
if (typeof v === 'number') return v
}
return typeof sub?.PersistenceVersion === 'number' ? sub.PersistenceVersion : null
}
/**
* Whether a player on `callerVersion` may enter `room` at all `null` when they may,
* otherwise the code to refuse with.
*
* A caller on the 2023 build ({@link BUILD_2023}) is refused any room with a subroom whose
* published scene is at persistence version {@link MIN_UNLOADABLE_PERSISTENCE_VERSION_2023}
* or above: that scene was saved by a newer client and the 2023 one can't load it, so
* the honest answer is `UpdateRequired` "your client can't go there" rather than
* handing out an instance that never finishes loading. The WHOLE room is gated, not just
* the requested subroom, since the client walks between subrooms without re-matchmaking.
*
* Every other build, and a token that names none, passes: the gate is about one known
* client, not a general ordering.
*/
function persistenceVersionRefusal(
room: Room,
callerVersion: string | null
): MatchmakingErrorCode | null {
if (buildNumber(callerVersion) !== BUILD_2023) return null
const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array<
Record<string, unknown>
>
const tooNew = subRooms.some((sub) => {
const v = subRoomPersistenceVersion(sub)
return v !== null && v >= MIN_UNLOADABLE_PERSISTENCE_VERSION_2023
})
return tooNew ? MatchmakingErrorCode.UpdateRequired : null
}
/**
* "This event isn't open to you" the refusal on a private event the caller wasn't
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
@@ -668,14 +789,6 @@ async function gameInviteType(c: Context<App>): Promise<MessageType> {
*/
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether
/**
* The placeholder `InviteId` on a v2 invite that has no `room_invite` row behind it the
* party fan-out, which invites without recording anything. What the client does with the
* field isn't known yet; `POST /invite` passes the real row id (see {@link RoomInvite}),
* which is the obvious candidate, and this stands in where there is no row to name.
*/
const UNKNOWN_INVITE_ID = 0
/** What an invite points at, in the shape both message versions need to describe it. */
type GameInviteTarget = {
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
@@ -684,7 +797,7 @@ type GameInviteTarget = {
roomId: number | null
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
name: string
/** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */
/** The id of the `room_invite` row this came from — what the invitee redeems. */
inviteId: number
}
@@ -1064,6 +1177,12 @@ async function readMatchmakeBody(
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The
* leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
* failures), and never blocks the matchmake beyond the sends themselves.
*
* Each member's invite is RECORDED, exactly as `POST /invite` records one, because the row
* is what the member redeems the frame against: the 2025 client joins off a party invite
* through `/matchmake/invite/{InviteId}` or `/matchmake/v2/player/{leaderId}`, and both
* resolve a `room_invite` row. A fan-out that only pushed the frame minted no row, so every
* party invite read as expired the moment it arrived while a manual `POST /invite` worked.
*/
async function inviteParty(
c: Context<App>,
@@ -1071,21 +1190,33 @@ async function inviteParty(
playerIds: number[],
instance: RoomInstance
): Promise<void> {
// The leader's own instance, which every member is being pulled into. Nothing records
// a `room_invite` row on this path, so a v2 invite has no real id to name.
const target: GameInviteTarget = {
instanceId: String(instance.roomInstanceId),
roomId: instance.roomId,
name: instance.name,
inviteId: UNKNOWN_INVITE_ID,
}
// One read of the leader's token for the whole party — every member gets the same
// message, so the type can't differ between them.
const type = await gameInviteType(c)
await Promise.all(
playerIds
.filter((pid) => pid !== leaderId)
.map((pid) => sendGameInvite(c, leaderId, pid, target, type))
.map(async (pid) => {
// The row before the frame, as `POST /invite` does: the frame names the row's id,
// so an invite that couldn't be recorded has nothing to redeem and isn't sent.
const invite = await createRoomInvite(c.env.DB, leaderId, pid, instance.roomId)
if (invite === null) {
logger.error('failed to record party room invite', {
fromPlayerId: leaderId,
toPlayerId: pid,
roomId: instance.roomId,
})
return
}
// The leader's own instance, which every member is being pulled into.
const target: GameInviteTarget = {
instanceId: String(instance.roomInstanceId),
roomId: instance.roomId,
name: instance.name,
inviteId: invite.RoomInviteId,
}
await sendGameInvite(c, leaderId, pid, target, type)
})
)
}
@@ -1203,6 +1334,20 @@ async function resolveRoomInstance(
return { instance: null, errorCode: BANNED_FROM_ROOM }
}
// The build this player is on, from their token. A 2023 client can't load a scene
// saved at a newer persistence version, so it is refused the room outright (see
// persistenceVersionRefusal) before any instance is created or reused.
const tokenVersion = await callerVersion(c)
const tooNew = persistenceVersionRefusal(room, tokenVersion)
if (tooNew !== null) {
logger.info('matchmake refused: room persistence version too new for client build', {
roomId: f.roomId,
ownerId,
gameVersion: tokenVersion,
})
return { instance: null, errorCode: tooNew }
}
// 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)
@@ -1213,10 +1358,10 @@ async function resolveRoomInstance(
const currentInstanceId = isPrivate
? undefined
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
// The build this player is on, from their token. It scopes the search below and is
// stamped on the instance when one is created, which is what keeps a session to a
// single client version.
const gameVersion = await callerGameVersion(c)
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
// the search below and is stamped on the instance when one is created, which is what
// keeps a session to a single client version.
const gameVersion = tokenVersion ?? GAME_VERSION
// Reuse an existing joinable public instance *of the same subroom and the same
// build* — subrooms are separate places, so joining one must never land you in
// another, and neither must a session running a different version of the room.
@@ -2040,6 +2185,143 @@ const app = new Hono<App>()
}
)
// The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as
// the v1 follow above — land in the instance the target is standing in — but gated on
// the `room_invite` table rather than friendship: the caller must hold a standing
// invite FROM the target (the newer client's invite frame doesn't always carry a
// redeemable `InviteId` — the party fan-out sends 0 — so it redeems by player instead
// of by row id, and this is that path). Everything the target sent stays checkable:
// the newest row is enough, since any live row is authorization.
//
// The row is consumed on a successful join: an invite authorizes one entry, and since
// this path follows the target's LIVE presence rather than the room the invite named,
// keeping it would leave a standing key into whatever instance they're in later.
//
// Like the follow and invite paths, this hands out real Photon coordinates without
// going through resolveRoomInstance, so it carries its own ban and build checks.
// `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2
// room routes do.
.post(
'/matchmake/v2/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Navigation', '2025'],
summary: 'Join the player who invited you (v2)',
description: [
'Places the caller into the room instance the target player is currently in, read from',
'the targets stored presence. INVITEES ONLY: the caller must hold a `room_invite` row',
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by',
'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:',
'a successful join deletes the row, so the same invite cant be redeemed again into',
'wherever that player goes next (a refusal leaves it standing, so a retry still works).',
'Answers 40',
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and',
'“expired” are one answer), 2 (PlayerNotOnline) when the target isnt in a room, 17',
'(AlreadyInTargetInstance) when the caller is already standing there, 3',
'(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is',
'banned from that room.',
'',
'2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as',
'the other `/matchmake/v2/*` routes do.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The player to join (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeV2Response,
'The targets instance, or a null RoomInstance with the refusal code'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const targetId = Number.parseInt(c.req.param('playerId'), 10)
// The gate: a standing invite from the target to the caller. No row means never
// invited or already swept — the same answer either way, since expiry deletes
// rows. This also refuses joining yourself: nobody holds a self-invite.
const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id)
if (invite === null) {
logger.info('v2 player matchmake refused: no invite from target', { targetId, id })
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
}
// Where the inviter is NOW, straight off their presence row — not the invite's
// stored RoomId, which records where they were when they sent it.
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
const instance = targetPresence?.roomInstance ?? null
if (!instance) {
logger.info('v2 player matchmake refused: target is not in a room', { targetId, id })
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
}
// Already standing there: nothing to do, and re-entering would churn presence and
// re-fire the friend fan-out for a move that didn't happen.
const own = await getPresence<RoomInstance>(c.env.DB, id)
if (own?.roomInstance?.roomInstanceId === instance.roomInstanceId) {
return matchmakeResult(c, MatchmakingErrorCode.AlreadyInTargetInstance, null)
}
// Real Photon coordinates without resolveRoomInstance, so the room's bans have to
// be checked here — otherwise an invite is a way around one.
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
logger.info('v2 player matchmake refused: player banned from room', {
roomId: instance.roomId,
id,
})
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Nor the build scoping a room matchmake has by construction. Compared against the
// TARGET's presence — they're the person actually standing in there.
const refusal = crossBuildRefusal(
await callerGameVersion(c),
targetPresence?.appVersion ?? GAME_VERSION
)
if (refusal !== null) {
logger.info('v2 player matchmake refused: target is on another client build', {
roomInstanceId: instance.roomInstanceId,
targetId,
id,
})
return matchmakeResult(c, refusal, null)
}
// Fullness read fresh, like the invite path: joins off an invite cluster exactly
// when a nearly-full instance is still filling. Null means a synthetic instance
// with no row (a dorm), which has no head-count to check.
if ((await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)) === true) {
logger.info('v2 player matchmake refused: instance is full', {
roomInstanceId: instance.roomInstanceId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.InsufficientSpace, null)
}
// Same instance, same Photon room, stored as the caller's presence so their
// heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
// The invite is spent: it was authorization for THIS join, and leaving the row
// standing would make it a permanent key into whatever instance the target is in
// later — this path reads their live presence, not the room the invite named.
// Dropped only once the caller is actually in, so every refusal above (target not
// in a room, full, banned, wrong build) leaves the invite redeemable for a retry.
await deleteRoomInvite(c.env.DB, invite.RoomInviteId)
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
}
)
// Accept a game invite and land in the inviter's instance
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
@@ -2496,6 +2778,10 @@ const app = new Hono<App>()
// 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.
//
// The Tachyon server is resolved from the same instance ({@link tachyonServerFor}), so
// the player who created the session and everyone who joins it later are all sent to
// one server without this endpoint having to remember what it told the first caller.
.get(
'/player/connection-info',
describeRoute({
@@ -2506,9 +2792,11 @@ const app = new Hono<App>()
'`{ 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). The voice',
'fields carry the Tachyon voice server (`TACHYON_HOST_PORT`/`TACHYON_NAME`),',
'empty when none is configured. `experiments` carries the',
'clients networking flags.',
'fields name the Tachyon server that instance was assigned — one entry out of the',
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is',
'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,',
'…). Both are empty when the pool is unset or the caller is in no instance.',
'`experiments` carries the clients networking flags.',
].join(' '),
security: AUTHED,
parameters: [
@@ -2532,14 +2820,22 @@ const app = new Hono<App>()
const apps = photonApps(c.env)
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.
// only stands in when there's no live presence to read. The instance id travels
// with the Photon room because the Tachyon server is derived from it — resolving
// one without the other would hand a joiner the right Photon room on a different
// game server than the rest of their session.
let roomInstanceId = presence?.roomInstance?.roomInstanceId ?? 0
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 ?? ''
const instance = await getRoomInstance(c.env.DB, requested)
roomInstanceId = instance?.roomInstanceId ?? 0
photonRoomId = instance?.photonRoomId ?? ''
}
}
// The instance's server, the same one every other player in it is handed.
const tachyon = tachyonServerFor(c.env, roomInstanceId)
// 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
@@ -2561,13 +2857,14 @@ const app = new Hono<App>()
photonAuthToken,
...apps,
photonRoomId,
// The Tachyon voice server, from the operator's vars — empty strings when
// unset (no separate voice server). Empty rather than null: the client's
// The Tachyon server this instance runs on, picked out of the operator's
// pool by {@link tachyonServerFor} — empty strings when the pool is empty
// or the caller is in no instance. Empty rather than null: the client's
// decoder is likelier to accept a missing-value string than a null on a
// string field. The presence payload's NULL_CONNECTION_INFO keeps its
// nulls — that one never carries credentials.
voiceConnectionInfo: varOr(c.env.TACHYON_HOST_PORT, ''),
voiceServerId: varOr(c.env.TACHYON_NAME, ''),
voiceConnectionInfo: tachyon.hostPort,
voiceServerId: tachyon.serverId,
experiments: PHOTON_EXPERIMENTS,
},
error: null,
@@ -2903,10 +3200,14 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
for (const instanceId of staleInstanceIds) {
await refreshInstanceFullness(env.DB, instanceId)
}
// Sample the player count into `stat` — taken after the purge, so it's the live
// rows and not the ones that just lapsed. One row per cron run: the `online` series.
const online = await countOnlinePlayers(env.DB)
await recordStat(env.DB, 'online', online)
// The tagged logger is request-scoped (its middleware never runs for a cron), so
// log plainly here — Workers observability picks it up either way.
console.log(
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances`
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances, ${online} online`
)
}
+12 -8
View File
@@ -206,8 +206,9 @@ export const ConnectionExperiments = z.object({
* 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. The voice fields name
* the Tachyon voice server (`TACHYON_HOST_PORT`/`TACHYON_NAME` vars), empty when none
* is configured. `photonRegion` matches the one stamped
* the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool
* likewise the same for everyone in the session and are empty when the pool is unset
* or the caller is in no instance. `photonRegion` matches the one stamped
* on every room instance, so the two can't disagree.
*/
export const ConnectionInfo = z.object({
@@ -219,8 +220,10 @@ export const ConnectionInfo = z.object({
photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z
.string()
.describe('The Tachyon voice server, `host:port`; empty when none is configured'),
voiceServerId: z.string().describe('The Tachyon voice server id; empty when none is configured'),
.describe('The instances Tachyon server, `host:port`; empty when none is configured'),
voiceServerId: z
.string()
.describe('That servers generated id (`tachyon-1`, …); cosmetic, empty when there is none'),
experiments: ConnectionExperiments,
})
@@ -278,10 +281,11 @@ export const NotifyDisconnectRequest = z.object({
* matchmakes that post no body at all.
*
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why
* it's a schema of its own; the room matchmakes extend it. Other fields the client sends
* (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`,
* `BypassMovementModeRestriction`) are accepted and ignored.
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/v2/player/:id`,
* `/matchmake/instance/:id`), which is why it's a schema of its own; the room matchmakes
* extend it. Other fields the client sends (`LoginLock`, `MaxPersistenceVersion`,
* `VoiceServerVersion`, `BypassMovementModeRestriction`, `PlayerIsPartyMember`) are
* accepted and ignored.
*/
export const CorrelationIdRequest = z.object({
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
+389 -5
View File
@@ -20,6 +20,7 @@ import {
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
setPresence,
STAT_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
@@ -124,6 +125,8 @@ beforeAll(async () => {
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room invites (owned by this worker) — POST /invite mints a row per invite.
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Stats (owned by this worker) — the presence cron samples the online count into it.
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
@@ -1244,8 +1247,9 @@ describe('auth-gated endpoints', () => {
// 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,
// Empty strings, not nulls — unlike the presence payload's connection fields,
// which stay null (they never carry credentials).
// No TACHYON_HOST_PORT pool configured, so there is no server to name. Empty
// strings, not nulls — unlike the presence payload's connection fields, which
// stay null (they never carry credentials).
voiceConnectionInfo: '',
voiceServerId: '',
experiments: {
@@ -1374,6 +1378,84 @@ describe('auth-gated endpoints', () => {
expect(body.value.photonRoomId).toBe('')
})
test('the Tachyon server is assigned per room instance, not per request', async () => {
// tachyon-1/-2 and tachyon-4/-5 are slots on one host apiece: a server id names a
// slot, which is why it's generated from the entry's position and not from its
// address. The blank entry and the stray spaces below are dropped — a list edited
// by hand shouldn't hand anyone an empty address.
const pool = [
{ voiceConnectionInfo: '198.51.100.10:7777', voiceServerId: 'tachyon-1' },
{ voiceConnectionInfo: '198.51.100.10:7778', voiceServerId: 'tachyon-2' },
{ voiceConnectionInfo: '198.51.100.11:7777', voiceServerId: 'tachyon-3' },
{ voiceConnectionInfo: '203.0.113.20:7777', voiceServerId: 'tachyon-4' },
{ voiceConnectionInfo: '203.0.113.20:7778', voiceServerId: 'tachyon-5' },
]
const original = env.TACHYON_HOST_PORT
try {
env.TACHYON_HOST_PORT =
'198.51.100.10:7777, 198.51.100.10:7778 ,,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778'
const voiceFor = async (player: string, roomInstanceId?: number) => {
const res = await exports.default.fetch(
roomInstanceId === undefined
? `${ORIGIN}/player/connection-info`
: `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
{ headers: await bearer(player) }
)
const body = (await res.json()) as {
value: { voiceConnectionInfo: string; voiceServerId: string }
}
return {
voiceConnectionInfo: body.value.voiceConnectionInfo,
voiceServerId: body.value.voiceServerId,
}
}
// The player who opened the session reads their server off their presence...
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 970,
roomId: 2,
photonRoomId: 'tachyon-instance-a',
maxCapacity: 12,
})
await setPresence(env.DB, {
accountId: 970,
roomInstance: instance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
const assigned = pool[instance.roomInstanceId % pool.length]!
expect(await voiceFor('970')).toEqual(assigned)
// ...and a joiner asking by instance id, before their own presence has landed,
// is sent to the same one. Two players in a session on two servers is the whole
// failure this is arranged to avoid.
expect(await voiceFor('971', instance.roomInstanceId)).toEqual(assigned)
// The next session opened goes to the next server along — instance ids are
// sequential, so the pool is walked round-robin as instances are created.
const next = await createRoomInstance(env.DB, {
ownerAccountId: 972,
roomId: 2,
photonRoomId: 'tachyon-instance-b',
maxCapacity: 12,
})
expect(next.roomInstanceId).toBe(instance.roomInstanceId + 1)
const alongside = await voiceFor('972', next.roomInstanceId)
expect(alongside).toEqual(pool[next.roomInstanceId % pool.length])
expect(alongside.voiceServerId).not.toBe(assigned.voiceServerId)
// A player in no instance gets no server, pool or no pool — there is nothing for
// them to be on the same server as, and the fields stay empty strings.
expect(await voiceFor('973')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
} finally {
env.TACHYON_HOST_PORT = original
}
})
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
@@ -1909,6 +1991,30 @@ describe('auth-gated endpoints', () => {
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('records an `online` stat sample of the live presence count on each run', async () => {
await env.DB.prepare('DELETE FROM stat').run()
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
.bind(nowSeconds())
.first<{ n: number }>())!.n
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
const rows = (
await env.DB.prepare('SELECT stat_type, value, datetime FROM stat').all<{
stat_type: string
value: number
datetime: string
}>()
).results
expect(rows).toHaveLength(1)
expect(rows[0]!.stat_type).toBe('online')
expect(rows[0]!.value).toBe(before)
// Stamped with the current time, as ISO-8601.
expect(Math.abs(Date.parse(rows[0]!.datetime) - Date.now())).toBeLessThan(10_000)
})
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
// (the generated `created_at` column follows the blob), so the empty-instance sweep
// can be exercised without waiting out the grace window.
@@ -2444,9 +2550,7 @@ describe('auth-gated endpoints', () => {
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
// the call answered — a v2 invite names it in its Data.
const inviteFrom = async (
version?: string
): Promise<{ frame: Sent; roomInviteId: number }> => {
const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => {
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
@@ -2810,6 +2914,14 @@ describe('auth-gated endpoints', () => {
RoomId: 2,
})
// The fan-out RECORDS each invite, like POST /invite: the row is what the invitee
// redeems the frame against, so a frame without one is expired on arrival.
const row = await env.DB.prepare(
'SELECT room_invite_id, room_id FROM room_invite WHERE from_player_id = 9850 AND to_player_id = 153'
).first<{ room_invite_id: number; room_id: number }>()
expect(row).not.toBeNull()
expect(row?.room_id).toBe(2)
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped.
await reset()
@@ -2825,6 +2937,58 @@ describe('auth-gated endpoints', () => {
expect(await sent()).toEqual([])
})
test('a v2 party matchmake mints invites the members can actually redeem', async () => {
// The reported bug: /matchmake/v2/room/:id fanned the party out as frames with no
// `room_invite` row behind them, so the member's join answered 40 (RoomInviteExpired)
// while a manual POST /invite worked.
type Sent = { playerId: number; data: { Type: number; Data: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, {
method: 'POST',
headers: {
...(await bearer('9860', '20250718.01')),
'Content-Type': 'application/json',
},
body: JSON.stringify({
AdditionalPlayerIds: [9861],
CorrelationId: '3c60e657-21c4-46be-815c-57ee51add506',
JoinMode: 2,
InviteMode: 20,
}),
})
expect(res.status).toBe(200)
const leader = (await res.json()) as { RoomInstance: { RoomInstanceId: number } }
// The party member's frame is a v2 invite naming a REAL row id.
const frames = (await (await hub().fetch('http://do/all')).json()) as Sent[]
const invite = frames.find((f) => f.playerId === 9861)
expect(invite?.data.Type).toBe(6) // MessageType.GameInviteV2
const { InviteId } = JSON.parse(invite?.data.Data ?? '{}') as { InviteId: number }
expect(InviteId).toBeGreaterThan(0)
// Redeeming it puts the member in the leader's instance rather than answering 40.
const joined = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${InviteId}`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
expect(joined.ErrorCode).toBe(0)
expect(joined.RoomInstance?.RoomInstanceId).toBe(leader.RoomInstance.RoomInstanceId)
// The by-sender redemption the newer client falls back to works off the same row.
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9861').run()
const bySender = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/9860`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number }
expect(bySender.ErrorCode).toBe(0)
})
test('POST /matchmake/invite/:id lands the invitee in the inviters instance', async () => {
// 8801 invites 8802. The invite row is what POST /invite answers with.
const instance = await createRoomInstance(env.DB, {
@@ -2933,6 +3097,146 @@ describe('auth-gated endpoints', () => {
).toBe(401)
})
test('POST /matchmake/v2/player/:id joins the inviter, invite row required', async () => {
// 8811 stands in an instance and invites 8812 (writing the room_invite row).
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8811,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
await setPresence(env.DB, {
accountId: 8811,
roomInstance: instance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
const join = async (targetId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=82c12c19-a3fc-4734-9abc-e912aeb1f351&MaxPersistenceVersion=227&PlayerIsPartyMember=False',
})
// No invite from the target yet → 40, and nothing about their state leaks.
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8811')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
})
// An invite from a DIFFERENT player doesn't authorize this target: 8813 holds no
// invite from 8811.
expect(await (await join(8811, '8813')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// The invitee lands in the inviter's instance, in the PascalCase v2 envelope with
// the CorrelationId echoed back.
const ok = await join(8811, '8812')
expect(ok.status).toBe(200)
const okBody = (await ok.json()) as Record<string, unknown>
expect(okBody).toMatchObject({
ErrorCode: 0,
CorrelationId: '82c12c19-a3fc-4734-9abc-e912aeb1f351',
RoomInstance: {
RoomInstanceId: instance.roomInstanceId,
RoomId: 2,
Name: '^RecCenter',
MatchmakingPolicy: 0,
},
})
// The exact wire shape, confirmed against the live client: the three-key envelope
// and the 15-key v2 instance, nothing extra (no Photon coordinates, no DataBlob).
expect(Object.keys(okBody).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
expect(Object.keys(okBody.RoomInstance as object).sort()).toEqual(
[
'RoomInstanceId',
'RoomId',
'SubRoomId',
'Location',
'EventId',
'ClubId',
'RoomCode',
'Name',
'MaxCapacity',
'IsFull',
'IsPrivate',
'IsInProgress',
'EncryptVoiceChat',
'RoomInstanceType',
'MatchmakingPolicy',
].sort()
)
// The invite was spent by that join: the row is gone, so the same call now reads as
// "no invite" (40) rather than authorizing a second entry off the same invite.
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8811 AND to_player_id = 8812'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// With a fresh invite, standing there already is 17, not a second join — and a
// refusal leaves that invite standing, which the PlayerNotOnline case below redeems.
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8811')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
})
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 17,
RoomInstance: null,
})
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee
// is moved out first so the AlreadyIn check doesn't answer ahead of it.)
await setPresence(env.DB, {
accountId: 8812,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8811').run()
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 2,
RoomInstance: null,
})
// Unauthenticated is a 401, not a refusal code.
expect(
(await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/8811`, { method: 'POST' })).status
).toBe(401)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -2973,6 +3277,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/v2/player/{playerId}',
'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player/exclusivelogin',
@@ -3086,6 +3391,85 @@ describe('account bans', () => {
})
})
// A 2023 client (`rn.ver` 20230414) can't load a scene saved at persistence version 227 or
// later, so any room with such a subroom refuses it with UpdateRequired. Every other build
// gets in as usual.
describe('persistence version gate for the 2023 client', () => {
const matchmake = async (roomId: number, sub: string, version?: string) => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/${roomId}`, {
method: 'POST',
headers: {
...(await bearer(sub, version)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '0' }).toString(),
})
expect(res.status).toBe(200)
return (await res.json()) as {
errorCode: number
result: number
roomInstance: { roomId: number } | null
}
}
beforeAll(async () => {
// Published scene at 227 — the first version the 2023 client can't load.
await seedRoomWithSubRooms(env.DB, {
RoomId: 7227,
Name: 'NewFormatRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 7300,
SubRooms: [
{ SubRoomId: 7227, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 },
{
SubRoomId: 7228,
UnitySceneId: SECOND_SUBROOM_SCENE,
MaxPlayers: 10,
CurrentSave: { DataBlob: 'new.room', PersistenceVersion: 227 },
},
],
} as unknown as Record<string, unknown>)
// Published at 226 — still loadable.
await seedRoomWithSubRooms(env.DB, {
RoomId: 7226,
Name: 'OldFormatRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 7300,
SubRooms: [
{
SubRoomId: 7226,
UnitySceneId: RECCENTER_SCENE,
MaxPlayers: 10,
CurrentSave: { DataBlob: 'old.room', PersistenceVersion: 226 },
},
],
} as unknown as Record<string, unknown>)
})
test('the 2023 build is refused a room with a subroom at 227+', async () => {
const res = await matchmake(7227, '7301', '20230414')
expect(res.errorCode).toBe(16) // UpdateRequired
expect(res.result).toBe(16)
expect(res.roomInstance).toBeNull()
// A point release of the same build is the same client.
expect((await matchmake(7227, '7302', '20230414.02')).errorCode).toBe(16)
})
test('the 2023 build still enters a room saved below 227', async () => {
const res = await matchmake(7226, '7303', '20230414')
expect(res.errorCode).toBe(0)
expect(res.roomInstance?.roomId).toBe(7226)
})
test('newer builds and unversioned tokens are not gated', async () => {
expect((await matchmake(7227, '7304', '20250718.01')).errorCode).toBe(0)
expect((await matchmake(7227, '7305')).errorCode).toBe(0)
})
})
// The ban follows the player past the account it was written on: a new account sharing a
// proven platform identity or an IP with a banned one is refused the same way. See
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
+1 -1
View File
@@ -68,7 +68,7 @@
},
// The operator's knobs the room substitutions (ROOM_REDIRECTS), the Photon app ids
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
// PHOTON_REGION), and the Tachyon voice server (TACHYON_HOST_PORT, TACHYON_NAME)
// PHOTON_REGION), and the Tachyon server pool (TACHYON_HOST_PORT)
// are deliberately NOT set here. They're injected at deploy time from
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
// pointing at your own Photon apps never means editing a versioned file. Unset the
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -17,7 +17,7 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -26,6 +26,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+140 -12
View File
@@ -99,6 +99,22 @@ export interface HubState {
pending: Array<{ playerId: number; count: number; latest: string }>
}
/**
* How many queued notifications one offline player may accumulate. Past this the OLDEST
* are dropped, newest kept.
*
* The queue used to be unbounded, which is fine while it only holds what one absent player
* missed and is not fine at all when delivery breaks (a reset leaves connection rows with
* no sockets, so live players' notifications queue too; see pruneDeadConnections). Then it
* grows with every notification the server sends and `flushPending` reads the lot into
* memory and writes it to a socket in a single event, which is its own way to take the
* object down.
*
* Newest-wins because these are notifications: a player returning to 500 of them is served
* no better by the 501st-oldest, and the recent ones are the ones still worth acting on.
*/
export const MAX_PENDING_PER_PLAYER = 500
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
const COACH_PLAYER_ID = 1
@@ -108,7 +124,13 @@ const COACH_MESSAGE_TYPE = 100
export class NotificationsHub extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
void ctx.blockConcurrencyWhile(async () => {
// Not floated: a rejection here is how a storage fault at wake-up surfaces
// ("Internal error in Durable Object storage caused object to be reset"), and an
// unhandled one tells us nothing about which object died or why. Catching doesn't
// prevent the reset — a throw inside blockConcurrencyWhile resets the object either
// way — it just leaves a trace when it happens.
ctx
.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS subscriptions (
connectionId TEXT NOT NULL,
@@ -128,7 +150,62 @@ export class NotificationsHub extends DurableObject<Env> {
);
CREATE INDEX IF NOT EXISTS idx_owner_player ON connection_owner(playerId);
`)
this.pruneDeadConnections()
})
.catch((err: unknown) => {
console.error('hub: storage init failed', {
error: err instanceof Error ? err.message : String(err),
})
})
}
/**
* Drop connection rows with no socket behind them.
*
* A row is written when a socket is accepted and removed when it closes, so the two
* only diverge when sockets die without `webSocketClose` running which is exactly
* what a Durable Object RESET does: the object is rebuilt, every socket is gone, and
* `connection_owner` / `subscriptions` still name all of them.
*
* Left alone those rows are worse than useless. `deliverToPlayer` finds connection ids
* for a player, sends to no sockets, and reports 0 delivered so `notifyPlayer` QUEUES
* for players who are online and reconnected long ago, and `pending` grows on every
* notification the server sends. One reset then degrades delivery indefinitely.
*
* Safe at construction because hibernation does NOT lose sockets: `getWebSockets()`
* returns them on wake, so anything missing from it is genuinely gone. And a row whose
* socket has gone can never deliver anything anyway dropping it costs nothing.
*/
private pruneDeadConnections(): void {
const live = new Set<string>()
for (const ws of this.ctx.getWebSockets()) {
const state = ws.deserializeAttachment() as SocketState | null
if (state) live.add(state.connectionId)
}
const known = this.ctx.storage.sql
.exec<{ connectionId: string }>(
`SELECT connectionId FROM connection_owner
UNION
SELECT connectionId FROM subscriptions`
)
.toArray()
.map((r) => r.connectionId)
const dead = known.filter((connectionId) => !live.has(connectionId))
if (dead.length === 0) return
for (const connectionId of dead) this.forgetConnection(connectionId)
console.warn('hub: pruned connections with no live socket', {
pruned: dead.length,
live: live.size,
})
}
/** Forget one connection: its ownership row and everything it subscribed to. */
private forgetConnection(connectionId: string): void {
this.ctx.storage.sql.exec('DELETE FROM subscriptions WHERE connectionId = ?', connectionId)
this.ctx.storage.sql.exec('DELETE FROM connection_owner WHERE connectionId = ?', connectionId)
}
/** WebSocket upgrade entrypoint — the worker forwards `/hub/v1` here. */
@@ -198,14 +275,7 @@ export class NotificationsHub extends DurableObject<Env> {
if (state) {
// Mirrors OnDisconnected: drop this connection's subscriptions, which
// also removes it from every player's connection set.
this.ctx.storage.sql.exec(
'DELETE FROM subscriptions WHERE connectionId = ?',
state.connectionId
)
this.ctx.storage.sql.exec(
'DELETE FROM connection_owner WHERE connectionId = ?',
state.connectionId
)
this.forgetConnection(state.connectionId)
}
try {
ws.close()
@@ -384,14 +454,41 @@ export class NotificationsHub extends DurableObject<Env> {
}
if (delivered === 0) {
this.queuePending(playerId, payload)
return { delivered: 0, queued: true }
}
return { delivered, queued: false }
}
/**
* Queue a notification for a player who wasn't reachable, keeping the queue to
* {@link MAX_PENDING_PER_PLAYER}. Trimming is oldest-first and happens on the write, so
* the bound holds no matter how the queue got long.
*/
private queuePending(playerId: number, payload: string): void {
this.ctx.storage.sql.exec(
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
playerId,
payload
)
return { delivered: 0, queued: true }
const trimmed = this.ctx.storage.sql
.exec(
`DELETE FROM pending
WHERE playerId = ?1 AND id NOT IN (
SELECT id FROM pending WHERE playerId = ?1 ORDER BY id DESC LIMIT ?2
)
RETURNING id`,
playerId,
MAX_PENDING_PER_PLAYER
)
.toArray().length
if (trimmed > 0) {
console.warn('hub: pending queue full, dropped oldest notifications', {
playerId,
dropped: trimmed,
cap: MAX_PENDING_PER_PLAYER,
})
}
return { delivered, queued: false }
}
/**
@@ -448,6 +545,28 @@ export class NotificationsHub extends DurableObject<Env> {
return { sent: this.broadcastToConnected(payload) }
}
/**
* The targeted form of {@link coachMessageAll}: the same `MessageReceived` frame from
* the Coach account (player 1), addressed to one player with a `ToPlayerId` the shape
* `api`'s player-to-player send uses.
*
* Unlike the broadcast this one QUEUES when the recipient is offline (see
* {@link notifyPlayer}). The broadcast is online-only because it has no recipient to
* hold anything for; a message written to a named player is worth keeping until they
* next connect.
*/
async coachMessage(
playerId: number,
content: string
): Promise<{ delivered: number; queued: boolean }> {
return this.notifyPlayer(playerId, NotificationType.MessageReceived, {
FromPlayerId: COACH_PLAYER_ID,
ToPlayerId: playerId,
Type: COACH_MESSAGE_TYPE,
Data: content,
})
}
/** Broadcast a notification to every connected (handshaken) client. */
async broadcast(
notificationType: string | number,
@@ -558,7 +677,16 @@ export class NotificationsHub extends DurableObject<Env> {
let delivered = 0
for (const connectionId of connectionIds) {
for (const ws of this.ctx.getWebSockets(connectionId)) {
const sockets = this.ctx.getWebSockets(connectionId)
// A connection id with no socket behind it is a row left over from a close we
// never saw — a reset, most likely (see pruneDeadConnections). Repaired the
// moment we trip over it, or every later send for this player queues instead of
// delivering, forever.
if (sockets.length === 0) {
this.forgetConnection(connectionId)
continue
}
for (const ws of sockets) {
ws.send(this.invocation('Notification', [payload]))
delivered++
}
+83 -15
View File
@@ -20,6 +20,62 @@ import type { App } from './context'
/** The hub state is global → one DO instance. */
const HUB_INSTANCE = 'global'
/**
* How many times to re-issue a hub call Cloudflare aborted mid-flight.
*
* The platform occasionally resets a Durable Object under us "Internal error in Durable
* Object storage caused object to be reset", carrying `retryable: true` and
* `durableObjectReset: true`. It is not a fault in the call: the object is rebuilt from its
* last durable state and the same call succeeds. Without a retry it surfaces as a 500 and
* the notification is simply lost, which is why these arrive periodically rather than
* predictably.
*
* Two attempts after the first is plenty a reset that persists past that is an outage,
* not a blip, and the caller should hear about it.
*/
const HUB_RETRIES = 2
/**
* Whether an error is one Cloudflare says to retry. `retryable` is set on the error the
* runtime throws; `durableObjectReset` accompanies the reset flavour of it. Anything else
* a bug in a hub method, a bad argument is thrown straight back, since retrying it would
* only produce the same failure more slowly.
*/
function isRetryableHubError(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false
const fields = err as { retryable?: unknown; durableObjectReset?: unknown }
return fields.retryable === true || fields.durableObjectReset === true
}
/**
* Call the hub, retrying a reset. A FRESH stub per attempt: the one that threw is bound to
* the object that just died.
*
* Safe to retry because a reset rolls the object back the aborted call left nothing
* behind and because every frame the hub sends is a complete, absolute statement (a
* notification, not a delta), so a duplicate is at worst a repeat and never a drift.
*/
async function hubCall<T>(
c: Context<App>,
call: (hub: ReturnType<App['Bindings']['RECFLARE_NOTIFICATIONS_HUB']['getByName']>) => Promise<T>
): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= HUB_RETRIES; attempt++) {
try {
return await call(c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE))
} catch (err) {
if (!isRetryableHubError(err)) throw err
lastError = err
logger.warn('hub call reset, retrying', {
attempt: attempt + 1,
of: HUB_RETRIES + 1,
error: err instanceof Error ? err.message : String(err),
})
}
}
throw lastError
}
/**
* A valid notification `Id` a client-defined string tag (e.g. "AccountUpdate")
* or a numeric code. An empty string is treated as missing.
@@ -125,7 +181,9 @@ const app = new Hono<App>()
// DO's proof of identity, so a client sending its own must not be believed.
request.headers.set(OWNER_HEADER, String(playerId))
return c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).fetch(request)
// The upgrade is retried too: it is a bodyless GET, so re-issuing it is free, and a
// reset here would otherwise fail the client's connect outright.
return hubCall(c, (hub) => hub.fetch(new Request(request)))
})
// ---- Internal service-to-service send/broadcast --------------------------
@@ -144,11 +202,8 @@ const app = new Hono<App>()
if (!body || typeof body.playerId !== 'number' || !isNotificationType(body.notificationType)) {
return c.json({ error: 'playerId and notificationType are required' }, 400)
}
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
body.playerId,
body.notificationType,
body.data
)
const { playerId, notificationType, data } = body
const result = await hubCall(c, (hub) => hub.notifyPlayer(playerId, notificationType, data))
return c.json({ success: true, ...result })
})
@@ -159,10 +214,8 @@ const app = new Hono<App>()
if (!body || !isNotificationType(body.notificationType)) {
return c.json({ error: 'notificationType is required' }, 400)
}
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
body.notificationType,
body.data
)
const { notificationType, data } = body
const result = await hubCall(c, (hub) => hub.broadcast(notificationType, data))
return c.json({ success: true, ...result })
})
@@ -171,8 +224,24 @@ const app = new Hono<App>()
const body = await c.req.json<{ messageContent?: string }>().catch(() => null)
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
const result =
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessageAll(content)
const result = await hubCall(c, (hub) => hub.coachMessageAll(content))
return c.json({ success: true, ...result })
})
// The targeted form of the broadcast above: one coach message to ONE player. Queued
// by the hub when they're offline (unlike coach-message-all, which reaches only
// whoever is connected), so this arrives either way.
.post('/internal/coach-message', async (c) => {
const body = await c.req
.json<{ playerId?: number; messageContent?: string }>()
.catch(() => null)
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
if (typeof body?.playerId !== 'number' || !Number.isInteger(body.playerId)) {
return c.json({ error: 'playerId is required' }, 400)
}
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
const { playerId } = body
const result = await hubCall(c, (hub) => hub.coachMessage(playerId, content))
return c.json({ success: true, ...result })
})
@@ -180,7 +249,7 @@ const app = new Hono<App>()
// didn't arrive: which connections are live, which players each one receives for,
// and what's queued for a player who wasn't reachable.
.get('/internal/hub-state', async (c) => {
return c.json(await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).inspect())
return c.json(await hubCall(c, (hub) => hub.inspect()))
})
// Discard queued notifications, for `?playerId=` or — with the explicit `?all=true`,
@@ -196,8 +265,7 @@ const app = new Hono<App>()
return c.json({ error: 'pass playerId, or all=true to clear every queue' }, 400)
}
const result =
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).clearPending(playerId)
const result = await hubCall(c, (hub) => hub.clearPending(playerId))
logger.info('cleared pending notifications', { playerId: playerId ?? null, ...result })
return c.json({ success: true, ...result })
})
+140 -3
View File
@@ -1,10 +1,12 @@
import { adminSecretsStore, env } from 'cloudflare:test'
import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../notify.app'
import type { Env } from '../../context'
import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub'
import type { HubState } from '../../notifications-hub'
declare module 'cloudflare:test' {
@@ -65,7 +67,12 @@ interface HubRecord {
async function connect(
id: string,
opts: { headers?: Record<string, string>; query?: string } = {}
): Promise<{ ws: WebSocket; waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord> }> {
): Promise<{
ws: WebSocket
waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord>
/** Everything this socket has received so far — for asserting something did NOT arrive. */
records: HubRecord[]
}> {
// The hub only accepts identified connections, so default to a token; pass explicit
// `headers` (`{}` for none) where the test is about who is connecting.
const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient']))
@@ -113,7 +120,7 @@ async function connect(
ws.send(`{"protocol":"json","version":1}${RS}`)
await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
return { ws, waitFor }
return { ws, waitFor, records }
}
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
@@ -337,6 +344,72 @@ describe('notification delivery', () => {
b.ws.close()
})
test('coach-message reaches only the named player, addressed to them', async () => {
const playerId = 9010
const target = await connect('coach-one', {
headers: await bearer(String(playerId), ['gameClient']),
})
const bystander = await connect('coach-one-bystander')
send(target.ws, {
type: 1,
invocationId: 's',
target: 'SubscribeToPlayers',
arguments: [{ playerIds: [playerId] }],
})
await target.waitFor((r) => r.type === 3 && r.invocationId === 's')
const res = await post('/internal/coach-message', { playerId, messageContent: 'just you' })
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ delivered: 1, queued: false })
const note = await target.waitFor((r) => r.type === 1 && r.target === 'Notification')
const payload = JSON.parse((note.arguments as string[])[0]) as {
Id: string
Msg: Record<string, unknown>
}
expect(payload.Id).toBe('2') // MessageReceived
// Same Coach frame as the broadcast, plus the recipient the broadcast can't name.
expect(payload.Msg).toMatchObject({
FromPlayerId: 1,
ToPlayerId: playerId,
Type: 100,
Data: 'just you',
})
// The point of the targeted send: nobody else's socket sees it. The bystander is
// subscribed to nothing, and a broadcast would have reached it regardless.
expect(bystander.records.some((r) => r.target === 'Notification')).toBe(false)
target.ws.close()
bystander.ws.close()
})
test('coach-message queues for a player who is offline', async () => {
const playerId = 9011
const res = await post('/internal/coach-message', {
playerId,
messageContent: 'catch you later',
})
expect(await res.json()).toMatchObject({ delivered: 0, queued: true })
// Unlike the broadcast, it survives until they connect.
const { ws, waitFor } = await connect('coach-one-late')
send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }] })
const note = await waitFor((r) => r.type === 1 && r.target === 'Notification')
expect(
(JSON.parse((note.arguments as string[])[0]) as { Msg: Record<string, unknown> }).Msg
).toMatchObject({ FromPlayerId: 1, ToPlayerId: playerId, Data: 'catch you later' })
ws.close()
})
test('coach-message 400s without a player or a message', async () => {
expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400)
expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400)
expect(
(await post('/internal/coach-message', { playerId: 9012, messageContent: ' ' })).status
).toBe(400)
})
test('coach-message-all 400s on an empty message', async () => {
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
expect(res.status).toBe(400)
@@ -582,6 +655,70 @@ describe('clearing pending notifications', () => {
})
})
// What a Durable Object RESET leaves behind, and how the hub recovers from it. A reset
// kills every socket without running webSocketClose, so the connection rows outlive the
// sockets they name — and a stale row makes deliverToPlayer report 0 delivered, which
// queues notifications for players who are online.
describe('recovering from lost sockets', () => {
const hubState = async (): Promise<HubState> =>
(await (
await exports.default.fetch(`${ORIGIN}/internal/hub-state`, {
headers: await bearer('1', ['gameClient', 'moderator']),
})
).json()) as HubState
test('forgets a connection whose socket is gone, instead of queueing to it forever', async () => {
const playerId = 9301
const { ws } = await connect('conn-reset', {
headers: await bearer(String(playerId), ['gameClient']),
})
// Kill the socket the way a reset does — no close frame, so the DO never runs
// webSocketClose and the row survives.
await ws.close()
// First send after the socket died: nothing to deliver to, so it queues...
const first = await post('/internal/notify', { playerId, notificationType: 40, data: {} })
expect(await first.json()).toMatchObject({ queued: true, delivered: 0 })
// ...and the dead row is dropped on the way, so the player no longer looks
// connected to anything.
const state = await hubState()
expect(state.connections.find((c) => c.connectionId === 'conn-reset')).toBeUndefined()
})
})
describe('pending queue bound', () => {
test('keeps the newest notifications and drops the oldest past the cap', async () => {
// Unbounded, this is what a broken delivery path fills up — and what flushPending
// then reads into memory in one go. Seeded straight into the object: the point is
// the bound, not the 500 HTTP round trips it would take to reach it.
const playerId = 9302
const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await runInDurableObject(stub, (_instance, state) => {
for (let i = 0; i < MAX_PENDING_PER_PLAYER + 100; i++) {
state.storage.sql.exec(
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
playerId,
JSON.stringify({ Id: '90', Msg: { i } })
)
}
})
// The next queued notification is what enforces the bound.
await post('/internal/notify', { playerId, notificationType: 90, data: { last: true } })
const state = (await (
await exports.default.fetch(`${ORIGIN}/internal/hub-state`, {
headers: await bearer('1', ['gameClient', 'moderator']),
})
).json()) as HubState
const queued = state.pending.find((p) => p.playerId === playerId)
expect(queued?.count).toBe(MAX_PENDING_PER_PLAYER)
// Newest-wins: the one just sent survived, the oldest hundred did not.
expect(JSON.parse(queued!.latest)).toEqual({ Id: '90', Msg: { last: true } })
})
})
// 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', () => {
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -17,7 +17,7 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -26,6 +26,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+7
View File
@@ -10,11 +10,18 @@ Player-settings worker served on the `playersettings` subdomain.
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`).
- `DELETE /playersettings``[Authorize]`. Removes a setting from the player's
map. The client sends a bare form-urlencoded `key=PlayerShoppingBagId` (no
`value`); a JSON body and a `?key=` query param are also read. Deleting a key
that isn't stored is a no-op `200`, not a `404`.
> A full settings PUT would replace the player's _entire_ settings set on each
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
> doesn't wipe the others.
> Emptying the map with DELETE puts the player back to a first read: the next
> `GET` re-seeds the defaults.
## KV namespace
```sh
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+20
View File
@@ -103,3 +103,23 @@ export const SettingJsonWrite = z.union([
})
),
])
/**
* The form-encoded delete the client actually sends: a bare `key=PlayerShoppingBagId`,
* with no `value`. An empty `key` is ignored.
*/
export const SettingFormDelete = z.object({
key: z.string().describe('The setting name to remove; an empty key is ignored'),
})
/**
* The JSON form of the same delete. Accepted as a bare setting name, a `{ key }` /
* `{ Key }` object, or an array of either.
*/
export const SettingJsonDelete = z.union([
z.string(),
z.object({ key: z.string().optional(), Key: z.string().optional() }),
z.array(
z.union([z.string(), z.object({ key: z.string().optional(), Key: z.string().optional() })])
),
])
+95 -1
View File
@@ -12,7 +12,9 @@ import {
HealthResponse,
json,
PlayerSettingEntry,
SettingFormDelete,
SettingFormWrite,
SettingJsonDelete,
SettingJsonWrite,
UNAUTHORIZED_RESPONSE,
} from './openapi'
@@ -26,7 +28,7 @@ import type { App } from './context'
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read.
*
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
* Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker.
*/
/**
@@ -78,6 +80,45 @@ async function parseSettings(c: Context<App>): Promise<Array<{ key: string; valu
return key ? [{ key, value }] : []
}
/**
* Pull the setting name(s) to remove out of a DELETE body. The client sends a bare
* form-urlencoded `key=PlayerShoppingBagId` with no `value`, and (unlike its PUTs) not
* always a `content-type` Hono's body parser recognises on a DELETE, so an unparsed body
* is re-read as raw text. A JSON body is accepted too, as a bare string, a `{ key }`
* object, or an array of either. Blank names are dropped.
*/
async function parseDeleteKeys(c: Context<App>): Promise<string[]> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await c.req.json<unknown>().catch(() => null)
const list = Array.isArray(body) ? body : body == null ? [] : [body]
return list
.map((o) => {
if (typeof o === 'string') return o
const rec = o as Record<string, unknown>
const key = rec.key ?? rec.Key
return typeof key === 'string' ? key : ''
})
.filter((k) => k !== '')
}
// Pick ONE read of the body from the content-type: Hono's parser only recognises the
// form types, and re-reading as text after it has cached a FormData re-serialises the
// body as multipart, so trying both in turn parses garbage.
let key = ''
if (contentType.includes('form-data') || contentType.includes('x-www-form-urlencoded')) {
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (typeof form.key === 'string') key = form.key
} else {
key = new URLSearchParams(await c.req.text().catch(() => '')).get('key') ?? ''
}
// Last resort, for a client that hangs the name off the URL instead.
if (key === '') key = c.req.query('key') ?? ''
return key ? [key] : []
}
const app = new Hono<App>()
.use(
'*',
@@ -180,6 +221,59 @@ const app = new Hono<App>()
}
)
// Remove a setting from the caller's map. The client sends `key=PlayerShoppingBagId`
// when it drops a value it no longer wants defaulted (a stale shopping bag id, say)
// rather than writing an empty string over it.
.delete(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Delete a player setting',
description: [
'Removes the named setting(s) from the callers KV map. The client sends a bare',
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
'Deleting a key that isnt stored, or sending nothing to delete, is a no-op 200, not a',
'404. Empty body on success.',
'',
'Note that emptying the map entirely puts the player back to a first read: the next',
'`GET` re-seeds the defaults.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormDelete, SettingJsonDelete, 'The setting(s) to remove'),
responses: {
200: { description: 'Removed, or nothing to remove (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const keys = await parseDeleteKeys(c)
if (keys.length === 0) return c.body(null, 200)
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
if (!existing) return c.body(null, 200)
const remaining = { ...existing }
let removed = false
for (const key of keys) {
if (key in remaining) {
delete remaining[key]
removed = true
}
}
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
return c.body(null, 200)
}
)
// 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(
@@ -53,6 +53,17 @@ function putForm(
}
}
function deleteForm(
fields: Record<string, string>,
headers: Record<string, string> = {}
): RequestInit {
return {
method: 'DELETE',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields).toString(),
}
}
describe('playersettings endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
@@ -134,6 +145,80 @@ describe('playersettings endpoints', () => {
expect(res.status).toBe(200)
})
it('DELETE /playersettings 401s without a token', async () => {
const res = await SELF.fetch(
`${ORIGIN}/playersettings`,
deleteForm({ key: 'PlayerShoppingBagId' })
)
expect(res.status).toBe(401)
})
it('DELETE /playersettings removes the named key and leaves the rest', async () => {
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerShoppingBagId', value: 'bag-1' }, await bearer('20'))
)
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerSessionCount', value: '3' }, await bearer('20'))
)
const res = await SELF.fetch(
`${ORIGIN}/playersettings`,
deleteForm({ key: 'PlayerShoppingBagId' }, await bearer('20'))
)
expect(res.status).toBe(200)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:20',
'json'
)
expect(stored).toEqual({ PlayerSessionCount: '3' })
})
it('DELETE /playersettings reads a body with no content-type', async () => {
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerShoppingBagId', value: 'bag-2' }, await bearer('21'))
)
const res = await SELF.fetch(`${ORIGIN}/playersettings`, {
method: 'DELETE',
headers: await bearer('21'),
body: 'key=PlayerShoppingBagId',
})
expect(res.status).toBe(200)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:21',
'json'
)
expect(stored).toEqual({})
})
it('DELETE /playersettings 200s for an unknown key and an empty body', async () => {
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'A', value: '1' }, await bearer('22'))
)
const unknown = await SELF.fetch(
`${ORIGIN}/playersettings`,
deleteForm({ key: 'NotStored' }, await bearer('22'))
)
expect(unknown.status).toBe(200)
const empty = await SELF.fetch(`${ORIGIN}/playersettings`, deleteForm({}, await bearer('22')))
expect(empty.status).toBe(200)
// Neither call touched the stored map.
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:22',
'json'
)
expect(stored).toEqual({ A: '1' })
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -154,7 +239,12 @@ describe('playersettings endpoints', () => {
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings'])
expect([...documented].sort()).toEqual([
'DELETE /playersettings',
'GET /',
'GET /playersettings',
'PUT /playersettings',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
@@ -0,0 +1,19 @@
-- Per-room leaderboard definitions. Generated from packages/domain/src/rooms-db.ts
-- (ROOM_SCHEMA_DDL) — keep in sync.
--
-- One row per (room, leaderboard). `leaderboard_id` is the client's slot number — small
-- ordinals (1, 2, 3…), unique only within the room, which is why the pair is the primary
-- key rather than the id alone. Re-posting a slot (POST /rooms/:id/leaderboards/:lid)
-- reconfigures it in place; DELETE on the same path removes it.
--
-- `sort_ascending` stores the client's `sortAscending=True/False` as 1/0; `stat_format`
-- is the client's `statFormat` int, echoed back as stored.
CREATE TABLE IF NOT EXISTS room_leaderboard (
room_id INTEGER NOT NULL,
leaderboard_id INTEGER NOT NULL,
leaderboard_title TEXT NOT NULL,
stat_format INTEGER NOT NULL DEFAULT 0,
sort_ascending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, leaderboard_id)
);
@@ -0,0 +1,12 @@
-- Retire `FriendlyName` from the room blob.
--
-- The rename route (PUT /rooms/:id/name) briefly wrote `FriendlyName` alongside `Name`,
-- and every read defaulted it to `Name`. Neither exists any more: this server serves no
-- display name apart from the unique `Name`, and a stored value would otherwise survive in
-- the blob forever (`json_set` on rename is gone, so nothing would ever update it again).
--
-- Strip the key from every room that carries one. `json_remove` on a blob without the key
-- is a no-op, so the WHERE only spares the rows that need no rewrite.
UPDATE room
SET data = json_remove(data, '$.FriendlyName')
WHERE json_type(data, '$.FriendlyName') IS NOT NULL;
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+36 -3
View File
@@ -98,6 +98,12 @@ 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')
/** The `:leaderboardId` path parameter on the room leaderboard routes. */
export const leaderboardIdParam = idParam(
'leaderboardId',
'The leaderboard slot within the room — small ordinals (1, 2, 3…), unique per room only'
)
/** 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' } }
@@ -311,9 +317,6 @@ export const RoomDto = z.object({
.int()
.nullable()
.describe('The rooms published snapshot. Nothing takes snapshots here, so always null'),
FriendlyName: z
.string()
.describe('Display name. Nothing sets one apart from `Name` here, so it mirrors `Name`'),
CCU: z
.int()
.nullable()
@@ -609,6 +612,28 @@ export const RoomBanEnvelope = z.object({
value: RoomBanDto.nullable().describe('Null on a rejection'),
})
/** `POST /rooms/{roomId}/leaderboards/{leaderboardId}` — configure one leaderboard slot. */
export const LeaderboardRequest = z.object({
leaderboardTitle: z.string().describe('The title the board displays'),
statFormat: z.string().optional().describe('The stat-format int; defaults to 0'),
sortAscending: z
.string()
.optional()
.describe('`True` / `False` — whether lower scores rank first. Defaults to `False`'),
})
/**
* What both leaderboard routes answer: a bare success/failure carrying no entity.
* PascalCase `Success`/`Error` with a lowercase `error_id` the same mixed casing the
* unprefixed isBanned envelope has ({@link IsBannedPascalEnvelope}), NOT the room
* mutations' lowercase `{ success, error, value }`.
*/
export const LeaderboardResultEnvelope = z.object({
Success: z.boolean(),
Error: z.string().nullable().describe('The message shown on a rejection; null on success'),
error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'),
})
/** `PUT /rooms/{roomId}/warning`. */
export const WarningRequest = z.object({
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
@@ -849,6 +874,14 @@ export const RoomExperiencePlayer = z
.array(z.unknown())
.describe('Always empty — no per-room experience is tracked')
/**
* `GET /showcase/{playerId}` the rooms a player showcases on their profile. Stubbed
* empty; nothing stores a showcase, so the element shape is unknown until something does.
*/
export const ShowcasedRooms = z
.array(z.unknown())
.describe('Always empty — no room showcase is stored')
/**
* `GET /rooms/curated_playlists` the curated room playlists the discovery pages'
* playlist sections draw from. Nothing curates one on this server, so the list is always
+144 -3
View File
@@ -14,6 +14,7 @@ import {
countRoomsByCreator,
createSubRoom,
deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom,
findSubRoom,
getBaseRooms,
@@ -47,6 +48,7 @@ import {
searchRooms,
setRoomDescription,
setRoomImage,
setRoomLeaderboard,
setRoomName,
setRoomRole,
setSubRoomPermissions,
@@ -90,6 +92,9 @@ import {
IsBannedPascalEnvelope,
json,
jsonBody,
leaderboardIdParam,
LeaderboardRequest,
LeaderboardResultEnvelope,
LoadScreenRequest,
MissingLookupParam,
ModifySubRoomRequest,
@@ -118,6 +123,7 @@ import {
SaveSubRoomDataRequest,
SearchSuggestions,
ServiceStatus,
ShowcasedRooms,
stringQuery,
SubRoomAccessibilityRequest,
SubRoomDataSaveResponseDto,
@@ -643,6 +649,15 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
*/
const banEnvelope = roomEnvelope
/**
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
* no entity. PascalCase with a lowercase `error_id` the same mixed casing the
* unprefixed isBanned route serves NOT the room mutations' lowercase envelope.
*/
function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
return c.json({ Success: error === null, Error: error, error_id: null })
}
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
@@ -1268,6 +1283,29 @@ const app = new Hono<App>()
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
// separate from `ownedby/{accountId}` (which is everything public they own). Stub →
// empty list: nothing stores a showcase yet, and an empty rail is what a player who
// has picked nothing looks like, where a 404 leaves the profile half-drawn. No auth,
// matching the profile list it sits beside — a showcase is public by definition.
.get(
'/showcase/:playerId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'A players showcased rooms',
description: [
'The rooms a player has showcased on their profile, as a bare array. Nothing stores a',
'showcase yet, so this is a stub serving an empty list — which the client reads as',
'“nothing showcased”, the same as a player who has picked none. Unlike',
'`ownedby/{accountId}`, which lists everything public the account owns, a showcase is',
'a chosen subset. No auth: a profile is public.',
].join(' '),
parameters: [playerIdParam],
responses: { 200: json(ShowcasedRooms, 'An empty list') },
}),
(c) => c.json([])
)
// Rooms the caller has favorited (from the interaction table). Auth-gated.
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
// other room-source `*by/me` lists the client loads.
@@ -1676,13 +1714,11 @@ const app = new Hono<App>()
})
}
// Writes `FriendlyName` too — see `setRoomName`. The two are the same string here,
// and the client labels the room from the display one.
await setRoomName(c.env.DB, roomId, name)
// The rename answers a bare `{ Success }` with no room in it, so the client has
// nothing to re-render from and kept showing the old name until the push arrived.
// Built from the room already in hand rather than re-read, like the image route's.
await pushRoomUpdate(c, accountId, { ...room, Name: name, FriendlyName: name })
await pushRoomUpdate(c, accountId, { ...room, Name: name })
return roomResult(c, { Success: true })
}
)
@@ -2236,6 +2272,111 @@ const app = new Hono<App>()
}
)
// Configure one of a room's leaderboard slots (form body `leaderboardTitle` +
// `statFormat` + `sortAscending`). Auth-gated (401) and owner/co-owner-only (403).
// One row per (room, slot) — re-posting a slot reconfigures it, so the call is
// idempotent.
.post(
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Configure a room leaderboard',
description: [
'Creates or reconfigures one leaderboard slot in the `room_leaderboard` table — one',
'row per (room, slot), so re-posting a slot rewrites its title, format and direction',
'rather than adding a second. The slot number in the path is the clients small',
'ordinal (1, 2, 3…), unique only within the room. Owner or co-owner only (403',
'otherwise).',
'',
'`statFormat` is stored verbatim (default 0); `sortAscending` is the clients',
'`True`/`False` string (default `False`).',
'',
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
'mutations lowercase `{ success, error, value }`.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
responses: {
200: json(LeaderboardResultEnvelope, 'Stored, 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 leaderboardEnvelope(c, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const title = typeof body.leaderboardTitle === 'string' ? body.leaderboardTitle : ''
const statFormat =
typeof body.statFormat === 'string' ? Number.parseInt(body.statFormat, 10) : Number.NaN
// The client sends .NET-style `True`/`False`; anything but a `true` reads false.
const sortAscending =
typeof body.sortAscending === 'string' && body.sortAscending.toLowerCase() === 'true'
await setRoomLeaderboard(
c.env.DB,
roomId,
leaderboardId,
title,
Number.isNaN(statFormat) ? 0 : statFormat,
sortAscending
)
return leaderboardEnvelope(c)
}
)
// Remove one of a room's leaderboard slots. Auth-gated (401) and owner/co-owner-only
// (403). The client fires these blindly for every slot when tearing boards down, so a
// slot that isn't configured is a rejection envelope, not an HTTP error.
.delete(
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Remove a room leaderboard',
description: [
'Removes the slots `room_leaderboard` row. Owner or co-owner only (403 otherwise).',
'',
'Removing a slot that isnt configured is a rejection (`Success: false`), not a',
'silent success — the caller asked to undo something that was not there. The client',
'deletes slots blindly when tearing boards down and tolerates the refusal.',
'',
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, leaderboardIdParam],
responses: {
200: json(LeaderboardResultEnvelope, 'Removed, 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 leaderboardEnvelope(c, 'This room does not exist!')
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
return leaderboardEnvelope(c)
}
)
// 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
+115 -15
View File
@@ -156,16 +156,16 @@ describe('rooms endpoints', () => {
// None of these are stored — the seed blobs predate the keys — so they are defaulted on
// read. The client's room DTO always carries them, and an ABSENT key is not the same as a
// zero/null one to its parser. `FriendlyName` is the one that can't be null: the client
// labels the room from it.
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', async () => {
// zero/null one to its parser. `FriendlyName` is deliberately NOT among them: this server
// does not serve a display name apart from `Name`, and migration 0017 strips any stored one.
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId and CCU, and no FriendlyName', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toHaveProperty('BoostCount', 0)
expect(body).toHaveProperty('CurrentSnapshotId', null)
expect(body).toHaveProperty('FriendlyName', body.Name)
expect(body).toHaveProperty('CCU', null)
expect(body).not.toHaveProperty('FriendlyName')
})
// Pinned whole: these are the numbers the client's publish UI counts against, and
@@ -195,6 +195,23 @@ describe('rooms endpoints', () => {
expect(await res.json()).toEqual([])
})
// Stub, same reasoning: the profile asks for a showcase for any player, and an
// unregistered path leaves the profile half-drawn. No auth, and no such player is
// still [] rather than a 404.
it('GET /showcase/:playerId returns [] for any player', async () => {
const res = await SELF.fetch(`${ORIGIN}/showcase/205`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const unknown = await SELF.fetch(`${ORIGIN}/showcase/99999`)
expect(unknown.status).toBe(200)
expect(await unknown.json()).toEqual([])
})
it('GET /showcase/:playerId 404s on a non-numeric id', async () => {
expect((await SELF.fetch(`${ORIGIN}/showcase/abc`)).status).toBe(404)
})
it('GET /rooms/:id 404s for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
expect(res.status).toBe(404)
@@ -1818,6 +1835,90 @@ describe('rooms endpoints', () => {
.run()
})
it('POST/DELETE /rooms/:id/leaderboards/:lid configures and removes a rooms leaderboard slots', async () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const del = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'DELETE',
headers: sub ? await bearer(sub) : {},
})
const slotsOf = async (roomId: number) =>
(
await env.DB.prepare(
'SELECT leaderboard_id, leaderboard_title, stat_format, sort_ascending FROM room_leaderboard WHERE room_id = ?1 ORDER BY leaderboard_id'
)
.bind(roomId)
.all()
).results
// The real client body, verbatim.
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
// No token → 401 (auth gate).
expect((await postForm('/rooms/2/leaderboards/1', body)).status).toBe(401)
expect((await del('/rooms/2/leaderboards/1')).status).toBe(401)
// A valid token but no role on the room → 403.
expect((await postForm('/rooms/2/leaderboards/1', body, '999')).status).toBe(403)
expect((await del('/rooms/2/leaderboards/1', '999')).status).toBe(403)
// The envelope both routes answer — PascalCase `Success`/`Error`, lowercase
// `error_id`, no entity. NOT the room mutations' lowercase `{ success, error, value }`.
const OK = { Success: true, Error: null, error_id: null }
// Unknown room → failure envelope.
expect(await (await postForm('/rooms/99999/leaderboards/1', body, '1')).json()).toEqual({
Success: false,
Error: 'This room does not exist!',
error_id: null,
})
// The owner configures slot 1 — a bare success, with the row persisted.
const ok = await postForm('/rooms/2/leaderboards/1', body, '1')
expect(ok.status).toBe(200)
expect(await ok.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 1, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
])
// Re-posting the slot reconfigures the one row rather than appending — and the
// co-owner may do it. `sortAscending=True` parses case-insensitively.
const rewrite = await postForm(
'/rooms/2/leaderboards/1',
{ leaderboardTitle: 'lap time', statFormat: '2', sortAscending: 'True' },
'2'
)
expect(rewrite.status).toBe(200)
expect(await rewrite.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 1, leaderboard_title: 'lap time', stat_format: 2, sort_ascending: 1 },
])
// Slots are per room: slot 2 here and slot 1 of another room are their own rows.
expect((await postForm('/rooms/2/leaderboards/2', body, '1')).status).toBe(200)
expect((await postForm('/rooms/3/leaderboards/1', body, '1')).status).toBe(200)
expect(await slotsOf(2)).toHaveLength(2)
expect(await slotsOf(3)).toHaveLength(1)
// DELETE removes exactly the named slot.
const removed = await del('/rooms/2/leaderboards/1', '1')
expect(removed.status).toBe(200)
expect(await removed.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 2, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
])
expect(await slotsOf(3)).toHaveLength(1)
// Deleting a slot that isn't configured is a rejection, not an HTTP error — the
// client tears boards down by deleting every slot blindly.
expect(await (await del('/rooms/2/leaderboards/1', '1')).json()).toEqual({
Success: false,
Error: 'This room has no such leaderboard!',
error_id: null,
})
// Clean up the surviving rows so this test leaves no trace.
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
})
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')
@@ -2915,13 +3016,12 @@ describe('rooms endpoints', () => {
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
RoomId: number
FriendlyName: string
Name: string
}
expect(room.RoomId).toBe(2)
// The DISPLAY name follows the rename. It is otherwise only defaulted to `Name` on
// read, so a room that had ever stored one would keep labelling itself with the old
// name while every name-keyed lookup used the new one.
expect(room.FriendlyName).toBe('RenamedCenter')
expect(room.Name).toBe('RenamedCenter')
// A rename must not resurrect the retired display name.
expect(room).not.toHaveProperty('FriendlyName')
})
/** The hub stub records every notifyPlayer call — see vitest.config.ts. */
@@ -2941,14 +3041,11 @@ describe('rooms endpoints', () => {
const sent = await sentNotifications()
expect(sent).toHaveLength(1)
// RoomUpdate, to the OWNER, carrying the room as it now stands — both names.
// RoomUpdate, to the OWNER, carrying the room as it now stands.
expect(sent[0].playerId).toBe(1)
expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
expect(sent[0].data).toMatchObject({
RoomId: 2,
Name: 'PushedRename',
FriendlyName: 'PushedRename',
})
expect(sent[0].data).toMatchObject({ RoomId: 2, Name: 'PushedRename' })
expect(sent[0].data).not.toHaveProperty('FriendlyName')
// Put it back for the tests that read room 2 by name.
await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
@@ -3972,6 +4069,7 @@ describe('rooms endpoints', () => {
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/leaderboards/{leaderboardId}',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /',
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
@@ -4007,9 +4105,11 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
'GET /roomserver/rooms/createdby/me',
'GET /showcase/{playerId}',
'POST /rooms/bulk',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
+104
View File
@@ -4563,6 +4563,110 @@
"LoadScreens": [],
"RestrictedCircuitsAllowListNames": []
},
{
"RoomId": 45,
"Name": "RecRally",
"Description": "A six-player off-road rally race where you boost, bump, and powerslide your way to the finish line!",
"ImageName": "RecRally.jpg",
"WarningMask": 8,
"CustomWarning": null,
"CreatorAccountId": 1,
"State": 0,
"Accessibility": 1,
"PublishState": 0,
"SupportsLevelVoting": false,
"IsRRO": true,
"IsRecRoomApproved": false,
"ExcludeFromLists": false,
"ExcludeFromSearch": false,
"SupportsScreens": true,
"SupportsWalkVR": true,
"SupportsTeleportVR": true,
"SupportsVRLow": true,
"SupportsQuest2": true,
"SupportsMobile": true,
"SupportsJuniors": true,
"MinLevel": 0,
"AgeRating": 2,
"CreatedAt": "2026-02-25T20:10:07.201204Z",
"PublishedAt": "2026-02-25T20:10:07.201204Z",
"BecameRRStudioRoomAt": null,
"Stats": {
"CheerCount": 0,
"FavoriteCount": 0,
"VisitorCount": 0,
"VisitCount": 0
},
"RankingContext": null,
"IsDorm": false,
"IsPlacePlay": false,
"MaxPlayerCalculationMode": 0,
"MaxPlayers": 6,
"CloningAllowed": true,
"DisableMicAutoMute": false,
"DisableRoomComments": false,
"EncryptVoiceChat": false,
"ToxmodEnabled": true,
"LoadScreenLocked": false,
"UgcVersion": 1,
"PersistenceVersion": 0,
"UgcSubVersion": null,
"MinUgcSubVersion": null,
"AutoLocalizeRoom": false,
"LocalizationContext": {
"TargetLocale": null,
"Scope": null,
"LocalizedFields": [
"Description",
"CustomWarning"
]
},
"IsDeveloperOwned": true,
"RankedEntityId": "45",
"BoostCount": 0,
"CurrentSnapshotId": null,
"SubRooms": [
{
"SubRoomId": 57,
"RoomId": 45,
"CreatorAccountId": null,
"UnitySceneId": "56193568-9ae0-498c-8a77-4df79dec91f5",
"Name": "Home",
"CurrentSave": null,
"LastModeratedSaveModerationState": 0,
"IsSandbox": false,
"MaxPlayers": 6,
"Accessibility": 1,
"ShouldAutoStageSaves": true,
"StagedSubRoomDataSaveId": null
}
],
"Roles": [
{
"AccountId": 1,
"Role": 255,
"LastChangedByAccountId": null,
"InvitedRole": 0
},
{
"AccountId": 2,
"Role": 30,
"LastChangedByAccountId": null,
"InvitedRole": 0
}
],
"IsJuniorCreated": false,
"Tags": [
{
"Tag": "rro",
"Type": 0
}
],
"PromoImages": [],
"PromoExternalContent": [],
"LoadScreens": [],
"RestrictedCircuitsAllowListNames": []
},
{
"RoomId": 51,
"Name": "DriveIn",
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2
View File
@@ -2,6 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/** Maximum accepted binary upload size in bytes. */
MAX_UPLOAD_BYTES?: string | number
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
+26 -1
View File
@@ -2,7 +2,13 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import {
intVar,
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
@@ -57,6 +63,19 @@ const UPLOAD_EXTENSION: Record<number, string> = {
5: '.inv',
}
/**
* A Worker must not accept an unbounded user-controlled blob into memory and R2.
* Operators can tune this for known room sizes, but an unset or invalid value keeps
* the safe 64 MiB default. Non-positive values are invalid rather than disabling the
* limit: a public upload endpoint must always have a finite ceiling.
*/
const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
function maxUploadBytes(value: unknown): number {
const configured = intVar(value, DEFAULT_MAX_UPLOAD_BYTES)
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_BYTES
}
function extensionForFileType(fileType: string): string {
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
}
@@ -130,6 +149,7 @@ const app = new Hono<App>()
200: json(UploadResponse, 'The stored (or echoed) file name'),
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The binary file exceeds the configured upload limit'),
},
}),
async (c) => {
@@ -143,6 +163,11 @@ const app = new Hono<App>()
const file = Object.values(body).find((v): v is File => v instanceof File)
if (file) {
const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES)
if (file.size > limit) {
return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413)
}
const fileType = textField(body, 'filetype') ?? '0'
const subfolder = subfolderForFileType(fileType)
if (subfolder === undefined) {
@@ -134,6 +134,24 @@ it('POST /upload 400s for a binary with an unknown/missing FileType', async () =
}
})
it('POST /upload rejects a binary above the configured size limit without storing it', async () => {
const original = env.MAX_UPLOAD_BYTES
env.MAX_UPLOAD_BYTES = '3'
try {
const res = await SELF.fetch(`${ORIGIN}/upload`, {
method: 'POST',
headers: await bearer(),
body: uploadForm('3', new Uint8Array([1, 2, 3, 4])),
})
expect(res.status).toBe(413)
expect((await res.json()) as { error: string }).toEqual({
error: 'file exceeds the 3-byte upload limit',
})
} finally {
env.MAX_UPLOAD_BYTES = original
}
})
it('POST /upload echoes an explicit name when no binary is posted', async () => {
const form = new FormData()
form.set('FileType', '3')
+119
View File
@@ -81,6 +81,125 @@ printf '1x0000000000000000000000000000000AA' |
The tests seed the same pair into their own local store in `beforeAll`.
### Benefits claim and Discord
The **Claim benefits** tab on the account page lets a player prove they hold one of
the qualifying roles in the community Discord and, if they do, gives their account Rec
Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the
app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so
that route has to keep working on a cold load even though nothing links to it. It runs a standard
OAuth2 **authorization code** flow:
1. The tab sends the browser to Discord's consent screen, using the URL `www`
assembles in `/api/config` plus a `state` nonce the page mints and stashes in
`sessionStorage`.
2. Discord redirects back to `/claim?code=…&state=…`. The page checks the nonce is
the one it minted, strips the query, and posts only the `code` to
`POST /api/benefits/claim` with the player's bearer token.
3. The worker swaps the code for an access token with the client secret, reads
`GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the
token, and — if any one of the configured roles is there — writes `hasPlus` onto
the account and links the Discord id.
**A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps
`hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the
CampusCard and the subscriber discount from that claim alone — no database read on
either path. The token the player's game is holding was minted before they claimed, it
lasts a day, and the client never refreshes it, so they have to restart Rec Room and
sign in again. The claim page says so.
The browser never holds a Discord access token: the client secret can't ship to a
page, which is why this is the second feature (after signup) with a server side.
The scopes are `identify` and `guilds.members.read`, which let the token's owner
read **their own** membership in one guild — so no bot is needed and this worker
holds no credential that could read anyone else's roles.
The verified Discord id is stored as a link in `platform_account` (the `auth`
worker's table of account ↔ external identities, migration 0007) under
`PlatformType.Discord` (101) — the same place a Steam or Meta identity lives,
because that is what it is. Only `hasPlus` goes on the account itself.
Nobody logs in with it. `auth`'s `verifyPlatformProof` can prove exactly two
platforms (Steam and Meta), so a `cached_login` naming 101 is refused outright, and
the login picker filters to those same platforms (`CACHED_LOGIN_PLATFORMS`). That
filter matters for privacy as well as correctness: the picker is public and
unauthenticated, so without it `GET /cachedlogin/forplatformid/101/<snowflake>`
would tell anyone which RecFlare account a given Discord user owns.
Storing the link there is what makes the claim once-only **per Discord user**, not
per account: a second claim from the same Discord member on a different account is
refused (409), answered from the table's index rather than a scan of every account
blob. Re-claiming on the same account is idempotent — the link is `INSERT OR
IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to
reload. Nothing revokes Plus: losing the role later leaves the flag set, so it
records "held the role once", not "holds it today".
Four settings configure it, and **all four** are required or the claim stays
closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab,
and `/api/benefits/claim` returns 403). A half-configured app is
treated as unconfigured on purpose: a client id and secret with no guild/roles
would authenticate a player and have no question left to ask about them.
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` — Secrets Store, same account-level
store as `JWT_SECRET` and the Turnstile pair. The id is public (it ships to the
browser inside the authorize URL) but lives beside its secret so one place
configures the feature.
- `DISCORD_GUILD_ID` / `DISCORD_BENEFITS_ROLE_IDS` — plain vars in `wrangler.jsonc`,
not credentials. Both hold Discord **snowflakes: all digits, no letters**. Turn on
Developer Mode in Discord (Settings → Advanced), then right-click the server or the
role and Copy ID. These are ids, not names — `Supporter` is what the role is
_called_, `1077000000000000002` is what goes in the var — and they're quoted as
strings because a snowflake is too large to survive as a JSON number.
`DISCORD_BENEFITS_ROLE_IDS` is a **list**, separated by commas and/or whitespace, so
several tiers can qualify for the same benefit. **Any one** of them is enough — they
are alternatives, not requirements:
```jsonc
"DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002"
```
Blank entries are dropped, so a trailing comma is harmless. A value that parses to no
ids at all counts as unset and closes the claim, rather than opening it with nothing
to check against.
```sh
printf '<client id>' |
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID --scopes workers --remote
printf '<client secret>' |
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET --scopes workers --remote
```
The two ids are **not secrets**, and setting only the secrets is the usual reason the
page never appears. Put them in the root `.env` as operator knobs, where they ride
along as `--var` on deploy (see `recflare_vars`), rather than editing `wrangler.jsonc`
— that keeps your server's ids out of the repo:
```sh
RECFLARE_DISCORD_GUILD_ID=1077000000000000000
RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
```
Use **commas with no spaces** there. Those knobs become `--var` flags that the deploy
script word-splits, so a value containing a space breaks it. (`parseRoleIds` also
accepts whitespace, which is fine in `wrangler.jsonc` but not via `.env`.)
Then redeploy `www` — the Secrets Store `.get()` caches per isolate, so a warm worker
won't pick up newly created secrets until it restarts.
**Diagnosing a claim that won't appear:** fetch `/api/config`. If `benefitsEnabled` is
`false`, the gate is closed and it isn't a UI problem — `www` logs
`discord is half-configured, so benefit claims are closed` with a flag per input
(`hasClientId`, `hasClientSecret`, `hasGuildId`, `roleIdCount`), which names exactly
which one is missing. `wrangler tail www` shows it.
In the [Discord developer portal](https://discord.com/developers/applications),
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
for byte: `www` derives the redirect URI from the incoming request's own origin
(never from the request body, which would turn the client secret into a redemption
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
want the flow to work under `pnpm turbo dev`.
## Development
### Run in dev mode
+4 -3
View File
@@ -18,8 +18,9 @@
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@scalar/api-reference": "1.63.0",
"hono": "4.12.27",
"@repo/jwt": "workspace:*",
"@scalar/api-reference": "1.67.0",
"hono": "4.13.5",
"react": "19.2.7",
"react-dom": "19.2.7",
"workers-tagged-logger": "1.0.1"
@@ -35,6 +36,6 @@
"@vitejs/plugin-react": "5.2.0",
"vite": "6.4.3",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+4 -6
View File
@@ -32,13 +32,11 @@ const AUTH_MESSAGES: Record<string, string> = {
'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.',
// Deliberately says nothing about when it lifts: auth sends one fixed description for
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
// here to quote.
'this account is banned': 'This account is banned and cannot be signed in to.',
// Not this account, but one it shares a device or network with. Phrased for BOTH the
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
// and for both forms, since signup and sign-in send the same description.
// and for both forms, since signup and sign-in send the same description. A directly
// banned account is not refused a sign-in at all (auth issues it a token so the game
// client can show the block screen), which is why there is no "banned" entry here.
'this device or network is blocked':
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
}
+374 -16
View File
@@ -46,6 +46,18 @@ interface Hosts {
interface SiteConfig {
signupEnabled: boolean
turnstileSiteKey: string | null
/**
* Whether the Discord-verified benefits claim is configured. False when the operator
* has no Discord app/guild/role set, in which case the claim page and its links stay
* hidden the endpoint would refuse anyway.
*/
benefitsEnabled: boolean
/**
* The Discord consent URL to send the player to, assembled by `www` (scopes and the
* redirect URI are its business, and must match what the claim will accept). Null when
* benefits are off. The `state` nonce is appended here see `startDiscordAuth`.
*/
discordAuthorizeUrl: string | null
}
/** The private self DTO from `accounts` (`GET /account/me`). */
@@ -495,6 +507,50 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
authed: true,
})
/** Where this account's benefits stand: `www` reads them off the account row. */
interface BenefitsStatus {
/** Whether the account already has Rec Room Plus. */
hasPlus: boolean
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
linked: boolean
}
/**
* The two ends of the benefits claim. Both live on `www` rather than on one of the game
* workers, because the claim needs the Discord client secret see www.app.ts.
*/
const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
call<BenefitsStatus>('/api/benefits/status', { authed: true })
/** Redeem the code Discord sent us back with. The access token never reaches this page. */
const claimBenefits = (code: string): Promise<{ discordUsername?: string }> =>
call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true })
/**
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
*
* OAuth's `state` only means anything if the same page that minted it is the one that
* checks it, so it can't come from the server. sessionStorage rather than localStorage:
* it belongs to this tab and this attempt, and it should not outlive the tab that started
* the flow.
*/
const OAUTH_STATE_KEY = 'rf_discord_state'
/**
* Send the browser to Discord's consent screen.
*
* A real navigation, not a client-side route Discord is another origin. The `state` is
* minted here and stashed for the return leg; `www` built everything else about the URL
* (see `/api/config`), so this only ever appends the one parameter it owns.
*/
function startDiscordAuth(authorizeUrl: string) {
const state = crypto.randomUUID()
sessionStorage.setItem(OAUTH_STATE_KEY, state)
const url = new URL(authorizeUrl)
url.searchParams.set('state', state)
window.location.assign(url.toString())
}
/**
* 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
@@ -516,6 +572,42 @@ const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
authed: true,
})
/**
* The same coach message to ONE player. `notify` queues it when they're offline, so
* `queued` (rather than a 0 delivery) is what "they weren't online" looks like here
* it still arrives on their next connect, unlike the broadcast.
*/
const coachMessage = (
playerId: number,
messageContent: string
): Promise<{ delivered?: number; queued?: boolean }> =>
call<{ delivered?: number; queued?: boolean }>(`${where().notify}/internal/coach-message`, {
json: { playerId, messageContent },
authed: true,
})
/**
* Resolve an `@username` to the account id the workers address a player by.
*
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
* sending a message to whoever happened to sort first would be worse than refusing. The
* exact name always sorts first among its own prefixes, so it's inside the search limit
* whenever it exists.
*/
async function accountIdForUsername(input: string): Promise<number> {
const name = input.trim().replace(/^@/, '')
if (name === '') throw new Error('Enter a username to send to.')
const matches = await call<Array<{ accountId?: number; username?: string }>>(
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
)
const found = Array.isArray(matches)
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
: undefined
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
return found.accountId
}
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
@@ -562,6 +654,214 @@ function Link({
)
}
/**
* The benefits claim itself: where the player stands, and the button that starts (or
* re-runs) the Discord round-trip.
*
* This is BOTH ends of the OAuth round-trip: it sends the player to Discord, and it is
* what renders when Discord sends them back. Which half is running is decided by whether
* the URL carries a `code`.
*
* What it never holds is a Discord access token. It forwards the one-time `code` to
* `www`, which does the exchange with the client secret and answers with a verdict; that
* is the whole reason this one feature has a server side at all.
*
* Rendered in TWO places, which is why it is a component rather than a page. Its home is
* the "Claim benefits" tab in the account dashboard, where someone would go looking for
* it. But it also has to render on `/claim`, because that path is Discord's registered
* redirect URI the browser comes back to it with a `?code=`, and it is the only URL a
* cold load can land on mid-flow. One component means the two can't drift.
*
* The effect keys off whether the URL carries a code, so the same code covers both: on
* the dashboard there is none, and it just reports status.
*/
function BenefitsPanel({ account, config }: { account: SelfAccount; config: SiteConfig }) {
const [status, setStatus] = useState<BenefitsStatus | undefined>(undefined)
const [error, setError] = useState('')
const [done, setDone] = useState('')
const [pending, setPending] = useState(false)
// Shown after a successful claim only. Plus rides on the game's token as `rn.plus`,
// stamped at login, so the copy of it the player is holding still says they have none —
// and tokens last a day and are never refreshed. Without this line the claim looks like
// it silently did nothing, which is the single most likely support question here.
const [relogin, setRelogin] = useState(false)
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second
// run would redeem a spent code and report a failure over a claim that just worked.
const redeemed = useRef(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
if (code === null) {
// Nothing came back from Discord — either the dashboard tab, or `/claim` opened
// directly. Just show where they stand. Discord also returns with
// `?error=access_denied` when someone cancels: no code, nothing to say, and the
// button is right there to try again.
void fetchBenefitsStatus()
.then(setStatus)
.catch(() => setStatus(undefined))
return
}
// The return leg. Strip the query first, whatever happens next: the code is spent by
// the request below, so a reload must not carry it (and a code has no business
// sitting in the address bar, or in whatever the player pastes it into). replaceState
// rather than a route change, so Back doesn't walk into a used code either.
window.history.replaceState(null, '', '/claim')
if (redeemed.current) return
redeemed.current = true
sessionStorage.removeItem(OAUTH_STATE_KEY)
// The nonce this tab minted must be the one that came back. A mismatch means the
// round-trip wasn't started here, which is exactly what `state` exists to catch.
if (state === null || expected === null || state !== expected) {
setError('That Discord sign-in did not match this browser. Please start again.')
return
}
setPending(true)
claimBenefits(code)
.then((result) => {
setStatus({ hasPlus: true, linked: true })
setDone(
result.discordUsername
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
: 'Verified — Rec Room Plus is now on your account.'
)
setRelogin(true)
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setPending(false))
}, [])
// Read into a local so the narrowing survives into the click handlers below.
const authorizeUrl = config.discordAuthorizeUrl
if (!config.benefitsEnabled || authorizeUrl === null) {
return (
<section className="card">
<h2>Claim benefits</h2>
<p className="muted">Benefit claims arent available on this server right now.</p>
</section>
)
}
const claimed = status?.hasPlus === true
return (
<section className="card">
<h2>Rec Room Plus</h2>
<p className="muted">
Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with
Discord and well check your roles we only ever read your username and which roles you
hold in our server.
</p>
<p className="muted">
Claiming as <strong>@{account.username}</strong> (#{account.accountId}). A Discord account
can claim on one RecFlare account only.
</p>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
{relogin && (
<p className="hint">
Restart Rec Room and sign in again to pick it up your game reads Rec Room Plus from the
session it signed in with, so it wont show until then.
</p>
)}
{pending ? (
<p className="muted">Checking your Discord roles</p>
) : claimed ? (
// Already claimed. The button stays, because a player whose roles changed (or who
// re-linked) can safely run it again — the claim is idempotent on their own
// account — but it no longer reads as the thing to do.
<>
{!done && (
<>
<p className="ok">Rec Room Plus is active on this account.</p>
<p className="hint">
If the game doesnt show it, sign out and back in Rec Room Plus is read from the
session your game signed in with.
</p>
</>
)}
<button className="linkish" onClick={() => startDiscordAuth(authorizeUrl)}>
Re-verify with Discord
</button>
</>
) : (
<button
type="button"
className="cta discord"
onClick={() => startDiscordAuth(authorizeUrl)}
>
Verify with Discord
</button>
)}
</section>
)
}
/**
* `/claim` the page Discord redirects back to.
*
* Not linked from anywhere any more: the claim lives in the account dashboard's "Claim
* benefits" tab. This route still has to exist and still has to work on a cold load,
* because it is the app's registered `redirect_uri` the browser arrives here from
* Discord carrying the `?code=`, with whatever session it has.
*
* Signing in comes FIRST, and not only because the grant needs an account to land on: the
* bearer token is what tells `www` whose row to write, so a claim without one has no
* subject. Hence the sign-in card rather than a redirect someone who arrives here from a
* link should be told what this is before being bounced to a login form.
*/
function ClaimPage({
account,
config,
navigate,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate
}) {
if (account === undefined || config === undefined) {
return (
<main className="shell">
<p className="muted">Loading</p>
</main>
)
}
if (account === null) {
return (
<main className="shell">
<h1>Claim your benefits</h1>
<section className="card">
<h2>Sign in first</h2>
<p className="muted">
Benefits are granted to a RecFlare account, so we need to know which one is yours before
you verify with Discord. If you were part-way through a claim, start it again from your
account page once youre signed in.
</p>
<Link to="/login" navigate={navigate} className="cta">
Sign in
</Link>
</section>
</main>
)
}
return (
<main className="shell">
<h1>Claim your benefits</h1>
<BenefitsPanel account={account} config={config} />
</main>
)
}
/**
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
@@ -597,7 +897,12 @@ export function App() {
.catch(() => setAccount(null))
})
.catch(() => {
setConfig({ signupEnabled: false, turnstileSiteKey: null })
setConfig({
signupEnabled: false,
turnstileSiteKey: null,
benefitsEnabled: false,
discordAuthorizeUrl: null,
})
setAccount(null)
})
}, [])
@@ -627,7 +932,11 @@ export function App() {
onAuthed={setAccount}
/>
) : path === '/account' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
<AccountPage account={account} config={config} navigate={navigate} onChange={setAccount} />
) : path === '/claim' ? (
// Its own page rather than a dashboard tab: this path is Discord's registered
// redirect URI, so it has to be one stable URL a cold load can land on.
<ClaimPage account={account} config={config} navigate={navigate} />
) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} />
) : (
@@ -818,7 +1127,7 @@ function Stage({
trademark stays out of the headline and appears lower down, in
plain nominative use next to the disclaimer. */}
<h1 className="stage-title">
Play like it&apos;s <em>2023</em>.
Play <em>today</em>!
</h1>
<p className="stage-lede">
The servers you remember, rebuilt and running free, open source, and up right now.
@@ -913,7 +1222,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
return (
<section className="about">
<div>
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</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 {' '}
@@ -1030,10 +1339,12 @@ function LoginPage({
/** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({
account,
config,
navigate,
onChange,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
@@ -1052,7 +1363,7 @@ function AccountPage({
return (
<main className="shell wide">
<h1>My account</h1>
<Dashboard account={account} navigate={navigate} onChange={onChange} />
<Dashboard account={account} config={config} navigate={navigate} onChange={onChange} />
</main>
)
}
@@ -1382,10 +1693,10 @@ function BlobUpload({
<span className="badge beta">Beta</span>
</p>
<p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file the server stores whatever it
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
scene data from a room built on anything newer may not load at all. Download the save
above and keep it before replacing it.
New and lightly tested. Nothing here checks the file the server stores whatever it is and
the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
from a room built on anything newer may not load at all. Download the save above and keep it
before replacing it.
</p>
<label className="blob-upload-file">
Scene data file
@@ -1776,10 +2087,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
function Dashboard({
account,
config,
navigate,
onChange,
}: {
account: SelfAccount
config: SiteConfig | undefined
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
@@ -1800,10 +2113,22 @@ function Dashboard({
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
// Only when the operator has Discord configured — otherwise the panel has nothing to
// offer and the tab is a promise the server can't keep. The claim also still lives at
// /claim, because that URL is Discord's registered redirect and has to keep working.
...(config?.benefitsEnabled
? [
{
id: 'benefits',
label: 'Claim benefits',
render: () => <BenefitsPanel account={account} config={config} />,
},
]
: []),
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
{ id: 'coach', label: 'Coach message', render: () => <CoachMessageForm /> },
]
: []),
]
@@ -1938,28 +2263,61 @@ function RoomCard({
)
}
/** Admin-only: send a coach/system message to every online player. */
/**
* Admin-only: send a coach/system message, either to one player by `@username` or to
* everyone online.
*
* The two go to different endpoints because they behave differently, not just in reach:
* the broadcast is online-only (nothing holds a message with no addressee), while a named
* recipient's message is queued by the hub and delivered whenever they next connect. The
* recipient box therefore says which of those the operator is about to do.
*/
function CoachMessageForm() {
const [recipient, setRecipient] = useState('')
const [message, setMessage] = useState('')
const { pending, error, done, run } = useAction()
// The `@` is how the name is written, not part of it — accepted either way, shown back
// with it, and sent without it.
const handle = recipient.trim().replace(/^@/, '')
const toOne = handle !== ''
return (
<section className="card">
<h2>Broadcast message</h2>
<h2>Coach message</h2>
<p className="muted">
Send a message from the Coach to every connected player. Players who aren&apos;t online
won&apos;t receive it.
Send a message from the Coach to one player, or leave the recipient blank to send it to
every connected player. A broadcast reaches only who is online right now; a message to one
player waits for them if they aren&apos;t.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await coachMessageAll(message.trim())
const content = message.trim()
if (!toOne) {
const { sent } = await coachMessageAll(content)
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
}
// Resolved before sending: the workers address players by id, and a name that
// matches nobody should be a refusal rather than a message into the void.
const { queued } = await coachMessage(await accountIdForUsername(handle), content)
setMessage('')
return queued === true
? `@${handle} is offline — it will arrive when they next connect.`
: `Sent to @${handle}.`
})
}}
>
<label>
Send to
<input
value={recipient}
placeholder="@username — blank sends to everyone online"
autoComplete="off"
onChange={(e) => setRecipient(e.target.value)}
/>
</label>
<label>
Message
<textarea
@@ -1972,7 +2330,7 @@ function CoachMessageForm() {
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send to all online'}
{pending ? 'Sending…' : toOne ? `Send to @${handle}` : 'Send to all online'}
</button>
</form>
</section>
+2 -1
View File
@@ -222,7 +222,8 @@ body {
text-wrap: balance;
}
/* The one place the orange carries meaning in the headline: the year it restores. */
/* The one accented word in the headline it carries the whole emphasis, so the markup
always wraps exactly one word in <em>. */
.stage-title em {
font-style: normal;
color: var(--accent);
+47 -3
View File
@@ -7,9 +7,11 @@ export type Env = SharedHonoEnv & {
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
ASSETS: Fetcher
/**
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it
* is the live presence head-count behind `/server-status`. Every table it can see is
* owned (and migrated) by another worker.
* The shared `recflare` D1. www asks it two things: the live presence head-count
* behind `/server-status`, and the caller's `account` row on the benefits claim
* which is also the one place www WRITES (the `hasPlus`/`discordUserId` pair, through
* `@repo/domain`'s `updateAccount`, so the blob's shape stays in one module). Every
* table it can see is owned (and migrated) by another worker; www never migrates.
*/
DB: D1Database
/**
@@ -39,6 +41,48 @@ export type Env = SharedHonoEnv & {
* failing to resolve closes web signup see src/turnstile.ts.
*/
TURNSTILE_SECRET_KEY: SecretsStoreSecret
/**
* The HS256 signing key every worker shares, out of the same account-level Secrets
* Store. www needs it for ONE thing: the benefits claim is the only route here that
* acts on behalf of a specific account (it writes `hasPlus` onto it), so it has to
* establish WHICH account is calling rather than take the SPA's word for it. Every
* other www route is either anonymous or hands the token straight to another worker.
*
* Resolve with `await env.JWT_SECRET.get()`; validate through `@repo/jwt` so the
* signature/exp checks are the ones every other worker runs.
*/
JWT_SECRET: SecretsStoreSecret
/**
* The Discord application's client id. PUBLIC it ships to the browser, which needs
* it to build the authorize URL but kept in the Secrets Store beside its secret so
* one place configures the claim, exactly as TURNSTILE_SITE_KEY is.
*/
DISCORD_CLIENT_ID: SecretsStoreSecret
/**
* The Discord application's client secret what turns an authorization code into an
* access token. Never leaves this worker (see src/discord.ts).
*/
DISCORD_CLIENT_SECRET: SecretsStoreSecret
/**
* The guild (Discord server) whose membership the benefits claim checks, and the roles
* within it that entitle a player to the benefits. Both hold Discord SNOWFLAKES all
* digits, never a role's display name kept as strings because a snowflake exceeds
* 2^53. Plain vars rather than secrets: any member of the server can read these off
* their own client, and none of them authorizes anything on its own.
*
* OPTIONAL because an operator who hasn't set up Discord has neither, and that is a
* supported state: it CLOSES the claim (see `discordConfig`) rather than opening an
* unverified one.
*/
DISCORD_GUILD_ID?: string
/**
* The role ids inside DISCORD_GUILD_ID that grant Rec Room Plus numeric snowflakes,
* separated by commas and/or whitespace, e.g. `"1077000000000000001,1077000000000000002"`.
* ANY one of them qualifies, so several tiers (a supporter role, a booster role, staff)
* can share the same benefit. Parsed by `parseRoleIds`; a value that parses to no ids at
* all closes the claim, exactly as an unset one does.
*/
DISCORD_BENEFITS_ROLE_IDS?: string
}
/** Variables can be extended */
+297
View File
@@ -0,0 +1,297 @@
import { logger } from '@repo/hono-helpers'
import type { Env } from './context'
/**
* Discord OAuth2, the identity check behind the website's benefits claim.
*
* A player proves they hold one of the qualifying roles in the community Discord, and
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
* access token using the client SECRET, which like the Turnstile secret next door
* can never ship to a page. The access token therefore never exists in the browser at
* all, and it is revoked here the moment the roles have been read.
*
* Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the
* `guilds.members.read` scope lets the TOKEN'S OWNER read their own membership. That is
* the whole reason this shape was chosen over a bot token nothing here has to be in
* the guild, and the worker holds no credential that could read anybody else's roles.
*
* The four settings (client id, client secret, guild, one or more roles) are the switch,
* exactly as the Turnstile keypair is for signup: with any of them missing the claim is CLOSED
* (`/api/config` says so and `/api/benefits/claim` refuses) rather than open and
* unverified. Nothing is ever inferred from the environment.
*/
/** Discord's API, pinned to v10 — the version the endpoints below are documented at. */
const API_BASE = 'https://discord.com/api/v10'
/**
* Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize
* page is a human-facing page on the main site, and the versioned path serves a redirect
* to it at best.
*/
export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
/**
* The scopes the claim asks for, in the order Discord shows them on the consent screen.
*
* - `identify` the user's own id, which the claim stores as a `PlatformType.Discord`
* link on the account to keep itself once-only.
* - `guilds.members.read` their member record (and so their ROLES) in one guild they
* are in. Narrower than `guilds`, which lists every server they belong to and is not
* needed: the claim asks about exactly one guild.
*
* A space-joined string because that is how the authorize URL takes them.
*/
export const SCOPES = 'identify guilds.members.read'
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
clientId: string
/** The application's client secret. Never leaves this worker. */
clientSecret: string
/** The guild (server) whose membership is checked. */
guildId: string
/**
* The role ids within that guild that entitle a player to the benefits Discord
* snowflakes, all digits. ANY one of them qualifies: they're alternatives (a supporter
* role, a booster role, staff), not requirements, so this is a set to test membership
* against and never an ordered list. Always at least one entry an empty list closes
* the claim (see `discordConfig`).
*/
roleIds: string[]
}
/**
* Parse the configured role ids Discord snowflakes, so each one is ALL DIGITS (a role's
* display name is not an id and will never match anything). They stay strings rather than
* becoming numbers: a snowflake exceeds 2^53, and they are only ever compared, never done
* arithmetic on.
*
* Separated by commas and/or whitespace, so a value pasted out of Discord one id per line
* works as well as `1077000000000000001,1077000000000000002` does; blank entries are
* dropped, which is what makes a trailing comma harmless rather than a role id of `''`
* that nothing can ever match.
*
* The digits are not ENFORCED here, deliberately. A typo'd snowflake is indistinguishable
* from a real role nobody holds, and both correctly result in a claim being refused, so a
* format rule would buy nothing but a way to reject a valid id if Discord ever widens the
* format. Misconfiguration shows up as "nobody can claim", which is the safe direction.
*/
export const parseRoleIds = (raw: string): string[] =>
raw
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
/**
* The Discord settings, or null when the claim isn't configured which is what CLOSES
* it. All four must be present, and the role list must parse to at least ONE id: a client
* id with no roles would authenticate a player and then have no question to ask about
* them, and treating that as "configured" would hand Plus to anyone with a Discord
* account.
*
* Which of the four is missing is logged (never their values) because a half-configured
* app is otherwise indistinguishable from an operator deliberately leaving benefits off.
*
* The id and secret come from the account-level Secrets Store the whole monorepo shares,
* so they're read per request rather than off `env` as strings; `.get()` caches per
* isolate, so changing either needs a `www` redeploy to take effect on a warm worker
* the same caveat TURNSTILE_* and JWT_SECRET carry. The guild and roles are plain vars:
* they're server ids visible to every member, not credentials.
*/
export async function discordConfig(env: Env): Promise<DiscordConfig | null> {
const [clientId, clientSecret] = await Promise.all([
readSecret(env.DISCORD_CLIENT_ID, 'DISCORD_CLIENT_ID'),
readSecret(env.DISCORD_CLIENT_SECRET, 'DISCORD_CLIENT_SECRET'),
])
const guildId = env.DISCORD_GUILD_ID ?? ''
const roleIds = parseRoleIds(env.DISCORD_BENEFITS_ROLE_IDS ?? '')
if (clientId !== '' && clientSecret !== '' && guildId !== '' && roleIds.length > 0) {
return { clientId, clientSecret, guildId, roleIds }
}
if (clientId !== '' || clientSecret !== '' || guildId !== '' || roleIds.length > 0) {
logger.error('discord is half-configured, so benefit claims are closed', {
hasClientId: clientId !== '',
hasClientSecret: clientSecret !== '',
hasGuildId: guildId !== '',
// The COUNT, not the ids: a value that parsed to nothing (say, a stray comma) is
// indistinguishable from an unset one without it.
roleIdCount: roleIds.length,
})
}
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 on `env`; what varies is whether the store
* holds the secret a missing one throws rather than resolving empty. Mirrors
* `turnstile.ts`'s reader, and for the same reason: a store this worker can't read must
* close the feature, not 500 the homepage.
*/
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
try {
return (await secret.get()) ?? ''
} catch (err) {
logger.error('failed to read a discord credential from the secrets store', {
secret: name,
error: String(err),
})
return ''
}
}
/**
* The URI Discord redirects back to after consent, derived from the request rather than
* configured.
*
* It must be byte-identical in three places the authorize URL the browser opens, the
* token exchange below, and the app's registered redirect list or Discord refuses the
* exchange. Deriving it from the incoming request's own origin is what keeps the first
* two in step across every environment (localhost in dev, the real domain in
* production) with nothing to configure, and it is also why the SPA does NOT get to
* supply it in the request body: an attacker-supplied redirect would turn this worker's
* client secret into a redemption oracle for codes issued to somebody else's app page.
*
* `/claim` is the SPA route that handles the return; see App.tsx.
*/
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
/**
* Swap an authorization code for an access token. Returns null on any refusal a code
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
* with a different redirect URI all land here, and none of them is worth telling the
* browser apart: the answer is the same, start the flow again.
*
* The credentials go in the BODY rather than a Basic auth header. Both are legal and
* Discord documents the body form.
*/
export async function exchangeCode(
config: DiscordConfig,
code: string,
redirect: string
): Promise<string | null> {
try {
const res = await fetch(`${API_BASE}/oauth2/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirect,
}).toString(),
})
if (!res.ok) {
// The body carries an OAuth error code (`invalid_grant`, `invalid_client`) — the
// last of which is a misconfiguration, not a player mistake, and this line is the
// only place it surfaces. Logged, never relayed: it tells a caller nothing.
logger.info('discord refused a code exchange', {
status: res.status,
body: await res.text().catch(() => ''),
})
return null
}
const token = (await res.json()) as { access_token?: unknown }
return typeof token.access_token === 'string' ? token.access_token : null
} catch (err) {
logger.error('could not reach discord to exchange a code', { error: String(err) })
return null
}
}
/**
* Whether a member holds ANY of the qualifying roles. Both sides are snowflake id
* strings, compared exactly Discord reports a member's roles as ids, never as names.
*
* The roles are alternatives (a supporter, a booster and a staff member all qualify), so
* this is an intersection test and not a subset one: requiring all of them would mean
* nobody ever claimed.
*/
export const qualifies = (memberRoles: string[], roleIds: string[]): boolean =>
memberRoles.some((role) => roleIds.includes(role))
/** Who claimed, and what they hold in the guild. */
export interface GuildMembership {
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
userId: string
/** Their Discord username, for the confirmation line. Display only, never stored. */
username: string
/** Their role ids in the guild. */
roles: string[]
}
/**
* The token owner's membership in the configured guild, or null when they aren't in it
* (Discord answers 404) or the call fails.
*
* `null` deliberately conflates "not a member" with "we couldn't ask". Both mean the same
* thing to the claim no proof was obtained and a claim that granted benefits when
* Discord was unreachable would be worse than one that asks the player to retry.
*/
export async function fetchGuildMembership(
accessToken: string,
guildId: string
): Promise<GuildMembership | null> {
try {
const res = await fetch(`${API_BASE}/users/@me/guilds/${guildId}/member`, {
headers: { authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
// 404 is the ordinary "they aren't in the server" answer, so it's info, not error.
logger.info('discord did not return a guild membership', { status: res.status })
return null
}
const member = (await res.json()) as {
user?: { id?: unknown; username?: unknown }
roles?: unknown
}
const userId = typeof member.user?.id === 'string' ? member.user.id : ''
if (userId === '') {
logger.error('discord returned a guild member with no user id')
return null
}
return {
userId,
username: typeof member.user?.username === 'string' ? member.user.username : '',
roles: Array.isArray(member.roles)
? member.roles.filter((r): r is string => typeof r === 'string')
: [],
}
} catch (err) {
logger.error('could not reach discord to read a guild membership', { error: String(err) })
return null
}
}
/**
* Hand the access token back to Discord once the roles have been read.
*
* Best-effort and deliberately un-awaited-on by the caller's success path: the claim has
* already been decided by this point, so a failed revoke must not fail it. It's here
* because the token is useless to us after one read and a live token is a liability for
* however long it would otherwise last (a week) this keeps the credential's lifetime
* about as long as the request that needed it.
*/
export async function revokeToken(config: DiscordConfig, accessToken: string): Promise<void> {
try {
await fetch(`${API_BASE}/oauth2/token/revoke`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
token: accessToken,
token_type_hint: 'access_token',
}).toString(),
})
} catch (err) {
logger.info('could not revoke a discord access token', { error: String(err) })
}
}
+255
View File
@@ -1,8 +1,19 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db'
import { PlatformType } from '@repo/domain/src/enums'
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
import { generateToken } from '@repo/jwt'
import {
CACHED_LOGIN_PLATFORMS,
countAccountsForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
PLATFORM_SCHEMA_DDL,
} from '../../../../auth/src/platform-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
@@ -21,12 +32,37 @@ declare module 'cloudflare:test' {
const TEST_SITE_KEY = '1x00000000000000000000AA'
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
// The shared HS256 key. www verifies tokens itself for exactly one route (the benefits
// claim), so the tests have to be able to MINT one — hence a known value here rather than
// whatever a deployed store holds.
const TEST_JWT_SECRET = 'test-jwt-secret'
/** A bearer token for `accountId`, signed the way `auth` signs one. */
const tokenFor = (accountId: number): Promise<string> =>
generateToken(String(accountId), '', 4, TEST_JWT_SECRET)
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
// dangerous half — an operator who registers an app and stops has something that can sign
// a player in and no question left to ask about them — so it is the state the route-level
// tests pin: the claim must still be CLOSED. The fully-configured path is covered by
// unit-testing `discordConfig`, since exercising it end to end would call discord.com.
const TEST_DISCORD_CLIENT_ID = 'test-discord-client-id'
const TEST_DISCORD_CLIENT_SECRET = 'test-discord-client-secret'
beforeAll(async () => {
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
await adminSecretsStore(env.JWT_SECRET).create(TEST_JWT_SECRET)
await adminSecretsStore(env.DISCORD_CLIENT_ID).create(TEST_DISCORD_CLIENT_ID)
await adminSecretsStore(env.DISCORD_CLIENT_SECRET).create(TEST_DISCORD_CLIENT_SECRET)
// `presence` is owned (and migrated) by other workers — www only reads it — so the
// table has to be created here for the head-count behind /server-status.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// And `platform_account`, where a claimed Discord identity is linked.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
@@ -42,6 +78,11 @@ it('advertises signup and where the other workers live', async () => {
expect(await res.json()).toEqual({
signupEnabled: true,
turnstileSiteKey: TEST_SITE_KEY,
// Closed, because the Discord app here has no guild/role to check against — and with
// it closed the SPA is given no authorize URL to send anyone to, so the claim can't
// even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false,
discordAuthorizeUrl: null,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
@@ -281,6 +322,220 @@ it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
expect(res.status).toBe(404)
})
// ---- Discord benefits claim ------------------------------------------------
// All four settings are the switch, exactly as the Turnstile keypair is for signup: a
// half-configured app must read as OFF. The dangerous half is a client id and secret with
// no guild/role — that authenticates a player and then has no question left to ask about
// them, so treating it as configured would hand Rec Room Plus to anyone with a Discord
// account. Checked directly because the configured path can't be reached from here (it
// would call discord.com for real).
it('treats a half-configured discord app as benefit claims 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 withDiscord = (
id: SecretsStoreSecret,
secret: SecretsStoreSecret,
guildId?: string,
roleIds?: string
) =>
({
ENVIRONMENT: 'development',
DISCORD_CLIENT_ID: id,
DISCORD_CLIENT_SECRET: secret,
DISCORD_GUILD_ID: guildId,
DISCORD_BENEFITS_ROLE_IDS: roleIds,
}) as Env
const id = stub('client-id')
const secret = stub('client-secret')
// Snowflakes, as the real vars hold: ids are all digits, never a role's display name.
const guild = '1077000000000000000'
const role = '1077000000000000001'
// Nothing at all, and a store this worker can't read: both closed, never a 500.
await expect(discordConfig(withDiscord(throws(), throws()))).resolves.toBeNull()
await expect(discordConfig(withDiscord(stub(''), stub(''), '', ''))).resolves.toBeNull()
// Each single missing piece, including the two that would otherwise grant Plus for a
// bare Discord login.
await expect(discordConfig(withDiscord(throws(), secret, guild, role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, throws(), guild, role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret, '', role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret, guild, ''))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret))).resolves.toBeNull()
// A role list that parses to NO ids is unset, not configured — otherwise a stray comma
// left in the var would open the claim with nothing to check against.
await expect(discordConfig(withDiscord(id, secret, guild, ' , , '))).resolves.toBeNull()
// All four present is the only configured state.
await expect(discordConfig(withDiscord(id, secret, guild, role))).resolves.toEqual({
clientId: 'client-id',
clientSecret: 'client-secret',
guildId: guild,
roleIds: [role],
})
// Several qualifying roles is the ordinary case, not a special one.
const second = '1077000000000000002'
await expect(discordConfig(withDiscord(id, secret, guild, `${role},${second}`))).resolves.toEqual(
{
clientId: 'client-id',
clientSecret: 'client-secret',
guildId: guild,
roleIds: [role, second],
}
)
})
// Several roles can qualify for the same benefit (a supporter role, a booster role,
// staff…), so the list is parsed leniently: an operator pasting ids out of Discord gets
// one per line, and a trailing comma is a typo rather than a role of '' that nothing
// could ever match. Every id is a snowflake — all digits, kept as a string.
it('parses a qualifying-role list however an operator writes it', () => {
expect(parseRoleIds('1077000000000000001')).toEqual(['1077000000000000001'])
expect(parseRoleIds('1077000000000000001,1077000000000000002')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
expect(parseRoleIds(' 1077000000000000001 , 1077000000000000002 ')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
// Pasted a line at a time, straight out of Discord.
expect(parseRoleIds('1077000000000000001\n1077000000000000002\n')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
// Kept as STRINGS, never parsed to numbers: a snowflake exceeds 2^53, so
// Number('1077000000000000001') would round and stop matching the real role.
expect(parseRoleIds('1077000000000000001')[0]).toBe('1077000000000000001')
// Nothing to match on — these are the values that must close the claim.
expect(parseRoleIds('')).toEqual([])
expect(parseRoleIds(' ')).toEqual([])
expect(parseRoleIds(',,')).toEqual([])
// A trailing separator adds no empty id, which would match no role and never qualify.
expect(parseRoleIds('1077000000000000001,')).toEqual(['1077000000000000001'])
})
// ANY one of the configured roles qualifies — they are alternatives, not requirements.
// Testing for a subset instead would mean a player had to hold every tier at once, i.e.
// nobody would ever claim.
it('qualifies a member holding any one of the roles', () => {
// Ids on both sides — Discord reports a member's roles as snowflakes, never as names.
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
const qualifying = [supporter, booster]
expect(qualifies([supporter], qualifying)).toBe(true)
expect(qualifies([booster], qualifying)).toBe(true)
expect(qualifies([booster, supporter], qualifying)).toBe(true)
// Holding some other role in the server is not enough.
expect(qualifies(['1077000000000000009'], qualifying)).toBe(false)
expect(qualifies([], qualifying)).toBe(false)
})
// The closed door, from the outside. This must be refused BEFORE the token is looked at,
// so an unconfigured server can't be talked into a claim by a valid session.
it('refuses a benefits claim when discord is only half configured', async () => {
const res = await SELF.fetch('https://example.com/api/benefits/claim', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${await tokenFor(4001)}`,
},
body: JSON.stringify({ code: 'whatever' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Benefit claims are currently disabled.' })
})
// The benefits routes act on ONE account — the claim writes `hasPlus` onto its row — so
// which account it is has to come from a verified token and never from the request. Both
// halves of "verified" are pinned: no token, and a token signed with a key this server
// doesn't use (i.e. one it never issued).
//
// Asserted on `/api/benefits/status` because it's the benefits route whose auth gate is
// reachable here: the claim refuses on the config gate FIRST (covered above), which is
// the right order — an unconfigured server shouldn't be examining credentials for a
// feature it doesn't run — but it means an unconfigured project can't observe its 401.
it('requires a valid session to read benefits', async () => {
const path = 'https://example.com/api/benefits/status'
// No token at all.
expect((await SELF.fetch(path)).status).toBe(401)
// A token that is well-formed but signed with the wrong key.
const forged = await generateToken('4002', '', 4, 'not-the-real-secret')
const res = await SELF.fetch(path, { headers: { authorization: `Bearer ${forged}` } })
expect(res.status).toBe(401)
})
// What the claim page renders before anyone presses anything. `hasPlus` is read off the
// account ROW rather than a token claim, because it's set after the browser's token was
// issued — a freshly-claimed player's token says nothing about it.
it('reports where an account stands on benefits', async () => {
const token = await tokenFor(4003)
// An account with no row at all reads as "nothing claimed" rather than 404ing: every
// account has a benefits status, whether or not it has been written to yet.
let res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
await updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
// use to the page, and an account's linked identities have no business on the wire.
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
})
// The once-only guard the claim is built on. Without it, one Discord member holding the
// role could walk it around every RecFlare account they own; with it, the second claim is
// refused and the first account keeps the benefit. Exercised at the two lookups the route
// asks, since the route's own path to them runs through discord.com.
it('tells a repeat claim from a second account claiming the same discord identity', async () => {
await updateAccount(env.DB, 4004, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4004, PlatformType.Discord, '99002')
// The identity is taken, so a DIFFERENT account claiming it is the 409 case…
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99002')
).resolves.toBe(1)
await expect(isPlatformIdentityLinked(env.DB, 4005, PlatformType.Discord, '99002')).resolves.toBe(
false
)
// …while the account that already holds it re-claims idempotently, which is what makes
// the page safe to reload and a lapsed-then-restored role re-claimable.
await expect(isPlatformIdentityLinked(env.DB, 4004, PlatformType.Discord, '99002')).resolves.toBe(
true
)
// A Discord member who has claimed nowhere yet.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99003')
).resolves.toBe(0)
})
// A Discord link must never become a way INTO an account. The login picker is public and
// unauthenticated, so listing one would both offer the client an account it can't redeem
// (the grant refuses platform 101) and tell anyone which RecFlare account a Discord user
// owns — a snowflake is readable by anyone sharing a server with them. `auth` owns that
// gate; this pins that the platform www writes to is one the gate actually excludes.
it('stores the discord identity on a platform the login picker will not list', () => {
expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord)
})
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.14 checks are run
// against, and a reviewer only sees the rendered page — so the four things they look
// for are pinned here. If a section is renamed, re-read the VRC before loosening the

Some files were not shown because too many files have changed in this diff Show More