34 Commits

Author SHA1 Message Date
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
102 changed files with 33058 additions and 1321 deletions
+46 -5
View File
@@ -87,6 +87,11 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10 # RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_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
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to> # 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 # 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 # with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
@@ -108,12 +113,21 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_PHOTON_VOICE_APP_ID= # RECFLARE_PHOTON_VOICE_APP_ID=
# RECFLARE_PHOTON_CHAT_APP_ID= # RECFLARE_PHOTON_CHAT_APP_ID=
# The Tachyon voice server (`match`, GET /player/connection-info): the `host:port` the # The pool of Tachyon servers sessions are spread across (`match`, GET
# client is handed as `voiceConnectionInfo`, and its id as `voiceServerId`. EMPTY unless # /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
# you set them — no separate voice server. Set both or neither; like the Photon ids they # the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
# are not secrets (the client receives them in the clear). # 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_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 # 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 # does default, to `us` (us-east1) — an instance stamped with an empty region is one the
@@ -141,3 +155,30 @@ RECFLARE_DOMAIN=rec.example.com
# Setting them both is what opens web signup; with either missing it stays closed. See # 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 # DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
# caps above apply regardless. # 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 - Changesets manage versions and changelogs
</architecture> </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> <code-style>
- Use tabs for indentation, spaces for alignment - Use tabs for indentation, spaces for alignment
- Type imports use `import type` - 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. - 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; 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. 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 - 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 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 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. edit the value, then re-deploy the worker that reads them.
| `.env` variable | Read by | Default | What it does | | `.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_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_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_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_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_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. | | `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" /> <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 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 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 ## 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 These game builds are supported:
[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 | Build | Manifest | Support |
different endpoints and response shapes and are not supported. | ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `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 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 ## Services
@@ -58,9 +67,11 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
### What year is this for? ### 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? ### 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? ### 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? ### 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? ### 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 share code, Turborepo runs build/test/lint with a single cached task graph, and
cross-service changes land in one atomic commit. 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 ## 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 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 resources to get response shapes, logic examples, enums, etc. They all had
missing pieces. Again, another reason to come together on one project and missing pieces. Again, another reason to come together on one project and
stop gatekeeping. stop gatekeeping.
Unfortunately, they were all leaked code except for - [CannedNet](https://github.com/CannedNet/CannedNet)
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman - [DorkNet](https://github.com/DorkSquadRR/DorkNet)
collection of RecNet APIs which is gone for some reason. So I will not list the - jordanparki7's postman collection of RecNet APIs which is gone for some reason.
leaks publicly. - 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)
+2 -2
View File
@@ -20,7 +20,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -32,6 +32,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+1 -1
View File
@@ -76,7 +76,7 @@ function unauthorized(c: Context<App>) {
} }
/** Username changes a fresh account starts with (until one has been consumed). */ /** 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. * Username-change result envelope: `{ success, error, value }`, always HTTP 200.
+33 -8
View File
@@ -149,6 +149,15 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401) 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 () => { 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() }) const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -160,7 +169,7 @@ describe('auth-gated endpoints', () => {
username: 'Player42', username: 'Player42',
personalPronouns: 0, personalPronouns: 0,
identityFlags: 0, identityFlags: 0,
availableUsernameChanges: 1, availableUsernameChanges: 3,
// An unset email is "", not null — the client reads it as a string, and the // 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. // hub frame this DTO also rides drops null values outright.
email: '', email: '',
@@ -181,7 +190,7 @@ describe('auth-gated endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, { const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
headers: await bearer(), 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 () => { test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
@@ -235,7 +244,7 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('') expect(body.value).toBe('')
}) })
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => { test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
const headers = { const headers = {
...(await bearer('892')), ...(await bearer('892')),
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@@ -260,11 +269,27 @@ describe('auth-gated endpoints', () => {
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') }) await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number } ).json()) as { username: string; availableUsernameChanges: number }
expect(me.username).toBe('coachx') 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`, { const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'coachy' }), ...form({ username: 'coachq' }),
headers, headers,
}) })
expect(blocked.status).toBe(200) expect(blocked.status).toBe(200)
@@ -565,12 +590,12 @@ describe('name, email and bio validation', () => {
expect(body.value).toBe('') 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. // and burning it on a typo would leave the player stuck with a name they never had.
const me = (await ( const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') }) await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
).json()) as { availableUsernameChanges: number } ).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. // 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`, { const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+419 -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 + * 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 * 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 * feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and * 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. * NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/ */
export const SCHEMA_DDL: string[] = [ export const SCHEMA_DDL: string[] = [
@@ -53,6 +53,16 @@ export interface InventionVersion {
ChipsCost: number ChipsCost: number
CloudVariablesCost: number CloudVariablesCost: number
AICost: 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 +113,32 @@ export interface SavedInvention {
HideFromPlayer: boolean HideFromPlayer: boolean
ReferencedInventions: number[] ReferencedInventions: number[]
/** /**
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on * The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
* save: the real `RRInvention` carries no Tags field and the client sends no tags * they most resemble, and the one this record has always carried on the invention.
* when saving, so an untagged invention's DTO stays identical to the real one. * 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[] Tags?: InventionTag[]
} }
@@ -130,6 +163,224 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion } 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 * 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 * on the `BlobName` it reads back. Uploads through the `storage` worker already land
@@ -189,6 +440,19 @@ export interface NewInvention {
aiCost?: number aiCost?: number
creationRoomId?: number | null creationRoomId?: number | null
referencedInventions?: number[] 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 +465,11 @@ export interface NewInvention {
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed. * 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 * 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. * 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( export async function createInvention(
db: D1Database, db: D1Database,
@@ -233,6 +502,7 @@ export async function createInvention(
ChipsCost: input.chipsCost ?? 0, ChipsCost: input.chipsCost ?? 0,
CloudVariablesCost: input.cloudVariablesCost ?? 0, CloudVariablesCost: input.cloudVariablesCost ?? 0,
AICost: input.aiCost ?? 0, AICost: input.aiCost ?? 0,
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
}, },
Accessibility: 0, Accessibility: 0,
IsPublished: false, IsPublished: false,
@@ -252,6 +522,16 @@ export async function createInvention(
AllowTrial: true, AllowTrial: true,
HideFromPlayer: false, HideFromPlayer: false,
ReferencedInventions: input.referencedInventions ?? [], 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() await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
return invention return invention
@@ -379,7 +659,7 @@ export async function searchInventions(
const offset = Math.max(skip, 0) const offset = Math.max(skip, 0)
if (limit === 0) return [] if (limit === 0) return []
const where = ['is_published = 1', 'hide_from_player = 0'] const where = [...VISIBLE_IN_FEEDS]
const binds: Array<string | number> = [] const binds: Array<string | number> = []
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */ /** 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)}` const bind = (v: string | number): string => `?${binds.push(v)}`
@@ -421,8 +701,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT data FROM invention `SELECT data FROM invention
WHERE is_published = 1 WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}` ${featuredOnly ? 'AND is_featured = 1' : ''}`
) )
.all<InventionRow>() .all<InventionRow>()
@@ -499,10 +778,9 @@ export async function getFeaturedInventions(
/** /**
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the * 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 * 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 * own (Type 0). Both lists are replaced wholesale, normalized as
* order the tags come back in — and are lowercased/trimmed and de-duplicated so * {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null * there's no such invention.
* when there's no such invention.
*/ */
export async function setInventionTags( export async function setInventionTags(
db: D1Database, db: D1Database,
@@ -513,6 +791,21 @@ export async function setInventionTags(
const invention = await getInventionById(db, inventionId) const invention = await getInventionById(db, inventionId)
if (invention === null) return null 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 tags: InventionTag[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const [list, type] of [ for (const [list, type] of [
@@ -526,8 +819,6 @@ export async function setInventionTags(
tags.push({ Tag: tag, Type: type }) tags.push({ Tag: tag, Type: type })
} }
} }
await writeInvention(db, { ...invention, Tags: tags })
return tags return tags
} }
@@ -540,6 +831,9 @@ export async function setInventionTags(
export const INVENTION_PERMISSION = { export const INVENTION_PERMISSION = {
unassigned: 0, unassigned: 0,
limitedoneuseonly: 10, 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, useonly: 20,
editandsave: 40, editandsave: 40,
publish: 60, publish: 60,
@@ -547,6 +841,36 @@ export const INVENTION_PERMISSION = {
unlimited: 100, unlimited: 100,
} as const } 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`, * 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. * `edit_and_save`) or the raw number. Undefined when it's neither.
@@ -567,6 +891,13 @@ export interface InventionPatch {
imageName?: string imageName?: string
allowTrial?: boolean allowTrial?: boolean
generalPermission?: number 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 +922,27 @@ export async function updateInvention(
ImageName: patch.imageName ?? invention.ImageName, ImageName: patch.imageName ?? invention.ImageName,
AllowTrial: patch.allowTrial ?? invention.AllowTrial, AllowTrial: patch.allowTrial ?? invention.AllowTrial,
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission, 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) await writeInvention(db, updated)
return 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. * 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 * Publishing sets the permission other players get (UseOnly unless the creator asks
@@ -605,8 +952,7 @@ export async function updateInvention(
export async function publishInvention( export async function publishInvention(
db: D1Database, db: D1Database,
inventionId: number, inventionId: number,
permissionLevel: number | undefined, publish: InventionPublish = {}
price: number | undefined
): Promise<SavedInvention | null> { ): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId) const invention = await getInventionById(db, inventionId)
if (invention === null) return null if (invention === null) return null
@@ -614,8 +960,13 @@ export async function publishInvention(
const updated: SavedInvention = { const updated: SavedInvention = {
...invention, ...invention,
IsPublished: true, IsPublished: true,
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly, GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
Price: price ?? 0, 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(), FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
} }
await writeInvention(db, updated) await writeInvention(db, updated)
@@ -638,6 +989,55 @@ export async function setInventionPrice(
return updated 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.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
return invention
}
/**
* 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. */ /** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters { export interface InventionTagFilters {
PinnedFilters: string[] PinnedFilters: string[]
@@ -714,8 +1114,7 @@ export async function getInventionsByRoom(
.prepare( .prepare(
`SELECT data FROM invention `SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1 WHERE json_extract(data, '$.CreationRoomId') = ?1
AND is_published = 1 AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
AND hide_from_player = 0`
) )
.bind(roomId) .bind(roomId)
.all<InventionRow>() .all<InventionRow>()
+290 -19
View File
@@ -119,12 +119,14 @@ export const SuccessErrorEnvelope = z.object({
// ---- Config ---------------------------------------------------------------- // ---- 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({ export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(), AmplitudeKey: z.string(),
StatSigKey: z.string(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(), 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. */ /** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
@@ -420,6 +422,10 @@ export const InventionVersionDto = z.object({
ChipsCost: z.int(), ChipsCost: z.int(),
CloudVariablesCost: z.int(), CloudVariablesCost: z.int(),
AICost: 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. */ /** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
@@ -456,10 +462,30 @@ export const InventionDto = z.object({
AllowTrial: z.boolean(), AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(), HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()), 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 Tags: z
.array(InventionTagDto) .array(InventionTagDto)
.optional() .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. */ /** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
@@ -469,6 +495,88 @@ export const InventionSaveResult = z.object({
InventionVersion: InventionVersionDto, 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. */ /** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({ export const TagFilters = z.object({
PinnedFilters: z.array(z.string()), PinnedFilters: z.array(z.string()),
@@ -506,6 +614,76 @@ export const SetTagsResponse = z.object({
Tags: z.array(z.string()).describe('Auto tags first, then custom'), 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. */ /** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({ export const UpdatePriceRequest = z.object({
InventionId: z.int(), InventionId: z.int(),
@@ -533,6 +711,34 @@ export const SaveInventionRequest = z.object({
aiCost: z.int().optional(), aiCost: z.int().optional(),
creationRoomId: z.int().optional(), creationRoomId: z.int().optional(),
referencedInventions: z.array(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 ------------------------------------------ // ---- Avatar / custom avatar items ------------------------------------------
@@ -1036,24 +1242,62 @@ export const VoteToKickReason = z.object({
}) })
/** /**
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked" * `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
* answer (no ban storage yet), mirroring the reference server's stub * account-wide ban in force (a `report` row with `banned` set) it describes that ban:
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0, * `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* which is a real category, and `Message` is null — the client distinguishes "no * its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* message" from a blank one, so we send null where the reference sends an empty string. * `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so * `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
* they carry their C# defaults (false / null). * `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({ export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'), ReportCategory: z
Duration: z.int(), .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(), GameSessionId: z.int(),
IsBan: z.boolean(), IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
IsHostKick: z.boolean(), Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
IsVoiceModAutoban: z.boolean(), PlayerIdReporter: z
Message: z.string().nullable(), .int()
PlayerIdReporter: z.int().nullable(), .nullable()
TimeoutStartedAt: z.string().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 +1342,33 @@ export const CreateWarningRequest = z.object({
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'), 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. */ /** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({ export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'), 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. * 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` * 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 * on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
* workers read it — `match` refuses every matchmake for a banned player, and `auth` * ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct * by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player * (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
* out of ONE room, this one out of the game. * 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
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still * from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
* answers "not blocked" unconditionally. * 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` * Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
* calls it on every matchmake and `auth` on every token grant, and neither has anything * that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
* to say about WHICH report did it. * caller that does, and reads `getActiveBan` itself.
*/ */
export async function isPlayerBanned( export async function isPlayerBanned(
db: D1Database, db: D1Database,
+464 -62
View File
@@ -4,8 +4,10 @@ import { describeRoute } from 'hono-openapi'
import { import {
CURRENT_OUTFIT_SLOT, CURRENT_OUTFIT_SLOT,
getOutfit, getOutfit,
getOutfits,
getOutfitsByAccounts, getOutfitsByAccounts,
inventionDescriptionRejection, inventionDescriptionRejection,
inventionLongDescriptionRejection,
inventionNameRejection, inventionNameRejection,
inventionTagRejection, inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS, MAX_BULK_OUTFIT_ACCOUNTS,
@@ -26,6 +28,7 @@ import {
import { authedId, unauthorized } from '../http' import { authedId, unauthorized } from '../http'
import { import {
createInvention, createInvention,
deleteInvention,
getFeaturedInventions, getFeaturedInventions,
getInventionById, getInventionById,
getInventionsByIds, getInventionsByIds,
@@ -35,6 +38,10 @@ import {
getInventionVersion, getInventionVersion,
getMyInventions, getMyInventions,
getTopInventions, getTopInventions,
INVENTION_TAG_RESULT,
inventionDeleteResult,
inventionSaveV9Failure,
normalizeInventionTags,
ownsAllInventions, ownsAllInventions,
parsePermissionLevel, parsePermissionLevel,
publishInvention, publishInvention,
@@ -42,6 +49,7 @@ import {
setInventionPrice, setInventionPrice,
setInventionTags, setInventionTags,
toSaveResult, toSaveResult,
toSaveResultV9,
updateInvention, updateInvention,
} from '../inventions-db' } from '../inventions-db'
import { import {
@@ -54,17 +62,20 @@ import {
CustomAvatarItemReportRequest, CustomAvatarItemReportRequest,
CustomAvatarItemResponse, CustomAvatarItemResponse,
CustomAvatarItemsPage, CustomAvatarItemsPage,
DeleteInventionRequest,
ErrorResponse, ErrorResponse,
form, form,
GeneratedGift, GeneratedGift,
GenerateGiftRequest, GenerateGiftRequest,
idParam, idParam,
intQuery, intQuery,
InventionDeleteResult,
InventionDetails, InventionDetails,
InventionDto, InventionDto,
InventionPersonalDetails, InventionPersonalDetails,
InventionReportRequest, InventionReportRequest,
InventionSaveResult, InventionSaveResult,
InventionSaveV9Result,
InventionVersionDto, InventionVersionDto,
json, json,
JsonArray, JsonArray,
@@ -77,7 +88,9 @@ import {
OutfitsMeRequest, OutfitsMeRequest,
OutfitsMeResponse, OutfitsMeResponse,
pageParams, pageParams,
PublishInventionRequest,
SaveInventionRequest, SaveInventionRequest,
SaveInventionV9Request,
SetTagsRequest, SetTagsRequest,
SetTagsResponse, SetTagsResponse,
stringParam, stringParam,
@@ -87,13 +100,14 @@ import {
TagFilters, TagFilters,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest, UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdatePriceRequest, UpdatePriceRequest,
} from '../openapi' } from '../openapi'
import { createReport } from '../reports-db' import { createReport } from '../reports-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from '../context' 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 * The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
@@ -133,24 +147,150 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
/** /**
* The gate every invention write runs through: the caller must be signed in, the * 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 * invention must exist, and it must be theirs. Yields the loaded invention, or why not —
* error response to return as-is (401 / 404 / 403). * 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( async function creatorsInvention(
c: Context<App>, c: Context<App>,
inventionId: number inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> { ): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
const playerId = await authedId(c) const gate = await creatorsInventionResult(c, inventionId)
if (playerId === null) return { response: unauthorized(c) } if ('invention' in gate) return gate
if (Number.isNaN(inventionId)) { if (gate.status === 401) return { response: unauthorized(c) }
return { response: c.json({ error: 'inventionId is required' }, 400) } if (gate.status === 404) return { response: c.notFound() }
return { response: c.json({ error: gate.rejection }, gate.status) }
}
/**
* 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 }
}
/**
* 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 getInventionById(c.env.DB, inventionId)
if (invention === null) return { response: c.notFound() } const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
if (invention.CreatorPlayerId !== playerId) { creatorPlayerId,
return { response: c.json({ error: 'Not your invention' }, 403) } inventionDataFilename,
} name,
return { invention } 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 }
} }
/** /**
@@ -882,29 +1022,46 @@ export const avatarRoutes = new Hono<App>({ strict: false })
} }
) )
// The caller's outfit wardrobe. An empty list for now — the outfits saved through // The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them // read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
// belong in this list (and in what shape) has not been pinned down, so it answers [] // both worker's write paths land in the shared `outfit` table, so both list endpoints
// rather than guessing. // 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( .get(
'/outfits/me/saved', '/outfits/me/saved',
describeRoute({ describeRoute({
tags: ['Avatar', '2025'], tags: ['Avatar', '2025'],
summary: 'The callers saved outfits', summary: 'The callers saved outfits',
description: description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' + 'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' + 'ordered by slot, and `[]` when they have saved none. The same rows `econ`s ' +
'list should carry, and in what shape, is not pinned down yet.', '`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, security: AUTHED,
responses: { responses: {
200: json(JsonArray, 'An empty list'), 200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
return c.json([]) return c.json(await getOutfits(c.env.DB, id))
} }
) )
@@ -1216,12 +1373,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel') const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10) const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention( const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
c.env.DB, permissionLevel:
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(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)) return published === null ? c.notFound() : c.json(toSaveResult(published))
} }
) )
@@ -1670,43 +1826,289 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400) if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) const outcome = await createInventionFromBody(c, id, body)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
return c.json(toSaveResult(outcome.invention))
const inventionDataFilename = str(body.inventionDataFilename)?.trim() }
if (!inventionDataFilename) { )
return c.json({ error: 'inventionDataFilename is required' }, 400)
} // 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
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled", // the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
// "No description yet"), so only a supplied one is held to the rules — otherwise // record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
// saving an unnamed invention would fail the 3-character minimum on a name the // the client reads `Success` and then `Value.Invention.InventionId`, and a body that
// player never typed. // isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
const name = str(body.name)?.trim() // takes it down rather than failing it, which is why every branch below answers one.
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name) .post(
if (nameRejection !== null) return c.json({ error: nameRejection }, 400) '/api/inventions/v9/save',
describeRoute({
const description = str(body.description) tags: ['Inventions'],
const descriptionRejection = summary: 'Save a new invention (v9)',
description === undefined ? null : inventionDescriptionRejection(description) description:
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400) '`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, { '`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
creatorPlayerId: id, '`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
inventionDataFilename, '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, name,
description, description,
imageName: str(body.imageName), longDescription,
instantiationCost: num(body.instantiationCost), imageName: edited('ImageName'),
lightsCost: num(body.lightsCost), tags: requested?.tags,
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,
}) })
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'], tags: ['Config'],
summary: 'Analytics keys', summary: 'Analytics keys',
description: description:
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' + 'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are placeholders and RudderStack ' + 'with. This server collects nothing, so the keys are blank and RudderStack and ' +
'is off — but the client needs the object to finish loading.', 'StatSig are off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') }, responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}), }),
(c) => (c) =>
c.json({ c.json({
AmplitudeKey: 'a', AmplitudeKey: '',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false, UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
}) })
) )
.get( .get(
+465 -34
View File
@@ -1,6 +1,22 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' 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 { authedId, authedRoles, unauthorized } from '../http'
import { import {
AUTHED, AUTHED,
@@ -9,18 +25,23 @@ import {
CreateWarningRequest, CreateWarningRequest,
DeviceIdRequest, DeviceIdRequest,
form, form,
InstantKickRequest,
json, json,
JsonArray, JsonArray,
jsonBody,
ModerationBlockDetails, ModerationBlockDetails,
SuccessErrorEnvelope, SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
VoteToKickReason, VoteToKickReason,
VoteToKickRequest,
} from '../openapi' } from '../openapi'
import { createReport } from '../reports-db' import { createReport, getActiveBan } from '../reports-db'
import { createWarning } from '../warnings-db' import { createWarning } from '../warnings-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context' 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 * 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 }, { Reason: 'Not following game rules', ReportCategory: 6 },
] as const ] 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 ------------------------------------------------------ // ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false }) export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans // Whether the caller is currently blocked (banned / timed out / host-kicked). The one
// are stored (a report row with `banned` set) and enforced at matchmake and at login, // kind of block this server has is the account-wide ban — a `report` row with `banned`
// but this endpoint is not wired to them, so it's always the "not blocked" answer — // set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
// the reference server's stub `ReturnModerationBlockDetails()`. // force gets it described here, and everyone else gets the "not blocked" answer of the
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category; // reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
// `Message` is null, not the empty string that stub sends — the client distinguishes // player is shown, which is why `auth` still issues them a token: without one the client
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the // never gets here, and the ban reads as a failed sign-in.
// DTO but left unset there, so they go out with their C# defaults. // 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 // 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. // GET too, so the path is reachable from either build.
.on( .on(
@@ -101,29 +315,40 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'], tags: ['Moderation'],
summary: 'Whether the caller is blocked', summary: 'Whether the caller is blocked',
description: description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' + 'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' + 'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
'not wired to them, so it is always the “not blocked” answer, following the ' + 'matchmake refuses on (login still issues a token, so the client can reach this ' +
'reference servers stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' + 'screen) — so a caller with one in force gets ' +
'is a real category, and `Message` is null rather than the empty string that stub ' + '`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'sends — the client distinguishes “no message” from a blank one. ' + '`Message` “Rule violation”, and the blocks span as the pair the client reads ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' + 'them as: `TimeoutStartedAt` is the reports `created_at` and `Duration` the ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' + 'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'it with no body.', 'ban). ' +
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') }, '`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) => async (c) => {
c.json({ const id = await authedId(c)
ReportCategory: -1, if (id === null) return unauthorized(c)
Duration: 0, const ban = await getActiveBan(c.env.DB, id)
GameSessionId: 0, return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
IsBan: false, }
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
) )
// The reasons the client offers when a player starts a vote-to-kick. Order matters — // 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 // 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'], tags: ['Moderation'],
summary: 'Submit a player report', summary: 'Submit a player report',
description: description:
'Records a player report in the `report` table; nothing dedupes the rows, and ' + 'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' + '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 ' + '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 ' + '`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 ' + '(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. // 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. // Gated on the `moderator` role in the token, not just a valid one.
.post( .post(
+1249 -34
View File
@@ -20,6 +20,7 @@ import {
PRESENCE_TTL_SECONDS, PRESENCE_TTL_SECONDS,
PROGRESSION_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL, RELATIONSHIP_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL, 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 { SavedImage } from '@repo/domain'
import type { Env } from '../../context' import type { Env } from '../../context'
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db' 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' { declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
@@ -90,6 +95,19 @@ const TEST_ROOMS = [
SubRooms: [{ SubRoomId: 3 }], SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], 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 () => { beforeAll(async () => {
@@ -128,6 +146,10 @@ beforeAll(async () => {
// Presence (owned by the rooms worker) — the online-friend count joins onto it. // 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() 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. // 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() 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`) const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
AmplitudeKey: 'a', AmplitudeKey: '',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false, 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) 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 // 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. // 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 () => { 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) 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`) const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401) 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`, { const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(), headers: await bearer(sub),
}) })
expect(res.status).toBe(200) 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 () => { 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 }) 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 () => { 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 // Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own. // exactly this row) and also creates one of their own.
@@ -3286,6 +4022,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', () => { describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) => const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, { exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
@@ -3444,6 +4548,111 @@ describe('player reports', () => {
test('banFromReport returns null for an unknown report', async () => { test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull() 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', () => { describe('player warnings', () => {
@@ -6300,9 +7509,11 @@ describe('openapi', () => {
'POST /api/PlayerCheer/v1/create', 'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile', 'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails', 'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee', 'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v3/create', 'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk', 'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate', 'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
@@ -6317,7 +7528,10 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update', 'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice', 'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save', 'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
'POST /api/messages/v1/friendOnlineStatus', 'POST /api/messages/v1/friendOnlineStatus',
'POST /api/messages/v1/sendMultiple', 'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send', 'POST /api/messages/v2/send',
@@ -6350,6 +7564,7 @@ describe('openapi', () => {
'POST /outfits/bulk', 'POST /outfits/bulk',
'POST /statsigUserProperties', 'POST /statsigUserProperties',
'PUT /api/customAvatarItems/v1/{id}', 'PUT /api/customAvatarItems/v1/{id}',
'PUT /api/inventions/v2/metadata',
'PUT /api/playerevents/v2/{eventId}/accessibility', 'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description', 'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name', 'PUT /api/playerevents/v2/{eventId}/name',
+1 -1
View File
@@ -925,7 +925,7 @@
}, },
{ {
"Key": "Statsig.Enabled", "Key": "Statsig.Enabled",
"Value": "true", "Value": "false",
"ActiveExperiments": null, "ActiveExperiments": null,
"StartTime": null, "StartTime": null,
"EndTime": 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), // GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions. // DELETE to reset it between assertions.
// //
// notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and // The ephemeral sends land in the same list, tagged `ephemeral`:
// carrying `playerIds` rather than `playerId` the two sends differ in // notifyPlayerEphemeral carries `playerId` like the durable send,
// whether an offline recipient gets the frame later, which is a thing worth // notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
// asserting (a cheer's effect is broadcast to a room this way). // 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: ` script: `
import { DurableObject } from 'cloudflare:workers' import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject { export class NotificationsHub extends DurableObject {
@@ -38,6 +40,10 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data }) this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true } 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) { async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data }) this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 } return { delivered: 0 }
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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' '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, * The `error_description` a grant is refused with when the caller's account is not itself
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on * banned but shares an identity with one that is (see bans-db's linked arms). A fixed
* this exact string to put a real sentence in front of a player — anything varying would * sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* fall through to the generic "you could not be signed in". Keep the two in sync. * 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
const BANNED_DESCRIPTION = 'this account is banned' * 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 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 * A DIRECTLY banned account is not refused here at all — see the token grant.
* 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.
*/ */
const BLOCKED_DESCRIPTION = 'this device or network is blocked' 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', '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.', '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', '**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a', 'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its', 'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'own when `ban_expires` passes, and never if that is null.', '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', 'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP', 'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned', '`lastLoginIp`, or the address this request came from) with a banned one, and a',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.', '`create_account` carrying either, which is refused BEFORE it mints anything. Such',
'Those two arms are the operators `BAN_EVASION_MATCH` knob (`ip`, `platform`, or', 'an account has no ban of its own for the block screen to describe, so there is',
'`off`); the ban on the account itself is always enforced. A linked match answers a', 'nothing to let it in for. Those two arms are the operators `BAN_EVASION_MATCH`',
'deliberately vaguer description than a direct one — the account refused may belong', 'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'to a housemate of the banned player rather than to them.', 'account refused may belong to a housemate of the banned player rather than to them.',
].join('\n'), ].join('\n'),
requestBody: form( requestBody: form(
TokenRequest, TokenRequest,
@@ -633,7 +633,7 @@ const app = new Hono<App>()
[ [
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an', 'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,', '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(' ') ].join(' ')
), ),
500: json( 500: json(
@@ -789,9 +789,9 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword. // via create_account or /account/me/changepassword.
let accountId: string let accountId: string
if (grantType === 'create_account') { if (grantType === 'create_account') {
// A banned player's next move is a new account, so the ban is checked BEFORE // A banned player's next move is a new account, so the evasion arms are checked
// one is minted — against the only identity a signup has, the IP it came from // BEFORE one is minted — against the only identity a signup has, the IP it came
// and the platform identity it just proved. Refusing after the fact (as the // 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 // 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 // row behind and burns a slot off both signup caps, so the evader gets to keep
// making them. // making them.
@@ -987,14 +987,19 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) 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 // A DIRECTLY banned account still gets its token. The client needs one to reach
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner // `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// one, which still has to exist because a token issued before the ban stays valid // (category, time left, "Rule violation") — refused here, they would only ever see
// until it expires. // 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 // Ban EVASION is still refused here: an account that merely shares a device or
// grant: password, cached_login and a refresh_token redeemed by a client that has // network with a banned one has no ban of its own for that screen to describe, so
// been running since before the ban. Deliberately AFTER the credential checks — // 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 // 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. // 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 }, identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
}) })
if (ban) { if (ban && ban.via !== 'account') {
logger.info('token refused: player banned', { logger.info('token refused: ban evasion', {
accountId, accountId,
grantType, grantType,
via: ban.via, via: ban.via,
@@ -1016,13 +1021,15 @@ const app = new Hono<App>()
reportId: ban.ban.id, reportId: ban.ban.id,
banExpires: ban.ban.ban_expires, banExpires: ban.ban.ban_expires,
}) })
return c.json( return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
{ }
error: 'invalid_grant', if (ban) {
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION, logger.info('token issued to banned account', {
}, accountId,
400 grantType,
) reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
} }
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing // 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 // 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 // authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on // /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
// every login and every refresh_token grant. // account serves both; they thus refresh on every login and every refresh_token
// grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId)) const roleAccount = await getAccount(c.env.DB, Number(accountId))
// A refresh grant posts no platform of its own, so the identity comes off the // 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. // account — the same read, and the only place the bound identity is authoritative.
@@ -1056,7 +1064,13 @@ const app = new Hono<App>()
jwtSecret, jwtSecret,
accountRoles(roleAccount), accountRoles(roleAccount),
accountPrivileges(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 // Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token. // 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 { resolver } from 'hono-openapi'
import { z } from 'zod' import { z } from 'zod'
import { PlatformType } from '@repo/domain/src/enums'
import type { OpenAPIV3_1 } from 'openapi-types' 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 * PlatformType, the client's platform enum — the single source for the schema and
* the single source for the schema and description below. The `platform` form field * description below. It lives in `@repo/domain` rather than here because the link table
* is posted as the integer; the token's `platform` claim carries the name. * (`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 = { export { PlatformType } from '@repo/domain/src/enums'
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]
/** /**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified — * 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 * 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 * 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. * 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). */ /** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [ export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account ( `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 WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''` 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. */ /** One account ↔ platform identity link. */
export interface PlatformLink { export interface PlatformLink {
accountId: number accountId: number
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
platformId: string platformId: string
): Promise<PlatformLink[]> { ): Promise<PlatformLink[]> {
if (platformId === '') return [] 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 const { results } = await db
.prepare( .prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id` `${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 platformId: string
): Promise<PlatformLink[]> { ): Promise<PlatformLink[]> {
if (platformId === '') return [] 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 const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`) .prepare(
.bind(platformId) `${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
ORDER BY linked_at, account_id`
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.all<LinkRow>() .all<LinkRow>()
return results return results
} }
+140 -44
View File
@@ -21,7 +21,9 @@ import {
createReport, createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL, SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db' } from '../../../../api/src/reports-db'
import { PlatformType } from '../../openapi'
import { import {
countAccountsForPlatformIdentity,
getLinksForAccount, getLinksForAccount,
linkPlatformIdentity, linkPlatformIdentity,
PLATFORM_BACKFILL_SQL, PLATFORM_BACKFILL_SQL,
@@ -87,8 +89,8 @@ beforeAll(async () => {
IsDorm: false, IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }], SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
}) })
// Report table (owned by the api worker) — a banned account is refused a token, and // Report table (owned by the api worker) — a ban is a report row with `banned` set;
// 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() 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 // 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 // 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. // 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') expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array. // No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined() 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') expect(payload.scope).toContain('rn.api')
}) })
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator'])) 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 () => { 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)') await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind( .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 // A banned account is still issued a token: the game client needs one to reach the api
// token every other worker is shut to it. The ban is a `report` row with `banned` set // worker's moderationBlockDetails, which is where the player is shown WHY they are
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued // blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
// before it was handed down. // 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', () => { 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 seedAccount(6101, 'BannedPlayer')
await banAccount(6101) await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`) const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400) expect(res.status).toBe(200)
expect(res.json.error).toBe('invalid_grant') expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
// 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')
}) })
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 seedAccount(6102, 'BannedByName')
await banAccount(6102) await banAccount(6102)
const res = await postToken( const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}` `grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
) )
expect(res.status).toBe(400) expect(res.status).toBe(200)
expect(res.json.error_description).toBe('this account is banned') 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 // A client that was already signed in when the ban landed refreshes as normal — its
// token; redeeming it must not renew the session. // next matchmake is what refuses it, and moderationBlockDetails says why.
test('POST /connect/token refuses to refresh a banned accounts session', async () => { test('POST /connect/token refreshes a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater') await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`) const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200) expect(login.status).toBe(200)
@@ -1368,13 +1474,12 @@ describe('banned accounts', () => {
const refreshed = await postToken( const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
) )
expect(refreshed.status).toBe(400) expect(refreshed.status).toBe(200)
expect(refreshed.json.error_description).toBe('this account is banned') 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 // A ban does not loosen the credential check: a wrong password on a banned account is
// account still answers the ordinary bad-credential refusal — it can't be used to // the ordinary bad-credential refusal.
// find out whether an account exists or is banned without knowing its password.
test('a wrong password on a banned account is still a credential refusal', async () => { test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw') await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104) await banAccount(6104)
@@ -1384,23 +1489,13 @@ describe('banned accounts', () => {
expect(res.json.error_description).toBe('invalid account_id or password') 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('a ban that has not expired yet still issues a token', async () => {
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 () => {
await seedAccount(6106, 'StillServing') await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString()) await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`) const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400) expect(res.status).toBe(200)
expect(res.json.error_description).toBe('this account is banned') expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
}) })
// A report is not a ban until a moderator converts it. // 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 // 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 // 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 // signup carrying either is refused before it mints anything. Unlike the banned account
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob. // 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', () => { describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */ /** 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> = {}) => { 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. // 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 const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' }) await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader') await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
@@ -1524,10 +1621,9 @@ describe('ban evasion at the token endpoint', () => {
env.BAN_EVASION_MATCH = 'off' env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200) expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says. // The banned account itself signs in whatever the knob says — its ban is
const banned = await login(6320) // enforced at matchmake, and the knob only governs the linked arms.
expect(banned.status).toBe(400) expect((await login(6320)).status).toBe(200)
expect(banned.json.error_description).toBe('this account is banned')
} finally { } finally {
env.BAN_EVASION_MATCH = original env.BAN_EVASION_MATCH = original
} }
+2 -2
View File
@@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"upload_source_maps": true, "upload_source_maps": true,
"cache": {
"enabled": true
},
"observability": { "observability": {
"logs": { "logs": {
"enabled": true, "enabled": true,
+2 -2
View File
@@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+24 -39
View File
@@ -14,13 +14,15 @@ const PAGE_SOURCES = [
'StoreConsumables', 'StoreConsumables',
] ]
interface Section { /**
id: string * A section as published. The reference captures are camelCase and the hand-authored
sectionType: number * store pages PascalCase; the client's decoder is case-insensitive, so both are served
sectionSubType: string * as-is and the tests read either spelling.
source: string */
sourceMetadata: string | null type Section = Record<string, unknown>
displayMetadata: string | null
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. */ /** 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. // is reachable without the worker knowing its name.
it.each(PAGE_SOURCES)('serves %s', async (type) => { it.each(PAGE_SOURCES)('serves %s', async (type) => {
const sections = await pageSource(type) const sections = await pageSource(type)
expect(sections.length).toBeGreaterThan(0) expect(Array.isArray(sections)).toBe(true)
for (const section of sections) { for (const section of sections) {
expect(typeof section.id).toBe('string') expect(typeof field(section, 'id')).toBe('string')
expect(typeof section.sectionType).toBe('number') expect(typeof field(section, 'sectionType')).toBe('number')
expect(typeof section.source).toBe('string') expect(typeof field(section, 'source')).toBe('string')
// An embedded JSON *string* the client parses itself, not an object — or null, // An embedded JSON *string* the client parses itself, not an object — or null,
// which several store and play-highlight sections use. // which several store and play-highlight sections use.
if (section.displayMetadata !== null) { const display = field(section, 'displayMetadata')
expect(typeof section.displayMetadata).toBe('string') if (display !== null && display !== undefined) {
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow() 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. // 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 () => { it('StoreCategories only carries sections the store page builder keeps', async () => {
for (const section of await pageSource('StoreCategories')) { for (const section of await pageSource('StoreCategories')) {
expect([4, 13]).toContain(section.sectionType) expect([4, 13]).toContain(field(section, 'sectionType'))
expect(section.displayMetadata).toBeTruthy() const display = field(section, 'displayMetadata')
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow() expect(display).toBeTruthy()
if (section.sectionType === 13) { expect(() => JSON.parse(display as string)).not.toThrow()
expect(['CuratedList', 'PageSource']).toContain(section.source) if (field(section, 'sectionType') === 13) {
expect(section.sourceMetadata).toBeTruthy() 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 asset manifest is case-sensitive and there is no index to fold case against, so
// the name has to match the file exactly. // the name has to match the file exactly.
it('404s a name whose case does not match the file', async () => { it('404s a name whose case does not match the file', async () => {
+1
View File
@@ -0,0 +1 @@
[]
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+449 -157
View File
@@ -17,14 +17,14 @@ import {
setOutfit, setOutfit,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' 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 { import {
getCustomAvatarItems, getCustomAvatarItems,
toUgcPurchasable, toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db' } 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 // 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. // so a gift note is masked by the very same word list every other player-typed string is.
import { censorSwears } from '../../api/src/sanitize' 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 defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json' import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json' import myProgress from '../static/my-progress.json'
import questRewards from '../static/quest-rewards.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { import {
ALL_PLATFORMS, ALL_PLATFORMS,
@@ -51,6 +52,7 @@ import {
isSpendable, isSpendable,
spendCurrency, spendCurrency,
} from './balance-db' } from './balance-db'
import { getCatalogItem } from './catalog-db'
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks // `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 // 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. // name the same moment or a build gets a store built to a different cutoff.
@@ -79,7 +81,9 @@ import {
BalanceEntry, BalanceEntry,
BulkPurchaseRequest, BulkPurchaseRequest,
BulkPurchaseResponse, BulkPurchaseResponse,
BuyInventionRequest,
BuyInventionResponse, BuyInventionResponse,
BuyInventionV3Response,
BuyItemRequest, BuyItemRequest,
BuyItemResponse, BuyItemResponse,
ChallengeProgressRequest, ChallengeProgressRequest,
@@ -124,6 +128,7 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db'
import type { import type {
BalanceResponsePayload, BalanceResponsePayload,
PurchaseBalanceModificationPayload, 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()) 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 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 * 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 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, * Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
* shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which * `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
* price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the * it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
* `developer` role IS the subscription; if a real subscription store ever lands, this is * applied the discount itself and then had the buy refused as a price mismatch is exactly
* the only place that has to learn about it. Read from the token's `role` claim, never the * what one definition prevents.
* body; no or an invalid token is "not subscribed". *
* 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> { async function isSubscriber(c: Context<App>): Promise<boolean> {
const roles = await authedRoles(c) return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
return roles?.includes(DEVELOPER_ROLE) ?? false
} }
/** `SubscriptionLevel.Gold`. 1 is Platinum. */ /** `SubscriptionLevel.Gold`. 1 is Platinum. */
@@ -446,20 +453,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
const STUB_SUBSCRIPTION_ID = 1 const STUB_SUBSCRIPTION_ID = 1
/** /**
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the * The complimentary subscription a subscriber reports — Rec Room Plus, which the client's
* client's API calls a `CampusCard`. * 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 * Nothing here sells subscriptions, so holding one of those IS the subscription. Every
* paid-tier surfaces get exercised without a store. Every field is computed per call and * field is computed per call and none of it is persisted, so this is not a record of
* none of it is persisted, so this is not a record of anything — revoking the role revokes * anything — dropping the role or the flag drops the subscription, and no expiry sweep or
* the subscription, and no expiry sweep or renewal exists. * renewal exists.
* *
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one * `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 * 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. * 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. * 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() const now = new Date()
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next // Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
// year whether or not a leap day falls in between. // 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` * A storefront catalog entry's `GiftDrop` (`static/storefronts/sf{N}.json`) — what a store
* reads: each store item carries the `GiftDrop` describing what you get, a list of * item hands over. Field-for-field the client's own `GiftDrop` class, in its declared
* `Prices` per currency, and optionally `SubscriberPrices` — the discounted list a Rec Room * order, so a name here is a name the client reads.
* Plus subscriber is shown and pays. The catalogs hold more fields (IsFeatured, …) that *
* the purchase path doesn't need. * 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 { 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 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 ConsumableItemDesc: string
AvatarItemDesc: string AvatarItemDesc: string
/** A UGC item's guid. Not on any captured entry — the captures predate them. */
CustomAvatarItemId?: string | null
AvatarItemType: number | null AvatarItemType: number | null
EquipmentPrefabName: string EquipmentPrefabName: string
EquipmentModificationGuid: 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 * 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 * 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." * random 4-star item that you don't have."
*/ */
IsQuery?: boolean 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 * 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 * 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 QueryRedirectRarity?: number
/** /**
* XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a * XP the drop pays out. Ours, not the client's and not any catalog's — a bought item is
* game reward is XP in a gift box, so the box and its notification carry the amount from * an item, but a game reward is XP in a gift box, so the box and its notification carry
* here. The XP itself is banked in `progression`, not read back off the box. * the amount from here. The XP itself is banked in `progression`, not read back off the
* box.
*/ */
Xp?: number Xp?: number
} }
@@ -807,7 +857,7 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
AvatarItemDesc: giftDrop.AvatarItemDesc, AvatarItemDesc: giftDrop.AvatarItemDesc,
PlatformMask: -1, PlatformMask: -1,
FriendlyName: giftDrop.FriendlyName, FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip, Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity, Rarity: giftDrop.Rarity,
} }
} }
@@ -818,7 +868,7 @@ function toEquipment(giftDrop: StoreGiftDrop): Equipment {
ModificationGuid: giftDrop.EquipmentModificationGuid, ModificationGuid: giftDrop.EquipmentModificationGuid,
PrefabName: giftDrop.EquipmentPrefabName, PrefabName: giftDrop.EquipmentPrefabName,
FriendlyName: giftDrop.FriendlyName, FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip, Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity, Rarity: giftDrop.Rarity,
PlatformMask: -1, PlatformMask: -1,
Favorited: false, 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 * 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 * 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 // 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. // posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false }) const app = new Hono<App>({ strict: false })
@@ -3014,8 +3259,8 @@ const app = new Hono<App>({ strict: false })
summary: 'Buy a storefront item', summary: 'Buy a storefront item',
description: [ description: [
'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice`', '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', 'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their', '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,', 'client applies the discount itself and not to every item — debits the buyer atomically,',
'grants the item (into the inventory or', 'grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item — and its', '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 // Buy an invention. [Authorize]. A GET, despite being a purchase — the 2023 client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer. // `?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
// A priced invention is settled player-to-player: the buyer is debited its `Price` in // different envelope back; the two share {@link settleInventionPurchase}, which is where
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the // the money and the rules live, and build their own bodies from what it returns.
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
//
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
// bumped: that column lives on the `invention` table the `api` worker owns, and this
// worker only reads it.
.get( .get(
'/api/storefronts/v2/buyInvention', '/api/storefronts/v2/buyInvention',
describeRoute({ 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. // 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 requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId) const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
if (invention === null) return c.json({ error: 'Invention not found' }, 404) if (settled instanceof Response) return settled
// 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 c.json({ return c.json({
BalanceUpdateResponse: { BalanceUpdateResponse: {
Balance: balance, Balance: settled.balance,
BalanceType: ALL_PLATFORMS, BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens, 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 // 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 // owed another reward while a second Soccer match inside the hour is not. An ask that
// sends no context keys on `''`. // 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( .post(
'/api/gamerewards/v1/request', '/api/gamerewards/v1/request',
describeRoute({ 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', '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', '`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', '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', 'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
'(on-cooldown) ask both answer `[]`.', '(`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(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'), 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. // 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. // 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 { 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) await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// Every grant moves the bar, whether or not it crossed a level. // Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression) 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 // 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 // buy one from: Plus is claimed on the website by proving a Discord role, and reaches
// reports an active Gold year, everyone else reports none. Nothing is stored — see // this worker as the token's `rn.plus` claim. A caller carrying it reports an active
// `developerSubscription`. // 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 // 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 // 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 // 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( .post(
'/api/CampusCard/v1/UpdateAndGetSubscription', '/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Subscription lookup', summary: 'Subscription lookup',
description: [ description: [
'The callers Rec Room Plus subscription. Nothing sells subscriptions here, so the', 'The callers Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
'operator-granted `developer` role stands in for one: a developers token reports an', 'claimed on the website by proving a qualifying role in the community Discord, and',
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),', 'arrives as the tokens `rn.plus` claim. A token carrying it reports an active Gold',
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —', '(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:', 'year from the call; every other caller gets `{}`. The `developer` role does NOT',
'the role IS the subscription, so revoking it revokes this.', '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(' '), ].join(' '),
responses: { responses: {
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'), 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) const id = await authedId(c)
if (id === null) return c.json({}) if (id === null) return c.json({})
return c.json({ return c.json({
Subscription: developerSubscription(id), Subscription: plusSubscription(id),
PlatformAccountSubscribedPlayerId: null, 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, * 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 * so this is the complimentary subscription reported by a caller whose token carries
* `developerSubscription` in econ.app.ts for why each field reads the way it does. * `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({ export const SubscriptionDto = z.object({
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'), 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 * The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING * GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body
* total, not the change, and `Data` is a single invention rather than a gift-drop list) * that is the only difference between the two routes.
* and the invention envelope the invention endpoints already serve. */
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({ export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({ BalanceUpdateResponse: z.object({
@@ -400,6 +415,54 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'), .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. */ /** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */ /** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({ export const UgcPurchasableBulkRequest = z.object({
@@ -592,7 +655,9 @@ export const GameRewardRequest = z.object({
giftContext: z giftContext: z
.string() .string()
.optional() .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. // caller's build, and these assertions are about the file's CONTENTS.
import carriedItems from '../../../static/db/consumables.json' import carriedItems from '../../../static/db/consumables.json'
import skinsJson from '../../../static/db/skins.json' import skinsJson from '../../../static/db/skins.json'
import questRewards from '../../../static/quest-rewards.json'
import sf32025 from '../../../static/storefronts/sf3-2025.json' import sf32025 from '../../../static/storefronts/sf3-2025.json'
import sf3 from '../../../static/storefronts/sf3.json' import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db' import { SCHEMA_DDL } from '../../avatar-db'
@@ -284,12 +285,21 @@ async function bearer(
sub = '42', sub = '42',
roles?: string[], roles?: string[],
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */ /** 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>> { ): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const claims: Record<string, unknown> = { sub, exp: now + 3600 } const claims: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles !== undefined) claims.role = roles if (roles !== undefined) claims.role = roles
if (version !== undefined) claims['rn.ver'] = version 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( const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims) JSON.stringify(claims)
)}` )}`
@@ -1769,7 +1779,7 @@ describe('econ endpoints', () => {
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => { test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
await drainFrames() 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(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85) expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { 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`, { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(await bearer('325', ['gameClient', 'developer'])), ...(await bearer('325', ['gameClient'], undefined, true)),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -1806,7 +1816,7 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(await bearer('326', ['gameClient', 'developer'])), ...(await bearer('326', ['gameClient'], undefined, true)),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -1827,10 +1837,10 @@ describe('econ endpoints', () => {
}) })
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => { 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) expect(res.status).toBe(409)
// …and above it: a made-up price is a mismatch in either direction. // …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) expect(over.status).toBe(409)
}) })
@@ -2646,6 +2656,168 @@ describe('econ endpoints', () => {
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([]) 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 () => { 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 // 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=..`). // econ host, with a form body (`Id=..&UnlockedLevel=..`).
@@ -3113,6 +3285,7 @@ describe('econ endpoints', () => {
AvatarItemDesc: string AvatarItemDesc: string
ConsumableItemDesc: string ConsumableItemDesc: string
GiftRarity: number GiftRarity: number
GiftContext: number
}> }>
} }
@@ -3512,6 +3685,94 @@ describe('econ endpoints', () => {
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc) 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 () => { 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`, { const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST', method: 'POST',
@@ -3657,7 +3918,7 @@ describe('econ endpoints', () => {
}) })
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => { 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) expect(res.status).toBe(200)
const body = (await res.json()) as { const body = (await res.json()) as {
Subscription: Record<string, unknown> Subscription: Record<string, unknown>
@@ -3689,7 +3950,7 @@ describe('econ endpoints', () => {
}) })
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => { 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({}) expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
// A token with no `role` claim at all. // A token with no `role` claim at all.
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({}) expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
@@ -3699,6 +3960,56 @@ describe('econ endpoints', () => {
expect(await anon.json()).toEqual({}) 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 () => { test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`) const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
@@ -3786,6 +4097,7 @@ describe('econ endpoints', () => {
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective', 'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update', 'PUT /api/equipment/v1/update',
]) ])
+2 -2
View File
@@ -20,7 +20,7 @@
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -32,6 +32,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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}. */ /** The Photon Chat application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
PHOTON_CHAT_APP_ID?: string PHOTON_CHAT_APP_ID?: string
/** /**
* The Tachyon voice server the client is handed as `voiceConnectionInfo` on * The pool of Tachyon servers sessions are spread across a COMMA-SEPARATED list of
* `GET /player/connection-info`, as `host:port` (e.g. `66.228.47.217:7777`). * `host:port` entries (e.g. `66.228.47.217:7777,66.228.47.217:7778,45.79.2.10:7777`).
* Optional, and EMPTY when unset no separate voice server. Not a secret (the * One entry is a single server, which is the common case. Optional, and EMPTY when
* client receives it in the clear), so a plain var like the Photon ids. * 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 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 * 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 * 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, Accessibility,
areFriends, areFriends,
canManageRoom, canManageRoom,
countOnlinePlayers,
createRoomInstance, createRoomInstance,
createRoomInvite, createRoomInvite,
deleteEmptyRoomInstances, deleteEmptyRoomInstances,
@@ -17,6 +18,8 @@ import {
getExpiredPresenceInstanceIds, getExpiredPresenceInstanceIds,
getFriendIds, getFriendIds,
getJoinableInstance, getJoinableInstance,
deleteRoomInvite,
getLatestRoomInviteBetween,
getMostActiveClubhouses, getMostActiveClubhouses,
getOrCreateDormRoom, getOrCreateDormRoom,
getPresence, getPresence,
@@ -35,6 +38,7 @@ import {
MessageType, MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT, MOST_ACTIVE_CLUBHOUSE_LIMIT,
recordRoomVisit, recordRoomVisit,
recordStat,
refreshInstanceFullness, refreshInstanceFullness,
RoomInstanceType, RoomInstanceType,
setPresence, setPresence,
@@ -163,6 +167,66 @@ function instancePhotonRegion(env: Env): string {
return varOr(env.PHOTON_REGION, DEFAULT_PHOTON_REGION) 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 * 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 * 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 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 * "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 * 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 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. */ /** What an invite points at, in the shape both message versions need to describe it. */
type GameInviteTarget = { type GameInviteTarget = {
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */ /** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
@@ -684,7 +797,7 @@ type GameInviteTarget = {
roomId: number | null roomId: number | null
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */ /** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
name: string 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 inviteId: number
} }
@@ -1064,6 +1177,12 @@ async function readMatchmakeBody(
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The * 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 * leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
* failures), and never blocks the matchmake beyond the sends themselves. * 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( async function inviteParty(
c: Context<App>, c: Context<App>,
@@ -1071,21 +1190,33 @@ async function inviteParty(
playerIds: number[], playerIds: number[],
instance: RoomInstance instance: RoomInstance
): Promise<void> { ): 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 // 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. // message, so the type can't differ between them.
const type = await gameInviteType(c) const type = await gameInviteType(c)
await Promise.all( await Promise.all(
playerIds playerIds
.filter((pid) => pid !== leaderId) .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 } 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 // 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 // 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) // 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 const currentInstanceId = isPrivate
? undefined ? undefined
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId : (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 // The same build, with GAME_VERSION standing in for a token that names none. It scopes
// stamped on the instance when one is created, which is what keeps a session to a // the search below and is stamped on the instance when one is created, which is what
// single client version. // keeps a session to a single client version.
const gameVersion = await callerGameVersion(c) const gameVersion = tokenVersion ?? GAME_VERSION
// Reuse an existing joinable public instance *of the same subroom and the same // 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 // 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. // 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 // Accept a game invite and land in the inviter's instance
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it // (`/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 // 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 // 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 // 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. // 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( .get(
'/player/connection-info', '/player/connection-info',
describeRoute({ describeRoute({
@@ -2506,9 +2792,11 @@ const app = new Hono<App>()
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the', '`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
'Photon application ids, and the `photonRoomId` of the instance the caller is in', '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', '(from their presence, falling back to the `roomInstanceId` query param). The voice',
'fields carry the Tachyon voice server (`TACHYON_HOST_PORT`/`TACHYON_NAME`),', 'fields name the Tachyon server that instance was assigned — one entry out of the',
'empty when none is configured. `experiments` carries the', '`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is',
'clients networking flags.', '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(' '), ].join(' '),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
@@ -2532,14 +2820,22 @@ const app = new Hono<App>()
const apps = photonApps(c.env) const apps = photonApps(c.env)
const presence = await getPresence<RoomInstance>(c.env.DB, id) const presence = await getPresence<RoomInstance>(c.env.DB, id)
// Presence first (it's the instance the player is actually in); the query param // 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 ?? '' let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
if (!photonRoomId) { if (!photonRoomId) {
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10) const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
if (!Number.isNaN(requested)) { 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 // 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 // `aud` is the realtime app it's for. Nothing verifies it while Photon is
@@ -2561,13 +2857,14 @@ const app = new Hono<App>()
photonAuthToken, photonAuthToken,
...apps, ...apps,
photonRoomId, photonRoomId,
// The Tachyon voice server, from the operator's vars — empty strings when // The Tachyon server this instance runs on, picked out of the operator's
// unset (no separate voice server). Empty rather than null: the client'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 // 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 // string field. The presence payload's NULL_CONNECTION_INFO keeps its
// nulls — that one never carries credentials. // nulls — that one never carries credentials.
voiceConnectionInfo: varOr(c.env.TACHYON_HOST_PORT, ''), voiceConnectionInfo: tachyon.hostPort,
voiceServerId: varOr(c.env.TACHYON_NAME, ''), voiceServerId: tachyon.serverId,
experiments: PHOTON_EXPERIMENTS, experiments: PHOTON_EXPERIMENTS,
}, },
error: null, error: null,
@@ -2903,10 +3200,14 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
for (const instanceId of staleInstanceIds) { for (const instanceId of staleInstanceIds) {
await refreshInstanceFullness(env.DB, instanceId) 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 // The tagged logger is request-scoped (its middleware never runs for a cron), so
// log plainly here — Workers observability picks it up either way. // log plainly here — Workers observability picks it up either way.
console.log( 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) * 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 * 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 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 * the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool
* is configured. `photonRegion` matches the one stamped * 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. * on every room instance, so the two can't disagree.
*/ */
export const ConnectionInfo = z.object({ 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'), photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z voiceConnectionInfo: z
.string() .string()
.describe('The Tachyon voice server, `host:port`; empty when none is configured'), .describe('The instances Tachyon server, `host:port`; empty when none is configured'),
voiceServerId: z.string().describe('The Tachyon voice server id; empty when none is configured'), voiceServerId: z
.string()
.describe('That servers generated id (`tachyon-1`, …); cosmetic, empty when there is none'),
experiments: ConnectionExperiments, experiments: ConnectionExperiments,
}) })
@@ -278,10 +281,11 @@ export const NotifyDisconnectRequest = z.object({
* matchmakes that post no body at all. * matchmakes that post no body at all.
* *
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`, * This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why * `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/v2/player/:id`,
* it's a schema of its own; the room matchmakes extend it. Other fields the client sends * `/matchmake/instance/:id`), which is why it's a schema of its own; the room matchmakes
* (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`, * extend it. Other fields the client sends (`LoginLock`, `MaxPersistenceVersion`,
* `BypassMovementModeRestriction`) are accepted and ignored. * `VoiceServerVersion`, `BypassMovementModeRestriction`, `PlayerIsPartyMember`) are
* accepted and ignored.
*/ */
export const CorrelationIdRequest = z.object({ export const CorrelationIdRequest = z.object({
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'), CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
+389 -5
View File
@@ -20,6 +20,7 @@ import {
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
setPresence, setPresence,
STAT_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
@@ -124,6 +125,8 @@ beforeAll(async () => {
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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. // 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() 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 // Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as. // 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 // 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. // them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId, photonRoomId: matchmaked.roomInstance.photonRoomId,
// Empty strings, not nulls — unlike the presence payload's connection fields, // No TACHYON_HOST_PORT pool configured, so there is no server to name. Empty
// which stay null (they never carry credentials). // strings, not nulls — unlike the presence payload's connection fields, which
// stay null (they never carry credentials).
voiceConnectionInfo: '', voiceConnectionInfo: '',
voiceServerId: '', voiceServerId: '',
experiments: { experiments: {
@@ -1374,6 +1378,84 @@ describe('auth-gated endpoints', () => {
expect(body.value.photonRoomId).toBe('') 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 () => { 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 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 // 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) 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` // 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 // (the generated `created_at` column follows the blob), so the empty-instance sweep
// can be exercised without waiting out the grace window. // 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 one frame an invite from a client on `version` pushes, with the RoomInviteId
// the call answered — a v2 invite names it in its Data. // the call answered — a v2 invite names it in its Data.
const inviteFrom = async ( const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => {
version?: string
): Promise<{ frame: Sent; roomInviteId: number }> => {
await hub().fetch('http://do/all', { method: 'DELETE' }) await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/invite`, { const res = await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST', method: 'POST',
@@ -2810,6 +2914,14 @@ describe('auth-gated endpoints', () => {
RoomId: 2, 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 // Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped. // themselves is skipped.
await reset() await reset()
@@ -2825,6 +2937,58 @@ describe('auth-gated endpoints', () => {
expect(await sent()).toEqual([]) 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 () => { 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. // 8801 invites 8802. The invite row is what POST /invite answers with.
const instance = await createRoomInstance(env.DB, { const instance = await createRoomInstance(env.DB, {
@@ -2933,6 +3097,146 @@ describe('auth-gated endpoints', () => {
).toBe(401) ).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 () => { test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -2973,6 +3277,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/player/{playerId}', 'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}', 'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/v2/player/{playerId}',
'POST /matchmake/v2/room/{roomId}', 'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}', 'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player/exclusivelogin', '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 // 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 // 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. // 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 // 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, // 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 // 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 // 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 // pointing at your own Photon apps never means editing a versioned file. Unset the
+2 -2
View File
@@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -26,6 +26,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+22
View File
@@ -448,6 +448,28 @@ export class NotificationsHub extends DurableObject<Env> {
return { sent: this.broadcastToConnected(payload) } 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. */ /** Broadcast a notification to every connected (handshaken) client. */
async broadcast( async broadcast(
notificationType: string | number, notificationType: string | number,
+19
View File
@@ -176,6 +176,25 @@ const app = new Hono<App>()
return c.json({ success: true, ...result }) 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 result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessage(
body.playerId,
content
)
return c.json({ success: true, ...result })
})
// Read-only view of the hub's routing state, for working out why a notification // Read-only view of the hub's routing state, for working out why a notification
// didn't arrive: which connections are live, which players each one receives for, // didn't arrive: which connections are live, which players each one receives for,
// and what's queued for a player who wasn't reachable. // and what's queued for a player who wasn't reachable.
+73 -2
View File
@@ -65,7 +65,12 @@ interface HubRecord {
async function connect( async function connect(
id: string, id: string,
opts: { headers?: Record<string, string>; query?: 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 // The hub only accepts identified connections, so default to a token; pass explicit
// `headers` (`{}` for none) where the test is about who is connecting. // `headers` (`{}` for none) where the test is about who is connecting.
const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient'])) const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient']))
@@ -113,7 +118,7 @@ async function connect(
ws.send(`{"protocol":"json","version":1}${RS}`) ws.send(`{"protocol":"json","version":1}${RS}`)
await waitFor((r) => r.type === 1 && r.target === 'OnConnect') 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) const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
@@ -337,6 +342,72 @@ describe('notification delivery', () => {
b.ws.close() 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 () => { test('coach-message-all 400s on an empty message', async () => {
const res = await post('/internal/coach-message-all', { messageContent: ' ' }) const res = await post('/internal/coach-message-all', { messageContent: ' ' })
expect(res.status).toBe(400) expect(res.status).toBe(400)
+2 -2
View File
@@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"wrangler": "4.105.0" "wrangler": "4.128.0"
} }
} }
+2 -2
View File
@@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"hono": "4.12.27", "hono": "4.13.5",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -26,6 +26,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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 `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`. player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`). 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 > 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`) > call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
> doesn't wipe the others. > 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 ## KV namespace
```sh ```sh
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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, HealthResponse,
json, json,
PlayerSettingEntry, PlayerSettingEntry,
SettingFormDelete,
SettingFormWrite, SettingFormWrite,
SettingJsonDelete,
SettingJsonWrite, SettingJsonWrite,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
} from './openapi' } 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 * (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read. * 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 }] : [] 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>() const app = new Hono<App>()
.use( .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 // The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output. // openapi.ts). `hide: true` keeps this route out of its own output.
app.get( 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', () => { describe('playersettings endpoints', () => {
it('GET / reports service status', async () => { it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`) const res = await SELF.fetch(`${ORIGIN}/`)
@@ -134,6 +145,80 @@ describe('playersettings endpoints', () => {
expect(res.status).toBe(200) 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 () => { it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`) const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -154,7 +239,12 @@ describe('playersettings endpoints', () => {
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) 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 // Every operation carries a summary — a path present but undescribed is not
// documentation. // documentation.
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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. */ /** The `:playerId` path parameter on the unban route. */
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban') 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. */ /** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } } return { name, in: 'query', required: false, description, schema: { type: 'string' } }
@@ -311,9 +317,6 @@ export const RoomDto = z.object({
.int() .int()
.nullable() .nullable()
.describe('The rooms published snapshot. Nothing takes snapshots here, so always null'), .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 CCU: z
.int() .int()
.nullable() .nullable()
@@ -609,6 +612,28 @@ export const RoomBanEnvelope = z.object({
value: RoomBanDto.nullable().describe('Null on a rejection'), 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`. */ /** `PUT /rooms/{roomId}/warning`. */
export const WarningRequest = z.object({ export const WarningRequest = z.object({
warningMask: z.string().describe('Content-warning bit flags, as an integer'), warningMask: z.string().describe('Content-warning bit flags, as an integer'),
@@ -849,6 +874,14 @@ export const RoomExperiencePlayer = z
.array(z.unknown()) .array(z.unknown())
.describe('Always empty — no per-room experience is tracked') .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' * `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 * 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, countRoomsByCreator,
createSubRoom, createSubRoom,
deleteRoom, deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom, deleteSubRoom,
findSubRoom, findSubRoom,
getBaseRooms, getBaseRooms,
@@ -47,6 +48,7 @@ import {
searchRooms, searchRooms,
setRoomDescription, setRoomDescription,
setRoomImage, setRoomImage,
setRoomLeaderboard,
setRoomName, setRoomName,
setRoomRole, setRoomRole,
setSubRoomPermissions, setSubRoomPermissions,
@@ -90,6 +92,9 @@ import {
IsBannedPascalEnvelope, IsBannedPascalEnvelope,
json, json,
jsonBody, jsonBody,
leaderboardIdParam,
LeaderboardRequest,
LeaderboardResultEnvelope,
LoadScreenRequest, LoadScreenRequest,
MissingLookupParam, MissingLookupParam,
ModifySubRoomRequest, ModifySubRoomRequest,
@@ -118,6 +123,7 @@ import {
SaveSubRoomDataRequest, SaveSubRoomDataRequest,
SearchSuggestions, SearchSuggestions,
ServiceStatus, ServiceStatus,
ShowcasedRooms,
stringQuery, stringQuery,
SubRoomAccessibilityRequest, SubRoomAccessibilityRequest,
SubRoomDataSaveResponseDto, SubRoomDataSaveResponseDto,
@@ -643,6 +649,15 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
*/ */
const banEnvelope = roomEnvelope 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). */ /** Rooms created/owned by the authed caller (shared by the createdby routes). */
async function ownedRooms(c: Context<App>) { async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c) 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))) 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. // 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 // Paginated via skip/take (take defaults to 100). Returns a bare array, like the
// other room-source `*by/me` lists the client loads. // 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) await setRoomName(c.env.DB, roomId, name)
// The rename answers a bare `{ Success }` with no room in it, so the client has // 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. // 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. // 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 }) 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 // 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 // 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 // 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 // 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 // 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 // zero/null one to its parser. `FriendlyName` is deliberately NOT among them: this server
// labels the room from it. // does not serve a display name apart from `Name`, and migration 0017 strips any stored one.
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', async () => { it('GET /rooms/:id carries BoostCount, CurrentSnapshotId and CCU, and no FriendlyName', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/1`) const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown> const body = (await res.json()) as Record<string, unknown>
expect(body).toHaveProperty('BoostCount', 0) expect(body).toHaveProperty('BoostCount', 0)
expect(body).toHaveProperty('CurrentSnapshotId', null) expect(body).toHaveProperty('CurrentSnapshotId', null)
expect(body).toHaveProperty('FriendlyName', body.Name)
expect(body).toHaveProperty('CCU', null) expect(body).toHaveProperty('CCU', null)
expect(body).not.toHaveProperty('FriendlyName')
}) })
// Pinned whole: these are the numbers the client's publish UI counts against, and // 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([]) 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 () => { it('GET /rooms/:id 404s for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`) const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
@@ -1818,6 +1835,90 @@ describe('rooms endpoints', () => {
.run() .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 () => { it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown } type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
@@ -2915,13 +3016,12 @@ describe('rooms endpoints', () => {
expect(await bodyOf(ok)).toMatchObject({ Success: true }) expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as { const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
RoomId: number RoomId: number
FriendlyName: string Name: string
} }
expect(room.RoomId).toBe(2) expect(room.RoomId).toBe(2)
// The DISPLAY name follows the rename. It is otherwise only defaulted to `Name` on expect(room.Name).toBe('RenamedCenter')
// read, so a room that had ever stored one would keep labelling itself with the old // A rename must not resurrect the retired display name.
// name while every name-keyed lookup used the new one. expect(room).not.toHaveProperty('FriendlyName')
expect(room.FriendlyName).toBe('RenamedCenter')
}) })
/** The hub stub records every notifyPlayer call — see vitest.config.ts. */ /** The hub stub records every notifyPlayer call — see vitest.config.ts. */
@@ -2941,14 +3041,11 @@ describe('rooms endpoints', () => {
const sent = await sentNotifications() const sent = await sentNotifications()
expect(sent).toHaveLength(1) 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].playerId).toBe(1)
expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom) expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
expect(sent[0].data).toMatchObject({ expect(sent[0].data).toMatchObject({ RoomId: 2, Name: 'PushedRename' })
RoomId: 2, expect(sent[0].data).not.toHaveProperty('FriendlyName')
Name: 'PushedRename',
FriendlyName: 'PushedRename',
})
// Put it back for the tests that read room 2 by name. // Put it back for the tests that read room 2 by name.
await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1') await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
@@ -3972,6 +4069,7 @@ describe('rooms endpoints', () => {
'DELETE /rooms/{roomId}/bans/{playerId}', 'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer', 'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite', 'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/leaderboards/{leaderboardId}',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}', 'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /', 'GET /',
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned', '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/no_unity_assets',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
'GET /roomserver/rooms/createdby/me', 'GET /roomserver/rooms/createdby/me',
'GET /showcase/{playerId}',
'POST /rooms/bulk', 'POST /rooms/bulk',
'POST /rooms/{roomId}/bans', 'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone', 'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data', 'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
+104
View File
@@ -4563,6 +4563,110 @@
"LoadScreens": [], "LoadScreens": [],
"RestrictedCircuitsAllowListNames": [] "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, "RoomId": 51,
"Name": "DriveIn", "Name": "DriveIn",
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5", "@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9", "@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.13.5",
"hono-openapi": "1.3.1", "hono-openapi": "1.3.1",
"openapi-types": "12.1.3", "openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1", "@types/node": "26.0.1",
"vitest": "4.1.9", "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' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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 // 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 // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here. // signed by `auth` verify here.
+26 -1
View File
@@ -2,7 +2,13 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' 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 { validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -57,6 +63,19 @@ const UPLOAD_EXTENSION: Record<number, string> = {
5: '.inv', 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 { function extensionForFileType(fileType: string): string {
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? '' 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'), 200: json(UploadResponse, 'The stored (or echoed) file name'),
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'), 400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The binary file exceeds the configured upload limit'),
}, },
}), }),
async (c) => { async (c) => {
@@ -143,6 +163,11 @@ const app = new Hono<App>()
const file = Object.values(body).find((v): v is File => v instanceof File) const file = Object.values(body).find((v): v is File => v instanceof File)
if (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 fileType = textField(body, 'filetype') ?? '0'
const subfolder = subfolderForFileType(fileType) const subfolder = subfolderForFileType(fileType)
if (subfolder === undefined) { 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 () => { it('POST /upload echoes an explicit name when no binary is posted', async () => {
const form = new FormData() const form = new FormData()
form.set('FileType', '3') 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`. 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 ## Development
### Run in dev mode ### Run in dev mode
+4 -3
View File
@@ -18,8 +18,9 @@
"dependencies": { "dependencies": {
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@scalar/api-reference": "1.63.0", "@repo/jwt": "workspace:*",
"hono": "4.12.27", "@scalar/api-reference": "1.67.0",
"hono": "4.13.5",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"workers-tagged-logger": "1.0.1" "workers-tagged-logger": "1.0.1"
@@ -35,6 +36,6 @@
"@vitejs/plugin-react": "5.2.0", "@vitejs/plugin-react": "5.2.0",
"vite": "6.4.3", "vite": "6.4.3",
"vitest": "4.1.9", "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 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.', '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.', '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 // An account that shares a device or network with a BANNED one. Phrased for BOTH the
// 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
// person evading a ban and the housemate of one — the IP arm cannot tell them apart — // 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':
'This device or network is blocked. If you think that is a mistake, contact the server operator.', '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 { interface SiteConfig {
signupEnabled: boolean signupEnabled: boolean
turnstileSiteKey: string | null 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`). */ /** The private self DTO from `accounts` (`GET /account/me`). */
@@ -495,6 +507,50 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
authed: true, 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 * 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 * 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, 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. */ /** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() { function useRouter() {
const [path, setPath] = useState(() => window.location.pathname) 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 * 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 * 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(() => setAccount(null))
}) })
.catch(() => { .catch(() => {
setConfig({ signupEnabled: false, turnstileSiteKey: null }) setConfig({
signupEnabled: false,
turnstileSiteKey: null,
benefitsEnabled: false,
discordAuthorizeUrl: null,
})
setAccount(null) setAccount(null)
}) })
}, []) }, [])
@@ -627,7 +932,11 @@ export function App() {
onAuthed={setAccount} onAuthed={setAccount}
/> />
) : path === '/account' ? ( ) : 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 ? ( ) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} /> <RoomPage account={account} roomId={roomId} navigate={navigate} />
) : ( ) : (
@@ -818,7 +1127,7 @@ function Stage({
trademark stays out of the headline and appears lower down, in trademark stays out of the headline and appears lower down, in
plain nominative use next to the disclaimer. */} plain nominative use next to the disclaimer. */}
<h1 className="stage-title"> <h1 className="stage-title">
Play like it&apos;s <em>2023</em>. Play <em>today</em>!
</h1> </h1>
<p className="stage-lede"> <p className="stage-lede">
The servers you remember, rebuilt and running free, open source, and up right now. 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 ( return (
<section className="about"> <section className="about">
<div> <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"> <p className="about-lede">
A free fan project, made by players who missed it. Aiming to be{' '} A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable {' '} <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. */ /** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({ function AccountPage({
account, account,
config,
navigate, navigate,
onChange, onChange,
}: { }: {
account: SelfAccount | null | undefined account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate navigate: Navigate
onChange: (a: SelfAccount) => void onChange: (a: SelfAccount) => void
}) { }) {
@@ -1052,7 +1363,7 @@ function AccountPage({
return ( return (
<main className="shell wide"> <main className="shell wide">
<h1>My account</h1> <h1>My account</h1>
<Dashboard account={account} navigate={navigate} onChange={onChange} /> <Dashboard account={account} config={config} navigate={navigate} onChange={onChange} />
</main> </main>
) )
} }
@@ -1382,10 +1693,10 @@ function BlobUpload({
<span className="badge beta">Beta</span> <span className="badge beta">Beta</span>
</p> </p>
<p className="muted blob-upload-caveat"> <p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file the server stores whatever it New and lightly tested. Nothing here checks the file the server stores whatever it is and
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
scene data from a room built on anything newer may not load at all. Download the save from a room built on anything newer may not load at all. Download the save above and keep it
above and keep it before replacing it. before replacing it.
</p> </p>
<label className="blob-upload-file"> <label className="blob-upload-file">
Scene data file Scene data file
@@ -1776,10 +2087,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
function Dashboard({ function Dashboard({
account, account,
config,
navigate, navigate,
onChange, onChange,
}: { }: {
account: SelfAccount account: SelfAccount
config: SiteConfig | undefined
navigate: Navigate navigate: Navigate
onChange: (a: SelfAccount) => void onChange: (a: SelfAccount) => void
}) { }) {
@@ -1800,10 +2113,22 @@ function Dashboard({
render: () => <EmailForm account={account} onChange={onChange} />, render: () => <EmailForm account={account} onChange={onChange} />,
}, },
{ id: 'password', label: 'Password', render: () => <PasswordForm /> }, { 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() ...(isAdmin()
? [ ? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> }, { 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() { function CoachMessageForm() {
const [recipient, setRecipient] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const { pending, error, done, run } = useAction() 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 ( return (
<section className="card"> <section className="card">
<h2>Broadcast message</h2> <h2>Coach message</h2>
<p className="muted"> <p className="muted">
Send a message from the Coach to every connected player. Players who aren&apos;t online Send a message from the Coach to one player, or leave the recipient blank to send it to
won&apos;t receive it. 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> </p>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
void run(async () => { void run(async () => {
const { sent } = await coachMessageAll(message.trim()) const content = message.trim()
if (!toOne) {
const { sent } = await coachMessageAll(content)
setMessage('') setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.` 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> <label>
Message Message
<textarea <textarea
@@ -1972,7 +2330,7 @@ function CoachMessageForm() {
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>} {done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}> <button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send to all online'} {pending ? 'Sending…' : toOne ? `Send to @${handle}` : 'Send to all online'}
</button> </button>
</form> </form>
</section> </section>
+2 -1
View File
@@ -222,7 +222,8 @@ body {
text-wrap: balance; 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 { .stage-title em {
font-style: normal; font-style: normal;
color: var(--accent); 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`). */ /** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
ASSETS: Fetcher ASSETS: Fetcher
/** /**
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it * The shared `recflare` D1. www asks it two things: the live presence head-count
* is the live presence head-count behind `/server-status`. Every table it can see is * behind `/server-status`, and the caller's `account` row on the benefits claim
* owned (and migrated) by another worker. * 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 DB: D1Database
/** /**
@@ -39,6 +41,48 @@ export type Env = SharedHonoEnv & {
* failing to resolve closes web signup see src/turnstile.ts. * failing to resolve closes web signup see src/turnstile.ts.
*/ */
TURNSTILE_SECRET_KEY: SecretsStoreSecret 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 */ /** 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 { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest' 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 { 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 { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile' import { turnstileKeys } from '../../turnstile'
@@ -21,12 +32,37 @@ declare module 'cloudflare:test' {
const TEST_SITE_KEY = '1x00000000000000000000AA' const TEST_SITE_KEY = '1x00000000000000000000AA'
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA' 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 () => { beforeAll(async () => {
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY) await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_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 // `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. // 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() 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: // 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({ expect(await res.json()).toEqual({
signupEnabled: true, signupEnabled: true,
turnstileSiteKey: TEST_SITE_KEY, 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: { hosts: {
auth: 'https://auth.rec.example.com', auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.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) 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 // 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 // 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 // for are pinned here. If a section is renamed, re-read the VRC before loosening the
+206 -1
View File
@@ -1,10 +1,35 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db' import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers' import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
// external identity exactly like the Steam and Meta ones, and unlike a field on the
// account blob it can answer "is this Discord user already on another account" from an
// index. Nobody logs in with it: `auth` refuses a cached_login for any platform it can't
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from '../../auth/src/platform-db'
import { authUnreachable } from './auth-messages' import { authUnreachable } from './auth-messages'
import {
AUTHORIZE_URL,
discordConfig,
exchangeCode,
fetchGuildMembership,
qualifies,
redirectUri,
revokeToken,
SCOPES,
} from './discord'
import { docsPage, fetchSpec } from './docs' import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy' import { privacyPage } from './privacy'
import { turnstileKeys, verifyTurnstile } from './turnstile' import { turnstileKeys, verifyTurnstile } from './turnstile'
@@ -21,6 +46,7 @@ import {
storageBase, storageBase,
} from './upstream' } from './upstream'
import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/** /**
@@ -37,8 +63,49 @@ import type { App } from './context'
* there's no client contract being duplicated. * there's no client contract being duplicated.
* - `/api/config`, which tells the SPA the Turnstile site key and where the other * - `/api/config`, which tells the SPA the Turnstile site key and where the other
* workers live, so one client build works for any operator's domain. * workers live, so one client build works for any operator's domain.
* - `/api/benefits/*`, the Discord-verified benefits claim, for the same reason as
* signup: the OAuth2 client secret that turns Discord's authorization code into an
* access token can't ship to a browser. It is also the only route here that writes to
* the database, and so the only one that verifies a token itself (see the section).
*/ */
/**
* The account behind a request's bearer token, or null.
*
* www verifies a token itself for exactly one feature. Everywhere else the browser
* carries its token to the worker that owns the data (`accounts`, `rooms`, ) and that
* worker does the checking; but the benefits claim WRITES `hasPlus` onto an account row,
* and "which account" is the whole question asking the SPA would let anyone grant Plus
* to any id. Same key, same `@repo/jwt` validation (signature, exp) every other worker
* runs.
*/
const claimant = async (c: Context<App>): Promise<number | null> =>
validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
/**
* The Discord consent URL the SPA sends the player to, fully assembled here rather than
* in the browser everything in it (the scopes the claim needs, and the redirect URI,
* which must match the token exchange byte for byte) is this worker's business, and a
* page that built its own could drift from what `/api/benefits/claim` will accept.
*
* It carries no `state`. That is the SPA's to add and to check on the way back: it's a
* per-attempt CSRF nonce, so it has to be minted by the thing that will later verify it
* (see App.tsx). Everything else about the request is fixed by the server.
*/
function authorizeUrl(request: Request, clientId: string): string {
const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri(request),
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
// repeat claim; the player has already pressed a button that says what this does.
prompt: 'none',
}).toString()
return url.toString()
}
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -60,10 +127,16 @@ const app = new Hono<App>()
// one build works for any operator. The site key is public (it ships in the widget // one build works for any operator. The site key is public (it ships in the widget
// markup either way); the secret never leaves the worker. // markup either way); the secret never leaves the worker.
.get('/api/config', async (c) => { .get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env) const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)])
return c.json({ return c.json({
signupEnabled: keys !== null, signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null, turnstileSiteKey: keys?.siteKey ?? null,
// The benefits claim, on the same terms: open only when it's fully configured, and
// the SPA is handed a ready-made consent URL rather than the parts to build one.
// Nothing secret is served — the client id inside it is public — and the guild/role
// ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
hosts: { hosts: {
auth: authBase(c.env), auth: authBase(c.env),
accounts: accountsBase(c.env), accounts: accountsBase(c.env),
@@ -169,6 +242,138 @@ const app = new Hono<App>()
return c.json(token) return c.json(token)
}) })
// ---- Discord benefits claim ---------------------------------------------
/**
* Where the player's Discord already stands with this account what the claim page
* renders before anyone presses anything, so a player who has already claimed sees
* that rather than being walked through the flow again to find out.
*
* `hasPlus` is read from the account row rather than from a token claim: it is set
* here, after the token in the browser was issued, so a freshly-claimed player's token
* says nothing about it until they sign in again.
*/
.get('/api/benefits/status', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const [account, links] = await Promise.all([
getAccount(c.env.DB, accountId),
getLinksForAccount(c.env.DB, accountId),
])
return c.json({
hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The
// player knows their own Discord; the id is of no use to the page and every reason
// to keep an account's linked identities off the wire.
linked: links.some((link) => link.platform === PlatformType.Discord),
})
})
/**
* Redeem a Discord authorization code and, if the player holds the configured role in
* the configured guild, give the account Rec Room Plus.
*
* The browser sends ONLY the code. It never sees an access token: the exchange happens
* here with the client secret, the roles are read with the resulting token, and the
* token is handed straight back to Discord (see discord.ts). The redirect URI is
* derived from this request's own origin rather than accepted from the body, so the
* secret can't be used to redeem codes issued for somebody else's page.
*
* The claim is once-only PER DISCORD USER, not per account: the Discord id is stored
* beside the flag, and a code from a Discord member who has already claimed elsewhere
* is refused. Otherwise one person with the role could walk it around every account
* they own. Re-claiming on the same account is allowed and simply re-affirms the flag,
* which is what makes the page safe to reload and a lapsed-then-restored role
* re-claimable.
*
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
* That's deliberate for now a sweep would need a bot token to enumerate the guild,
* which this design specifically avoids but it does mean the flag records "held the
* role once", not "holds it today".
*/
.post('/api/benefits/claim', async (c) => {
const config = await discordConfig(c.env)
if (!config) return c.json({ error: 'Benefit claims are currently disabled.' }, 403)
const accountId = await claimant(c)
if (accountId === null) {
return c.json({ error: 'Please sign in before claiming your benefits.' }, 401)
}
type ClaimBody = { code?: string }
const { code } = await c.req.json<ClaimBody>().catch(() => ({}) as ClaimBody)
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
const accessToken = await exchangeCode(config, code, redirectUri(c.req.raw))
// A code lives about a minute and is single-use, so this is far and away the most
// likely failure a real player hits — hence a sentence about starting over rather
// than a relayed OAuth code, which would tell them nothing.
if (accessToken === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const membership = await fetchGuildMembership(accessToken, config.guildId)
// The token has told us everything it can; hand it back before answering, whatever
// the answer turns out to be. Awaited rather than fired into the void so a Worker
// that finishes the response can't cancel it.
await revokeToken(config, accessToken)
if (membership === null) {
return c.json({ error: 'You are not a member of our Discord server.' }, 403)
}
// Any ONE of the configured roles qualifies — see `qualifies`. The message stays
// singular-ish and names no role: which roles qualify is the operator's business to
// advertise in their own server, and listing them here would leak the guild's role
// layout to anyone who pressed the button.
if (!qualifies(membership.roles, config.roleIds)) {
return c.json({ error: 'Your Discord account does not have a qualifying role.' }, 403)
}
// The once-only guard, asked of the link table: is this Discord identity already on an
// account, and is that account someone else's? Re-claiming on the caller's OWN account
// is the idempotent case and must fall through — it's how a player whose role lapsed
// and came back re-affirms Plus, and it's what makes the page safe to reload.
//
// `countAccountsForPlatformIdentity` counts EVERY link for the identity, unfiltered by
// platform, which is why the claim can use the same helper `auth`'s per-identity
// signup cap does.
const alreadyMine = await isPlatformIdentityLinked(
c.env.DB,
accountId,
PlatformType.Discord,
membership.userId
)
if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB,
PlatformType.Discord,
membership.userId
)
if (claimedElsewhere > 0) {
logger.info('a discord account tried to claim benefits on a second account', {
accountId,
})
return c.json(
{ error: 'That Discord account has already claimed benefits on another account.' },
409
)
}
}
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
// FIRST claim's time), and the flag is already true on a re-claim.
await linkPlatformIdentity(c.env.DB, accountId, PlatformType.Discord, membership.userId)
await updateAccount(c.env.DB, accountId, { hasPlus: true })
logger.info('granted plus from a discord benefits claim', { accountId })
// The username is echoed for the confirmation line only — it is never stored, and a
// Discord member who has since renamed themselves is not a problem to solve here.
return c.json({ hasPlus: true, discordUsername: membership.username })
})
// ---- Privacy policy ----------------------------------------------------- // ---- Privacy policy -----------------------------------------------------
// Server-rendered rather than a SPA route so the page has real text without // Server-rendered rather than a SPA route so the page has real text without
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is // JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
+51 -1
View File
@@ -66,6 +66,37 @@
"binding": "TURNSTILE_SECRET_KEY", "binding": "TURNSTILE_SECRET_KEY",
"store_id": "local", "store_id": "local",
"secret_name": "TURNSTILE_SECRET_KEY" "secret_name": "TURNSTILE_SECRET_KEY"
},
// The shared HS256 signing key, bound here for the ONE www route that acts on a
// specific account: the benefits claim writes `hasPlus` onto the caller's row, so it
// has to verify which account is calling rather than trust the SPA.
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
// The Discord OAuth2 application behind the benefits claim. Same store, same
// public-key-beside-its-secret arrangement as the Turnstile pair: the client id
// ships to the browser to build the authorize URL, the secret never leaves the
// worker (see src/discord.ts).
//
// wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
// --scopes workers --remote
// wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
// --scopes workers --remote
//
// These two PLUS the DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS vars below are what
// OPENS the claim; with any of the four missing it stays closed, so an operator who
// skips this gets no claim rather than one that grants Plus without checking.
{
"binding": "DISCORD_CLIENT_ID",
"store_id": "local",
"secret_name": "DISCORD_CLIENT_ID"
},
{
"binding": "DISCORD_CLIENT_SECRET",
"store_id": "local",
"secret_name": "DISCORD_CLIENT_SECRET"
} }
], ],
// The `auth` worker, reached directly instead of over its public hostname. This is // The `auth` worker, reached directly instead of over its public hostname. This is
@@ -97,6 +128,25 @@
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which // (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
// is how one client build works for any operator. For local dev, point it at a // is how one client build works for any operator. For local dev, point it at a
// deployed domain so the page has real workers to call. // deployed domain so the page has real workers to call.
"DOMAIN": "rec.example.com" "DOMAIN": "rec.example.com",
// The Discord server the benefits claim checks membership of, and the roles in it
// that grant Rec Room Plus. Every value here is a Discord SNOWFLAKE all digits, no
// letters copied off a client with Developer Mode on (right-click the server or the
// role Copy ID). They are ids, not names: "Supporter" is what the role is called,
// 1077000000000000002 is what goes here. Not credentials, so they live in this file
// rather than in the Secrets Store; quoted as STRINGS because a snowflake exceeds
// 2^53 and would lose precision as a JSON number.
//
// ROLE_IDS is a LIST, separated by commas and/or whitespace. Any ONE of them
// qualifies, so several tiers can share the benefit:
//
// "DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002"
//
// Empty by default: an operator who hasn't set up a Discord app has no server to
// point at, and an empty value (or one that parses to no ids) closes the claim see
// src/discord.ts `discordConfig` instead of leaving a form that grants Plus to
// anyone who signs in with Discord.
"DISCORD_GUILD_ID": "",
"DISCORD_BENEFITS_ROLE_IDS": ""
} }
} }
+24
View File
@@ -101,6 +101,30 @@ export interface Account {
* `runx admin grant-moderator`. Absent/false means no role. * `runx admin grant-moderator`. Absent/false means no role.
*/ */
isModerator?: boolean isModerator?: boolean
/**
* Whether this account has Rec Room Plus the paid tier the client's API calls a
* `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.
*
* This flag ALONE is what confers it, and it stands on its own: two things set it, and
* neither is a precondition of the other.
*
* - the website's benefits claim (`www` `POST /api/benefits/claim`), where a player
* proves a qualifying role in the community Discord. That path also links their
* Discord identity into `platform_account` as a `PlatformType.Discord` row but the
* link exists to keep the CLAIM once-only per Discord user, not to justify the flag.
* - an operator, via `runx admin grant-plus`, with no Discord anywhere in sight.
*
* So never read a Discord link as a precondition for Plus, and never revoke one because
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
* that is a normal, supported state.
*
* Nothing reads this per request. `auth` stamps it into every token it mints as the
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
* that claim alone so setting it takes effect on the account's NEXT login, not
* immediately. Tokens last a day and the client never refreshes them, so that lag is
* real: the website's claim page warns about it, and so does `grant-plus`.
*/
hasPlus?: boolean
} }
interface AccountRow { interface AccountRow {
+33
View File
@@ -5,6 +5,39 @@
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files). * the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
*/ */
/**
* PlatformType, the client's platform enum. Declaration order is wire order. The
* `platform` form field is posted as the integer; a token's `platform` claim carries it
* too. `auth` re-exports this as the source for its OpenAPI schema and description.
*
* A plain `as const` object rather than an `enum` like its neighbours, and deliberately
* so: `auth` builds `PlatformTypeSchema`'s description by walking `Object.entries`, and a
* numeric TS enum also emits a REVERSE mapping (`{ '0': 'Steam', Steam: 0, … }`), which
* would double every member in the generated spec.
*
* Everything from `Steam` to `Pico` is a real Rec Room client platform, numbered by the
* client. `Discord` is OURS it is not a platform anyone signs in from, and the client
* never sends it. It exists so a verified Discord identity can be stored as an account
* link like any other external identity (see `auth`'s platform-db and the website's
* benefits claim); it sits at 101, well clear of the client's range, so a future client
* platform can be added without colliding with it.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
Discord: 101,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */ /** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
export enum RoomInstanceType { export enum RoomInstanceType {
Public = 0, Public = 0,
+1
View File
@@ -15,6 +15,7 @@ export * from './room-instance-db'
export * from './room-comments-db' export * from './room-comments-db'
export * from './room-invites-db' export * from './room-invites-db'
export * from './presence-db' export * from './presence-db'
export * from './stats-db'
export * from './gifts-db' export * from './gifts-db'
export * from './inventory-invention-db' export * from './inventory-invention-db'
export * from './lists-db' export * from './lists-db'
@@ -1,7 +1,7 @@
/** /**
* Owned inventions on the shared `recflare` D1 database the inventions a player has * Owned inventions on the shared `recflare` D1 database the inventions a player has
* bought. One row per (account, invention), written at purchase time by the `econ` * bought. One row per (account, invention), written at purchase time by the `econ`
* worker's `GET /api/storefronts/v2/buyInvention`. * worker's buyInvention the `v2` GET and the `v3` POST, which share one settlement.
* *
* Only the invention id is stored: the invention record itself lives in the `invention` * Only the invention id is stored: the invention record itself lives in the `invention`
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on * table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
+14 -2
View File
@@ -12,9 +12,21 @@
const ITERATIONS = 100_000 const ITERATIONS = 100_000
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)) const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0)) const fromB64 = (s: string): Uint8Array<ArrayBuffer> =>
Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
async function deriveBits(password: string, salt: Uint8Array): Promise<Uint8Array> { /**
* The salt is `Uint8Array<ArrayBuffer>` rather than a bare `Uint8Array` because the latter
* is `Uint8Array<ArrayBufferLike>`, which admits a `SharedArrayBuffer` and the DOM lib's
* `BufferSource` does not. Both callers already produce a plain-ArrayBuffer view
* (`getRandomValues` and `fromB64`), so this only writes down what was always true; without
* it, any worker whose tsconfig includes the DOM lib (`www`, for its React client) fails to
* compile on the `deriveBits` call below.
*/
async function deriveBits(
password: string,
salt: Uint8Array<ArrayBuffer>
): Promise<Uint8Array<ArrayBuffer>> {
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(password), new TextEncoder().encode(password),
+16 -12
View File
@@ -33,21 +33,25 @@ export const PRESENCE_TTL_SECONDS = 900
export const GAME_VERSION = '20230414' export const GAME_VERSION = '20230414'
/** /**
* Client builds `/api/versioncheck/v4` answers "current" for. `GAME_VERSION` is the one * Client builds `/api/versioncheck/v4` answers "current" for, in the support tiers the
* the rest of the stack targets and reports for itself; the others are later clients * README publishes. They talk close enough to the same protocol to get past the update
* that talk close enough to the same protocol to get past the update prompt. * prompt; how much beyond that is tested differs by tier:
* *
* DEBUGGING ONLY beyond `GAME_VERSION`: this is not a supported-version list. Nothing * - `GAME_VERSION` (`20230414`, manifest `7859140924515540835`) is the DEFAULT the
* else in the stack targets those builds, so a client waved through here can still hit * build the rest of the stack targets and the one it reports for itself.
* protocol differences the version check would otherwise have caught. Trim it back to * - `20250718.01` (manifest `1151455856673601091`, reached through the `patch-2025`
* `GAME_VERSION` alone before anyone but us is playing. * patch) is BETA supported, and the build the "newer than `20230414`" gates in
* `econ`/`api`/`rooms` are written and tested against.
* - The rest are ALPHA: waved past the version check, but nothing else in the stack
* targets them, so they can still hit protocol differences this check would otherwise
* have caught. Expect breakage rather than treating a bug there as a regression.
*/ */
export const SUPPORTED_GAME_VERSIONS: string[] = [ export const SUPPORTED_GAME_VERSIONS: string[] = [
GAME_VERSION, GAME_VERSION, // default
'20230616', '20250718.01', // beta
'20231207', '20230616', // alpha
'20250424.01', '20231207', // alpha
'20250718.01', '20250424.01', // alpha
] ]
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */ /** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
+50
View File
@@ -85,6 +85,38 @@ export async function getRoomInvite(
} }
} }
/**
* The newest live invite from `fromPlayerId` to `toPlayerId`, or null when none stands.
*
* This is the by-PLAYER-pair lookup behind `POST /matchmake/v2/player/:playerId`, where
* the caller redeems "an invite from that player" without holding a `RoomInviteId` (the
* newer client's invite frame doesn't always carry a usable one). Newest by id ids are
* AUTOINCREMENT, so the largest is the most recently sent and, like
* {@link getRoomInvite}, a miss covers both "never invited" and "already swept".
*/
export async function getLatestRoomInviteBetween(
db: D1Database,
fromPlayerId: number,
toPlayerId: number
): Promise<RoomInvite | null> {
const row = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM room_invite
WHERE from_player_id = ?1 AND to_player_id = ?2
ORDER BY room_invite_id DESC LIMIT 1`
)
.bind(fromPlayerId, toPlayerId)
.first<RoomInviteRow>()
if (!row) return null
return {
RoomInviteId: row.room_invite_id,
FromPlayerId: row.from_player_id,
ToPlayerId: row.to_player_id,
RoomId: row.room_id,
}
}
/** /**
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the * Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
* client reads it back. `roomId` is null when the caller's room instance didn't resolve. * client reads it back. `roomId` is null when the caller's room instance didn't resolve.
@@ -115,3 +147,21 @@ export async function createRoomInvite(
RoomId: row.room_id, RoomId: row.room_id,
} }
} }
/**
* Delete one invite by its id, answering whether a row was there to delete.
*
* An invite is single-use: `POST /matchmake/v2/player/:playerId` redeems the newest row
* from the target and drops it here once the caller is actually in the instance, so a
* standing invite doesn't stay a permanent key into whatever room that player is in
* later. Deleting rather than flagging matches the expiry sweep, which is why every
* lookup reads a miss as "no longer good" without a status column.
*/
export async function deleteRoomInvite(db: D1Database, roomInviteId: number): Promise<boolean> {
const row = await db
.prepare(`DELETE FROM room_invite WHERE room_invite_id = ?1 RETURNING room_invite_id`)
.bind(roomInviteId)
.first<{ room_invite_id: number }>()
return row !== null
}
+102 -20
View File
@@ -103,6 +103,21 @@ export const ROOM_SCHEMA_DDL: string[] = [
PRIMARY KEY (room_id, banned_player_id) PRIMARY KEY (room_id, banned_player_id)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`, `CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
// Per-room leaderboard definitions (migrations/0016_room_leaderboard.sql). One row per
// (room, leaderboard): `leaderboard_id` is the client's slot number — small ordinals
// (1, 2, 3…), unique only within the room — so the pair is the key, and re-posting a
// slot reconfigures it in place rather than appending.
//
// Deliberately NOT in the room's `data` blob, same reasoning as `room_ban`: the blob is
// served verbatim as the room and the client doesn't read leaderboards off it.
`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)
)`,
] ]
/** /**
@@ -196,6 +211,22 @@ export function canManageRoom(room: Room, accountId: number): boolean {
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
} }
/**
* Whether an account may MODERATE a room its creator, or the holder of a role at
* Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
* of: a moderator polices who is in the room right now (kicking someone out of an
* instance) without being trusted to change the room itself, while everyone who can
* manage a room can obviously also police it, so CoOwner and Creator pass here too.
*
* Host (10) is deliberately below the line: it is the "runs this session" tier, which the
* client hands out freely, and a kick is a moderation power rather than a hosting one.
*/
export function canModerateRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
}
/** A player banned from a room (a `room_ban` row). */ /** A player banned from a room (a `room_ban` row). */
export interface RoomBan { export interface RoomBan {
RoomId: number RoomId: number
@@ -286,6 +317,75 @@ export async function isPlayerBannedFromRoom(
return row !== null return row !== null
} }
/** A room's leaderboard definition — one configured slot (`leaderboard_id` is per-room). */
export interface RoomLeaderboard {
RoomId: number
LeaderboardId: number
LeaderboardTitle: string
StatFormat: number
SortAscending: boolean
}
interface RoomLeaderboardRow {
room_id: number
leaderboard_id: number
leaderboard_title: string
stat_format: number
sort_ascending: number
}
const toRoomLeaderboard = (row: RoomLeaderboardRow): RoomLeaderboard => ({
RoomId: row.room_id,
LeaderboardId: row.leaderboard_id,
LeaderboardTitle: row.leaderboard_title,
StatFormat: row.stat_format,
SortAscending: row.sort_ascending === 1,
})
/**
* Create or reconfigure one of a room's leaderboard slots, returning the stored
* definition. One row per (room, leaderboard): re-posting a slot rewrites its title,
* format and direction rather than appending a second row, so the call is idempotent.
*/
export async function setRoomLeaderboard(
db: D1Database,
roomId: number,
leaderboardId: number,
leaderboardTitle: string,
statFormat: number,
sortAscending: boolean
): Promise<RoomLeaderboard> {
const row = await db
.prepare(
`INSERT INTO room_leaderboard (room_id, leaderboard_id, leaderboard_title, stat_format, sort_ascending)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(room_id, leaderboard_id) DO UPDATE SET
leaderboard_title = ?3, stat_format = ?4, sort_ascending = ?5
RETURNING *`
)
.bind(roomId, leaderboardId, leaderboardTitle, statFormat, sortAscending ? 1 : 0)
.first<RoomLeaderboardRow>()
// RETURNING always yields the upserted row.
return toRoomLeaderboard(row!)
}
/**
* Remove one of a room's leaderboard slots, returning the definition that was removed
* or null when the slot wasn't configured, which lets the caller tell a real delete
* from a no-op.
*/
export async function deleteRoomLeaderboard(
db: D1Database,
roomId: number,
leaderboardId: number
): Promise<RoomLeaderboard | null> {
const row = await db
.prepare('DELETE FROM room_leaderboard WHERE room_id = ?1 AND leaderboard_id = ?2 RETURNING *')
.bind(roomId, leaderboardId)
.first<RoomLeaderboardRow>()
return row ? toRoomLeaderboard(row) : null
}
/** /**
* Clone an existing room into a new one owned by `accountId`. Copies the source * Clone an existing room into a new one owned by `accountId`. Copies the source
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
@@ -367,23 +467,10 @@ export async function setRoomDescription(
.run() .run()
} }
/** /** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
* Set a room's Name in place (the caller checks ownership + name uniqueness first).
*
* Writes `FriendlyName` to the same string. That is the DISPLAY name what the client
* labels the room with and it is only defaulted to `Name` on read
* ({@link attachRoomDtoDefaults}), with `??=`, so a room whose blob has ever carried one
* keeps it. Renaming without this leaves that room displaying its old name forever while
* every name-keyed lookup uses the new one.
*
* The reference lets a creator set a display name apart from the unique `Name`; nothing
* here exposes that, so the two are kept in step rather than allowed to diverge silently.
*/
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> { export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
await db await db
.prepare( .prepare("UPDATE room SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
"UPDATE room SET data = json_set(data, '$.Name', ?2, '$.FriendlyName', ?2) WHERE room_id = ?1"
)
.bind(roomId, name) .bind(roomId, name)
.run() .run()
} }
@@ -1062,10 +1149,6 @@ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
* it is 0 for every room. * it is 0 for every room.
* - `CurrentSnapshotId` the room's published snapshot. Nothing takes snapshots, so it is * - `CurrentSnapshotId` the room's published snapshot. Nothing takes snapshots, so it is
* null, which is also what the reference serves for a room that has none. * null, which is also what the reference serves for a room that has none.
* - `FriendlyName` the display name, which the reference lets a creator set apart from
* the unique `Name`. Nothing sets one here, so it falls back to `Name`; it must never be
* null, because the client labels a room from it and renders nothing for a room without
* one.
* - `CCU` concurrent users. No live-population counter exists here, so it is null, which * - `CCU` concurrent users. No live-population counter exists here, so it is null, which
* is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody * is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
* is in here" in the browse feeds). * is in here" in the browse feeds).
@@ -1076,7 +1159,6 @@ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
function attachRoomDtoDefaults(room: Room): void { function attachRoomDtoDefaults(room: Room): void {
room.BoostCount ??= 0 room.BoostCount ??= 0
room.CurrentSnapshotId ??= null room.CurrentSnapshotId ??= null
room.FriendlyName ??= room.Name
room.CCU ??= null room.CCU ??= null
} }
+45
View File
@@ -0,0 +1,45 @@
/**
* Server statistics sampled over time (`stat` table). The `match` presence cron
* records one `online` sample per run the count of live `presence` rows once the
* expired ones are swept. Migration: apps/match/migrations/0002_stat.sql.
*/
/** Schema DDL (mirror of apps/match/migrations/0002_stat.sql). */
export const STAT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS stat (
stat_type TEXT NOT NULL,
value INTEGER NOT NULL,
datetime TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime)`,
]
export interface StatRow {
statType: string
value: number
datetime: string
}
/** Record one sample of `statType`, stamped with the current UTC time (ISO-8601). */
export async function recordStat(
db: D1Database,
statType: string,
value: number,
now: Date = new Date()
): Promise<void> {
await db
.prepare('INSERT INTO stat (stat_type, value, datetime) VALUES (?1, ?2, ?3)')
.bind(statType, value, now.toISOString())
.run()
}
/** Samples of `statType`, oldest first. */
export async function getStats(db: D1Database, statType: string, limit = 1000): Promise<StatRow[]> {
const { results } = await db
.prepare(
'SELECT stat_type, value, datetime FROM stat WHERE stat_type = ?1 ORDER BY datetime ASC LIMIT ?2'
)
.bind(statType, limit)
.all<{ stat_type: string; value: number; datetime: string }>()
return results.map((r) => ({ statType: r.stat_type, value: r.value, datetime: r.datetime }))
}
+17
View File
@@ -61,6 +61,15 @@ export const MIN_INVENTION_NAME_LENGTH = 3
export const MAX_INVENTION_NAME_LENGTH = 24 export const MAX_INVENTION_NAME_LENGTH = 24
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512 export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
/**
* The long description the blurb on an invention's detail page, as opposed to the one
* line that fits under a browse tile. The client sends it on `v9/save` and edits it
* through `v2/metadata`, and how long it lets one get has not been pinned down, so the cap
* here is this server's own: generous enough that no real blurb hits it, small enough that
* a record stays a record. It is checked only when a caller actually sends one.
*/
export const MAX_INVENTION_LONG_DESCRIPTION_LENGTH = 4096
/** /**
* One invention tag. Short and letters-only because tags are a controlled vocabulary the * One invention tag. Short and letters-only because tags are a controlled vocabulary the
* browse chips are derived from (see `getInventionTagFilters`) a tag with digits, * browse chips are derived from (see `getInventionTagFilters`) a tag with digits,
@@ -180,6 +189,14 @@ export function inventionDescriptionRejection(value: string): string | null {
return null return null
} }
/** Why a long description can't be stored, or null when it can. */
export function inventionLongDescriptionRejection(value: string): string | null {
if (glyphLength(value) > MAX_INVENTION_LONG_DESCRIPTION_LENGTH) {
return `Invention long descriptions can be at most ${MAX_INVENTION_LONG_DESCRIPTION_LENGTH} characters.`
}
return null
}
/** /**
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED * Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
* tag (trimmed and lowercased, as `setInventionTags` stores it) checking what was typed * tag (trimmed and lowercased, as `setInventionTags` stores it) checking what was typed
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@hono/standard-validator": "0.2.2", "@hono/standard-validator": "0.2.2",
"hono": "4.12.27", "hono": "4.13.5",
"http-codex": "0.6.7", "http-codex": "0.6.7",
"workers-tagged-logger": "1.0.1", "workers-tagged-logger": "1.0.1",
"zod": "4.4.3" "zod": "4.4.3"
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"hono": "4.12.27" "hono": "4.13.5"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "4.20260630.1", "@cloudflare/workers-types": "4.20260630.1",
+1
View File
@@ -1,5 +1,6 @@
export { export {
validateAndGetAccountId, validateAndGetAccountId,
validateAndGetPlus,
validateAndGetRoles, validateAndGetRoles,
validateAndGetVersion, validateAndGetVersion,
generateToken, generateToken,
+46 -3
View File
@@ -52,8 +52,12 @@ export async function validateAndGetAccountId(
const accountId = await getAccountIdFromToken(token, secret) const accountId = await getAccountIdFromToken(token, secret)
if (!accountId) return null if (!accountId) return null
const id = Number.parseInt(accountId, 10) // `parseInt("42junk", 10)` is 42, which lets a malformed subject select a real
return Number.isNaN(id) ? null : id // account. Token subjects are canonical positive base-10 account ids: reject
// partial parses, signs, decimals, leading zeroes and values outside JS's safe range.
if (!/^[1-9]\d*$/.test(accountId)) return null
const id = Number(accountId)
return Number.isSafeInteger(id) ? id : null
} }
/** /**
@@ -82,6 +86,30 @@ export async function validateAndGetRoles(
} }
} }
/**
* Whether a request's bearer token says the caller has Rec Room Plus the `rn.plus`
* claim stamped by {@link generateToken} from `account.hasPlus`. This is the ONE way Plus
* is decided (see `econ`'s `isSubscriber`); nothing re-reads the account for it, which is
* why a freshly-claimed player must sign in again before it applies.
*
* False for a missing, malformed or expired token, and false for a valid token that
* simply carries no claim the two are not worth telling apart, since neither is a
* subscriber. Only a literal `true` counts, so a token carrying some other value in that
* key can't read as Plus.
*/
export async function validateAndGetPlus(request: Request, secret: string): Promise<boolean> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return false
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
return payload['rn.plus'] === true
} catch {
return false
}
}
/** /**
* Validate a request's bearer token and return its `rn.ver` claim the game build the * Validate a request's bearer token and return its `rn.ver` claim the game build the
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the * caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
@@ -189,7 +217,8 @@ export async function generateToken(
secret: string, secret: string,
extraRoles: string[] = [], extraRoles: string[] = [],
privileges: string[] = [], privileges: string[] = [],
version: string = GAME_VERSION version: string = GAME_VERSION,
hasPlus = false
): Promise<string> { ): Promise<string> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to // The client reads `role`/`scope` (and expects a well-formed iss/aud) to
@@ -220,6 +249,20 @@ export async function generateToken(
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte // `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
// what it was before privileges existed. // what it was before privileges existed.
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}), ...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
// Whether the account has Rec Room Plus (`account.hasPlus`) — a CLAIM, like
// `rn.privilege` and for the same reason: it is ours, the client has never heard of
// it, and `scope` is a fixed list the client parses. `econ` reads it to answer the
// CampusCard lookup and to price the subscriber discount, which is the whole point
// of carrying it here: those calls then need no database read at all.
//
// Omitted when false, so a non-subscriber's token is byte-for-byte what it was
// before Plus existed, and `validateAndGetPlus` reads an absent claim as "no Plus".
//
// STAMPED AT LOGIN, so it is only as fresh as the token: a player who claims Plus on
// the website has to sign in again (and restart the game) before it takes effect.
// Tokens last a day and the client does not refresh them — see TOKEN_TTL_SECONDS —
// so that wait is real, and it is the accepted trade for making the check free.
...(hasPlus ? { 'rn.plus': true } : {}),
scope: TOKEN_SCOPES, scope: TOKEN_SCOPES,
jti: crypto.randomUUID(), jti: crypto.randomUUID(),
}, },
+31 -8
View File
@@ -18,6 +18,7 @@ import type { D1ExecResult } from '../d1'
* runx admin clear-password --username alice [--remote] * runx admin clear-password --username alice [--remote]
* runx admin lookup --username alice [--remote] * runx admin lookup --username alice [--remote]
* runx admin grant-developer --account 1 [--revoke] [--remote] * runx admin grant-developer --account 1 [--revoke] [--remote]
* runx admin grant-plus --username alice [--revoke] [--remote]
*/ */
/** /**
@@ -123,16 +124,21 @@ const clearPassword = new Command('clear-password')
}) })
/** /**
* Build a `grant-<role>` command that toggles a boolean role flag on the account * Build a `grant-<thing>` command that toggles a boolean flag on the account blob.
* blob. `jsonKey` is the account field (e.g. `isDeveloper`) a fixed literal, not * `jsonKey` is the account field (e.g. `isDeveloper`) a fixed literal, not user input.
* user input. Both the /role/:role lookup and the token's `role` claim read it. *
* `noun` is what the flag IS, and it is not always "role": the role flags feed the
* /role/:role lookup and the token's `role` claim, while `hasPlus` is an entitlement that
* rides on its own `rn.plus` claim and confers no role at all. Getting that word right in
* the output is the difference between an operator believing they granted a staff power
* and knowing they granted a subscription.
*/ */
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) { function grantRoleCommand(name: string, jsonKey: string, roleLabel: string, noun = 'role') {
return new Command(name) return new Command(name)
.description(`Grant (or, with --revoke, remove) the ${roleLabel} role on an account`) .description(`Grant (or, with --revoke, remove) ${roleLabel} on an account`)
.option('--account <id>', 'Account id to target') .option('--account <id>', 'Account id to target')
.option('--username <name>', 'Username to target (case-insensitive)') .option('--username <name>', 'Username to target (case-insensitive)')
.option('--revoke', `Remove the ${roleLabel} role instead of granting it`, false) .option('--revoke', `Remove ${roleLabel} instead of granting it`, false)
.option('--local', 'Target the local dev database (the default).', false) .option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false) .option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => { .action(async (opts) => {
@@ -141,10 +147,10 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
const value = opts.revoke ? 'false' : 'true' const value = opts.revoke ? 'false' : 'true'
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id` const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
const verb = opts.revoke ? 'Revoking' : 'Granting' const verb = opts.revoke ? 'Revoking' : 'Granting'
console.log(`${verb} ${roleLabel} role for ${label} on ${target(remote)}`) console.log(`${verb} ${roleLabel} ${noun} for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label) assertMatched(await execSql(sql, remote), label)
console.log( console.log(
chalk.green(`${roleLabel} role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`) chalk.green(`${roleLabel} ${noun} ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
) )
}) })
} }
@@ -152,6 +158,21 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer') const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator') const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
/**
* Rec Room Plus, the account's `hasPlus` flag. Players normally get it themselves by
* claiming a Discord role on the website; this is the operator's way in and the ONLY
* one, since the `developer` role deliberately no longer confers Plus.
*
* Granting does not take effect until the account's NEXT login: `auth` stamps `hasPlus`
* into the token as `rn.plus` when it mints one, and `econ` reads nothing else. Tokens
* last a day and the client never refreshes them, so tell the player to restart the game
* and sign in again.
*
* Revoking has the same lag in reverse a player keeps Plus until their current token
* expires. It is not a way to cut someone off immediately.
*/
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
const lookup = new Command('lookup') const lookup = new Command('lookup')
.description('Print an account by id or username') .description('Print an account by id or username')
.option('--account <id>', 'Account id to look up') .option('--account <id>', 'Account id to look up')
@@ -202,6 +223,7 @@ export const adminCmd = new Command('admin')
.addCommand(clearPassword) .addCommand(clearPassword)
.addCommand(grantDeveloper) .addCommand(grantDeveloper)
.addCommand(grantModerator) .addCommand(grantModerator)
.addCommand(grantPlus)
.addCommand(lookup) .addCommand(lookup)
.addHelpText( .addHelpText(
'after', 'after',
@@ -216,5 +238,6 @@ Examples:
$ runx admin clear-password --username alice $ runx admin clear-password --username alice
$ runx admin grant-developer --account 1 [--revoke] $ runx admin grant-developer --account 1 [--revoke]
$ runx admin grant-moderator --username alice --remote $ runx admin grant-moderator --username alice --remote
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
$ runx admin lookup --username alice --remote` $ runx admin lookup --username alice --remote`
) )
+1 -1
View File
@@ -36,7 +36,7 @@
"dependencies": { "dependencies": {
"esbuild": "0.28.1", "esbuild": "0.28.1",
"slugify": "1.6.9", "slugify": "1.6.9",
"wrangler": "4.105.0", "wrangler": "4.128.0",
"yaml": "2.9.0", "yaml": "2.9.0",
"zod": "4.4.3", "zod": "4.4.3",
"zx": "8.8.5" "zx": "8.8.5"

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