Compare commits

...

234 Commits

Author SHA1 Message Date
dukedotdev 5f34028267 [rooms] load Studio export dependencies (#142) 2026-10-25 17:01:42 -04:00
Devin Zuczek b02a646892 [sql] improvements 2026-10-25 13:35:18 -04:00
Devin Zuczek 8397997234 [studio] rollout support 2026-10-24 15:00:49 -04:00
Devin Zuczek 4e2cdd87eb [studio] move cloud_build_key to secrets 2026-10-24 01:16:49 -04:00
Devin Zuczek d1e69bfd14 [auth] discord login 2026-10-24 00:28:10 -04:00
Devin Zuczek 09e40af6b7 [api] fix slow rooms badge query 2026-10-23 22:57:53 -04:00
Jake Vice f9600c1836 Pumple Evil Edition >:) (#138)
* Add Halloween overlay and walking pumpkin

* Add Pumple chaos Halloween minigame

---------

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-23 17:45:12 -04:00
dukedotdev 3da35a8692 [studio] avatar authoring, managed prefab builds (#140) 2026-10-23 15:25:31 -04:00
Devin Zuczek 16e5f61160 [leaderboard] update leaderboard for 2026 changes 2026-10-22 14:58:29 -04:00
Devin Zuczek 20a947dcad [leaderboard] fix 2026 display at least 2026-10-22 14:35:21 -04:00
Devin Zuczek fbf7c923a6 [econ] room consumable token/currency purchase 2026-10-22 14:16:18 -04:00
Devin Zuczek 1d0e71d651 [api] adjust to match images v5 derived shape 2026-10-22 13:08:29 -04:00
Devin Zuczek 76db89c30c [econ] fix missing owned keys endpoint 2026-10-22 12:51:53 -04:00
Devin Zuczek 208178494a [clubs] announce delete 2026-10-22 12:46:27 -04:00
Devin Zuczek 2222a545ff [econ] owner currency 0 amount purchase 2026-10-22 12:32:54 -04:00
Devin Zuczek 605a35c36b [img] hardening 2026-10-22 12:21:21 -04:00
Devin Zuczek 3cf4b4029e [econ] fix room owner key purchase 2026-10-22 12:11:27 -04:00
Devin Zuczek 337367da52 [api] turn off backtraces 2026-10-22 12:11:27 -04:00
Devin Zuczek 7abef376b6 [storage] upload hardening 2026-10-21 22:50:07 -04:00
Devin Zuczek ae360e1e83 [img] fix potential xss 2026-10-21 22:14:43 -04:00
dukedotdev a17183f15c Add hosted Studio template distribution (#139) 2026-10-21 17:32:56 -04:00
Devin Zuczek ed45f38137 [2026] split 2026 to tachy hosts 2026-10-20 21:21:24 -04:00
Devin Zuczek affdcb85e3 [rooms] fix slow engagement counters 2026-10-20 21:03:54 -04:00
Devin Zuczek 5f3cdc5922 [rooms] fix slow room tag query 2026-10-20 16:36:07 -04:00
Devin Zuczek eab86ec223 [rooms] fix slow room role query 2026-10-20 16:25:51 -04:00
Devin Zuczek 71b0215ed3 [api] up invention limit to match client 2026-10-20 12:52:28 -04:00
Devin Zuczek 38bb9cff73 [link] store share links 2026-10-20 12:22:52 -04:00
Devin Zuczek 6bf297ed98 [www] add share links from invention/image 2026-10-20 12:09:17 -04:00
Devin Zuczek f04b9bd66b [chat] fix favorite thread 2026-10-20 11:31:28 -04:00
Devin Zuczek 2091483cc3 [econ] fix consumable notification 2026-10-20 10:25:03 -04:00
Devin Zuczek d918936795 [econ] fix some storefronts 2026-10-20 10:25:03 -04:00
Devin Zuczek be1b275a0a [econ] catalog fix 2026-10-20 10:25:03 -04:00
Devin Zuczek b1628f28e2 [econ] update storefronts 2026-10-20 10:25:03 -04:00
Devin Zuczek 05d7fe4c32 [2026] rollout support, to be short lived I suppose 2026-10-19 22:54:28 -04:00
dukedotdev a3291bc9ce [studio] Complete editor uploads and queued cloud builds (#135)
* [studio] Complete editor uploads and queued cloud builds

* [studio] Protect unpublished source and remove unused notification RPC
2026-10-19 11:04:58 -04:00
Devin Zuczek 81c372135c [www] update quest url 2026-10-16 23:01:15 -04:00
Jake Vice 02ca828a69 [studio] add get-build-count-for-user route (#134)
Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
2026-10-16 22:51:25 -04:00
Devin Zuczek 2ba2ad3a4b [econ] add missing items courtesy studio87 2026-10-16 21:42:03 -04:00
Devin Zuczek 4c1b9ca9da [api] sharecam 2026-10-16 17:40:45 -04:00
Devin Zuczek 33b74d02c5 [lists] fix saved rooms 2026-10-16 15:39:18 -04:00
Devin Zuczek f24e35224c [api] fix a few other queries > 100 items 2026-10-16 13:30:17 -04:00
Devin Zuczek 6d905e1811 [apps] possible inventions fix 2026-10-16 12:46:55 -04:00
Devin Zuczek 5eac7480ae [www] better ban display 2026-10-16 10:46:10 -04:00
Devin Zuczek d87e1ca5ae [www] gate delete room on developer 2026-10-16 10:34:49 -04:00
Devin Zuczek c52b37037e [match/list] actually record room visits 2026-10-15 23:24:45 -04:00
Devin Zuczek f8a6338eaa [studio] do not enforce limits for staff 2026-10-15 15:05:21 -04:00
Devin Zuczek 972432d4d3 [api] add xp boost stub endpoint 2026-10-15 15:01:21 -04:00
Devin Zuczek e225a8a2f1 [api] stub out progression event record 2026-10-15 13:38:31 -04:00
Devin Zuczek 2a9ff1337a [econ] add getBalance endpoint 2026-10-15 13:04:39 -04:00
Devin Zuczek e7ee9ed444 [www] show moderated image 2026-10-14 15:21:37 -04:00
Devin Zuczek 6f8e1182e8 [econ] tweak xp rewards 2026-10-14 14:49:28 -04:00
Devin Zuczek 0f757b2c4f [econ] change token award to once per activity per day 2026-10-14 14:22:10 -04:00
Devin Zuczek eb5c0d28ff [auth] allow staff in rollout 2026-10-14 12:10:33 -04:00
Devin Zuczek 9902de39a4 [www] reset dorm button for 2023 2026-10-14 11:48:12 -04:00
Devin Zuczek 94b106c12f [econ] stub for consumables that do not exist here 2026-10-14 00:00:24 -04:00
Devin Zuczek ee725056d2 [www] add displayname change 2026-10-13 21:47:34 -04:00
Devin Zuczek 4f36146912 [accounts] mirror regex from old client for display name 2026-10-13 21:47:34 -04:00
Devin Zuczek 5ba57210b9 [match] restrict matchmaking to event rooms only 2026-10-13 21:47:34 -04:00
Devin Zuczek ba6c703050 [2026] consumables 2026-10-13 21:47:34 -04:00
Devin Zuczek 33d235f8ad [clubs] revert this call 2026-10-13 21:47:34 -04:00
Devin Zuczek 6d4156bd67 [2026] fix a few wire shapes 2026-10-13 21:47:34 -04:00
dukedotdev bf817977ec [econ]: add 51 missing avatar item variants (#133) 2026-10-13 17:23:18 -04:00
Devin Zuczek cf6868a753 [api] testing real progression event 2026-10-13 10:52:48 -04:00
Devin Zuczek 810e3b8f27 [econ] stub out new consumables endpoint 2026-10-13 10:49:08 -04:00
Devin Zuczek 7d509f6230 [clubs] fix home 404 2026-10-13 10:48:52 -04:00
Devin Zuczek c48cefda26 [econ] some more lil stubs 2026-10-13 10:36:34 -04:00
Devin Zuczek c1444f906d [api] add progression events from https://github.com/Rec-Archive/RRArchive/tree/43c097e0255d8a6816af9f3842e060afe4c1e5f7/ProgressionEvents 2026-10-13 10:11:48 -04:00
Devin Zuczek 961dae5c7a [econ] add battle pass stub 2026-10-13 10:07:05 -04:00
Devin Zuczek 92c93b0a58 [match] 2026 verifylock endpoint 2026-10-13 09:44:14 -04:00
dukedotdev 1f4c3e5fc0 [cards] Authenticate channel routes and return channel lists (#121) 2026-10-13 00:52:03 -04:00
Devin Zuczek 6f9348222a [rooms] boosts stub 2026-10-13 00:48:30 -04:00
Devin Zuczek e06eba6929 [match] missing endpoint for 2026 that apparently breaks game 2026-10-12 22:17:51 -04:00
Devin Zuczek 1dd5fd409e [2026] relax versioncheck to major versions 2026-10-12 21:28:29 -04:00
Devin Zuczek bd15e30bfc [rooms] fix imported unity assets 2026-10-12 21:18:51 -04:00
Devin Zuczek dfe5071878 [www] fix role sync not grabbing all roles 2026-10-12 16:30:40 -04:00
Devin Zuczek a0345af765 [2026] rollout support 2026-10-12 16:17:14 -04:00
dukedotdev 4d6a88f20e [chat] Return the 2026 party message contract (#122) 2026-10-12 14:23:15 -04:00
dukedotdev aa5e83764e [commerce] Add the purchase restriction contract (#123) 2026-10-12 14:22:46 -04:00
dukedotdev 58a9b08548 [ai] Serve the 2026 companion fallback without realtime credentials (#118) 2026-10-12 14:21:25 -04:00
dukedotdev 0e699cab2b [match] Persist and broadcast presence visibility (#127)
* [match] Persist visibility changes and refresh own-player presence

* [match] Propagate saved visibility to friends
2026-10-12 14:20:29 -04:00
dukedotdev 2a41b93c13 [econ] Fix three 2026 response contracts (#125)
* [econ] Wrap 2026 room offer purchase counts in a result envelope

* [econ] Include the built-in identity flag on owned 2026 consumables

* [econ] Return the 2026 empty subscription season DTO
2026-10-12 14:19:23 -04:00
dukedotdev a3a5851373 [rooms] Support 2026 Studio loading of legacy baked bundles (#129)
* [rooms] Preserve legacy Unity bundle save DTOs for the Studio loader

* [rooms] Resolve JSON baked bundle requests with exact target and version
2026-10-12 14:17:43 -04:00
Devin Zuczek 585cf68637 [www] dev/mod sandbox support 2026-10-10 16:27:35 -04:00
Devin Zuczek dd687f02d7 [rooms] fix issue where dev/mod cant make private instance of unowned room 2026-10-10 15:13:22 -04:00
Devin Zuczek 9a3ab3e50c [rooms] admin switch to exclude rooms from lists 2026-10-10 14:55:45 -04:00
Devin Zuczek c049c8f14d [rooms] obey excludefromlists 2026-10-10 14:35:11 -04:00
Devin Zuczek ab948f1f28 [2026] alpha 2026-10-10 14:34:58 -04:00
Devin Zuczek e9af56aff4 [econ] add button for supporter boosts 2026-10-10 01:05:42 -04:00
Devin Zuczek ef65b505cc [studio] prefix assets 2026-10-09 01:37:28 -04:00
Mrmadman1 f9beba0dae Add Halloween overlay and walking pumpkin (#117) 2026-10-09 00:08:33 -04:00
Devin Zuczek 74bab0f552 [studio] tweaks for old unity multipart handling unquoted boundaries 2026-10-08 23:29:52 -04:00
Devin Zuczek 3916e40c2e [econ] influencer endpoints for fun 2026-10-08 14:53:31 -04:00
Devin Zuczek cebeafeb40 [api] add influencer badges 2026-10-08 14:34:04 -04:00
Devin Zuczek 6b8c908771 [studio] rate limit stub 2026-10-08 14:27:54 -04:00
Devin Zuczek 7ec295ef33 [econ] purchase offer delete 2026-10-08 13:03:26 -04:00
Devin Zuczek c6e035e4e1 [match] apply subroom capacity to instance cap 2026-10-08 11:45:12 -04:00
Devin Zuczek 1415ebcc06 [match] improve presence delete on expiry/logout 2026-10-08 10:37:18 -04:00
Devin Zuczek e54224dbd0 [rooms] fixup room save endpoint to return same as room endpoint (no effect, just QOL) 2026-10-08 00:44:42 -04:00
Devin Zuczek 909bcebb38 [econ] bypass purchase offers for contributors 2026-10-07 15:03:53 -04:00
Devin Zuczek f71d55c4d3 [rooms] room progression 2026-10-07 14:49:50 -04:00
Devin Zuczek 6885c2eec7 [match] tweak policy change for mitm 2026-10-07 14:43:21 -04:00
Devin Zuczek e56cfa802e [rooms] fix subroom permissions UI for some photon perms 2026-10-07 00:15:09 -04:00
Devin Zuczek 853050c2f0 [rooms] fix permissions update 2026-10-06 23:25:23 -04:00
Devin Zuczek d8a1c4b0da [rooms] fix subrooms permission gate 2026-10-06 22:06:10 -04:00
Devin Zuczek e1391b59b3 [rooms] fix photon access 2026-10-06 16:42:38 -04:00
Devin Zuczek 525e6ee24e [econ] stub active consumable endpoint (?) 2026-10-06 14:00:25 -04:00
Devin Zuczek 19fe7ac7c3 [www] allow changing unity asset ID 2026-10-06 11:19:14 -04:00
Devin Zuczek 7dd88a0198 [rooms] fix get subroom save endpoints for co-owners 2026-10-06 10:48:22 -04:00
Devin Zuczek ae0f2f4cc9 [www] increase role drop 2026-10-05 17:45:30 -04:00
Devin Zuczek aceaf54519 [www] discord link sync 2026-10-05 17:27:00 -04:00
Devin Zuczek 4e3301ade8 [rooms] support for mitm 2026-10-05 17:07:42 -04:00
Devin Zuczek 032e37733d [econ] wishlist 2026-10-05 16:22:55 -04:00
Devin Zuczek 53844c807f [studio] add missing migration script 2026-10-05 14:42:01 -04:00
Crasho 26270e9c25 [studio] beta studio (#114)
* rrs stuff

* studio service yay

* rrs whitelist

* moree studiooooooo fellowwww

* rec room studio room loading yay

* fixes yay

* fix 2 because idk  yayuh

* Update cdn.app.ts

* ccontent length fix?

* Preserve content-length and upgrade metadata

Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable.

* fix x524234

* shirt thing?

* [auth] remove grant table as we already have audit_log for now

* rename table to generic device_grant

* cleanup/lint

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-05 14:39:22 -04:00
Devin Zuczek 0900bcac25 [www] token drop performance improvement 2026-10-05 13:10:05 -04:00
Devin Zuczek 8ea72fcdc5 [match] put players with room roles in same private instance when unpublished 2026-10-05 11:29:15 -04:00
Devin Zuczek f7167358d8 [rooms] fix cloud variables 2026-10-03 21:53:23 -04:00
Devin Zuczek ec423b2eb9 [econ] fix currency name 2026-10-03 20:29:47 -04:00
Devin Zuczek 739176a3ed [econ] room storefront purchaseables 2026-10-03 13:57:32 -04:00
Devin Zuczek 41d0b0543a [api] add image moderation and v6 endpoint 2026-10-03 13:34:07 -04:00
Devin Zuczek 625cec113b [match] on 2023 do not room switch 2026-10-03 13:34:07 -04:00
Devin Zuczek 6d9a871e2c [rooms] record invention room usage 2026-10-03 13:34:07 -04:00
Devin Zuczek fd2e266e50 [econ] some invention stats 2026-10-03 13:34:07 -04:00
Devin Zuczek bfe88a9837 [inventions] cheer endpoint 2026-10-03 13:34:07 -04:00
Devin Zuczek a2f4e4959e [api] fix various issues with inventions 2026-10-03 13:34:07 -04:00
Devin Zuczek 6167925ee0 [www] enable plus for a user 2026-10-03 13:34:07 -04:00
Devin Zuczek 10973d9685 [match] fix issue where private room subroom join was not marked as private 2026-10-03 13:32:37 -04:00
Devin Zuczek 602d6cea90 [www] discord per-role token drop 2026-10-03 12:14:41 -04:00
Devin Zuczek 1458df1664 [econ] key purchasing 2026-10-01 14:35:34 -04:00
Devin Zuczek b09f014b6d [econ] fix key images 2026-10-01 13:29:04 -04:00
Devin Zuczek de607b7632 [econ] room key award 2026-10-01 00:29:11 -04:00
Devin Zuczek 0636f535f3 [events] account events endpoint 2026-09-30 23:55:09 -04:00
Devin Zuczek 4319f2942b [clubs] add account clubs endpoint 2026-09-30 23:45:08 -04:00
Devin Zuczek e00103c6b3 [msc] make playersettings more efficient 2026-09-30 18:04:56 -04:00
Devin Zuczek 90c808c4a3 [match] dont allow invites into unpublished rooms 2026-09-30 17:17:11 -04:00
Devin Zuczek 7e10143454 [rooms] better gating for unpublished rooms 2026-09-30 17:15:42 -04:00
Devin Zuczek 56ccb23412 [econ] fix missing hairs and other items 2026-09-30 12:09:42 -04:00
Devin Zuczek d543b301e2 [match] allow friends to join friends in public 2026-09-30 11:18:37 -04:00
Devin Zuczek fec3fc5258 [api] update progression stub 2026-09-30 10:52:43 -04:00
Devin Zuczek 08d06004ee [events] sort by attendance 2026-09-30 10:51:54 -04:00
Devin Zuczek a5ae46fac4 [api] add img delete and modify 2026-09-29 23:44:47 -04:00
Devin Zuczek ef5bfece59 [api] only show public images in slideshow 2026-09-29 23:09:36 -04:00
Devin Zuczek e8dcaa3051 [api] fix ban duration display 2026-09-29 23:05:46 -04:00
Devin Zuczek 94816b63b1 [api] prevent inflation via inventions 2026-09-28 00:23:56 -04:00
Devin Zuczek ef3c5100fe [econ] fighting inflation 2026-09-28 00:03:50 -04:00
Devin Zuczek 83ccb4bc12 [api] prevent money laundering 2026-09-27 23:54:12 -04:00
Devin Zuczek ab15eb9768 [www] flag to disable websignups 2026-09-27 23:02:42 -04:00
Devin Zuczek a499196692 [api] fix a relationship index 2026-09-27 22:24:28 -04:00
Devin Zuczek b090dc105a [auth] device id limits 2026-09-27 22:20:45 -04:00
Devin Zuczek 91db9ff82b [rooms] fix room instance index 2026-09-27 21:27:33 -04:00
Devin Zuczek 3324a935b4 [www] auto reward on join 2026-09-27 21:04:59 -04:00
Devin Zuczek b32b16c1f0 [api] improve relationships query 2026-09-27 13:54:35 -04:00
Devin Zuczek 53a46d91bc [www] implement share link for custom items 2026-09-27 13:09:43 -04:00
Devin Zuczek fd5d0e6330 [rooms] improve interaction query 2026-09-27 11:19:05 -04:00
Devin Zuczek 08bad7742d [www] update meta link 2026-09-26 22:34:14 -04:00
Devin Zuczek 65365d8fc3 [econ] auto award discord role tokens 2026-09-26 11:52:03 -04:00
Devin Zuczek 3037043a00 [www] discord role sync 2026-09-25 17:17:58 -04:00
Devin Zuczek 4d4d0b1cd2 [econ] coach needs more money 2026-09-25 16:41:11 -04:00
Devin Zuczek 37c4e4d61e [econ] potentially fix hair again 2026-09-25 16:28:55 -04:00
Devin Zuczek bbdc061d05 [econ] room key wip 2026-09-25 13:54:03 -04:00
Devin Zuczek 979e8bc595 [www] show lifted bans 2026-09-25 13:30:33 -04:00
Devin Zuczek b2942a0a51 [rooms] playerdata 2026-09-25 12:17:11 -04:00
Devin Zuczek 9c4c1bdd3a [www] pfp upload 2026-09-25 11:02:42 -04:00
Devin Zuczek 5178f18c79 [api] fix progression stub 2026-09-25 10:32:42 -04:00
Devin Zuczek f72d29e088 [rooms] retain beta/limitsv2 tags after clone 2026-09-25 10:29:15 -04:00
Devin Zuczek 95e752513a [www] add more staff gift options 2026-09-24 16:16:51 -04:00
Devin Zuczek 72fd75e29e [econ] fix consumable/skin boxes 2026-09-24 14:18:06 -04:00
Devin Zuczek a1e89d6948 [clubs] subscribers WIP 2026-09-24 14:09:42 -04:00
Devin Zuczek 13b80f9748 [econ] skins and consumables 2026-09-24 13:55:47 -04:00
Devin Zuczek 5013c5d4e6 [rooms] add calculation mode 2026-09-23 17:56:08 -04:00
Devin Zuczek 4597f9cb03 [rooms] support to move subrooms 2026-09-23 17:40:03 -04:00
Devin Zuczek 9bd3e1630f [api] fix multiple issues with events 2026-09-23 17:14:04 -04:00
Devin Zuczek 400f2903bd [econ] maybe fix hairdyes 2026-09-23 16:14:31 -04:00
Devin Zuczek b172260cfd [api] add creator filter to clothing search endpoint 2026-09-23 13:27:08 -04:00
Devin Zuczek 1b9984c72c [img] allow 64 width 2026-09-23 13:26:43 -04:00
Devin Zuczek ca9f4197c6 [www] show private photos when logged in 2026-09-23 12:23:14 -04:00
Devin Zuczek 907b6efe15 [www] add message to staff boxes 2026-09-22 23:35:34 -04:00
Devin Zuczek b81974476e [www] token drop functionality 2026-09-22 23:35:34 -04:00
Devin Zuczek 8ef8f9af50 [rooms] clean up old invites/votes 2026-09-22 19:14:36 -04:00
Devin Zuczek 30038afd30 [rooms] clean up old votes 2026-09-22 19:03:12 -04:00
Devin Zuczek 70fd0896c5 [econ] add new activity bonus tokens 2026-09-22 18:41:59 -04:00
Devin Zuczek 811a42f189 [rooms] improve a slow query 2026-09-22 17:45:26 -04:00
Devin Zuczek 5ba0cef60d [api] improve invention query 2026-09-22 17:01:29 -04:00
Devin Zuczek dfe772432e [www] tweak permissions 2026-09-22 16:51:14 -04:00
Devin Zuczek c6bda1fc6a [www] remove subrooms from public template 2026-09-22 16:31:43 -04:00
Devin Zuczek 01f64f78af [api] votekick cooldowns 2026-09-22 16:28:19 -04:00
Devin Zuczek db8ea1d056 [www] room token fun 2026-09-22 15:42:25 -04:00
Devin Zuczek 7376f2706f [www] I award you no points 2026-09-22 15:34:22 -04:00
Devin Zuczek f64b04544a [www] staff gifting 2026-09-22 15:24:01 -04:00
Devin Zuczek 533644fcf1 [www] token giver 3000 2026-09-22 12:53:17 -04:00
Devin Zuczek 2dc374ab70 [rooms] unset cloning on copy 2026-09-22 10:25:15 -04:00
Devin Zuczek c46341b331 [www] extend audit log to ban/unban 2026-09-22 09:41:20 -04:00
Devin Zuczek ed1a9f4714 [econ] fix custom gift box not appearing (maybe) 2026-09-22 09:41:20 -04:00
Devin Zuczek 4e070a59bd [all] turn off worker logs for $ 2026-09-22 09:41:20 -04:00
Devin Zuczek 58a794d4e2 [match] tachyon sandbox switch 2026-09-22 09:41:20 -04:00
Devin Zuczek cf0317ab5b [auth] store platform (discord) roles to do something with it later 2026-09-22 09:41:20 -04:00
devin 85dde374f4 Fix typo in DEPLOYING.md about services 2026-09-22 09:36:23 -04:00
devin 7c5baccf59 Clarify deployment requirements in DEPLOYING.md
Updated prerequisites and deployment instructions for clarity and added new requirements for 2023 and 2025.
2026-09-22 09:35:42 -04:00
Devin Zuczek c61fccdb79 [rooms] log room delete 2026-09-22 00:14:24 -04:00
Devin Zuczek 3bcef61c32 [rooms] fix staff delete 2026-09-22 00:08:21 -04:00
Devin Zuczek 2d7d37f382 [www] add mod delete room
Co-authored-by: Jake Vice <jakevice9@gmail.com>
2026-09-21 23:17:00 -04:00
devin 95bca2458a [api] quest asset split on unity target (#108)
* [api] quest asset split

* [api] unity swap + hash removal
2026-09-20 23:40:08 -04:00
Devin Zuczek 2909550cd6 [www] move upload control into img 2026-09-19 11:30:42 -04:00
Devin Zuczek c7ee2bab53 [www] optional downgrade 2026-09-19 11:27:01 -04:00
Devin Zuczek 8995000259 [www] room converter 2026-09-19 11:21:19 -04:00
Devin Zuczek 5e2fad31df [api] chat msg report 2026-09-19 11:21:07 -04:00
Mrmadman1 69dda96739 [www] add photos to account page, WIP player profile page/search (#104)
* Add player search and public profiles

* just hide player search for now

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-09-19 00:53:14 -04:00
Devin Zuczek a2e635df24 [www] stats 2026-09-18 23:51:53 -04:00
Devin Zuczek d3a1a30e62 [api] audit logging 2026-09-18 17:55:55 -04:00
Devin Zuczek ba428199c5 [api] votekick and uniform dorm kicks 2026-09-18 17:32:14 -04:00
Devin Zuczek 04554b0826 [match] add developer tachyon override 2026-09-18 13:08:43 -04:00
Devin Zuczek fd4264d982 [rooms] improve room ban/unban, history
[api] mod kick
[match] allow banned users into dorm
2026-09-17 16:14:24 -04:00
Devin Zuczek 61aa125af5 [store] update lists for CAI 2026-09-16 16:15:49 -04:00
Devin Zuczek ac6f648593 [store] 2025 store 2026-09-16 15:53:39 -04:00
Devin Zuczek 81df863aa4 [rooms] fix owner and invite role decline 2026-09-16 13:51:21 -04:00
Devin Zuczek 6bf5d218dd [auth] wire up new developer/moderator endpoint with proper response 2026-09-16 13:39:06 -04:00
Devin Zuczek e5b0836755 [www] room image upload 2026-09-16 00:50:07 -04:00
Devin Zuczek 95d0cc2856 [store] prefix the img with avatar/ 2026-09-16 00:25:53 -04:00
Devin Zuczek 61bf09d18e [api] catalog loader 2026-09-15 23:39:38 -04:00
Devin Zuczek a3d026743a [econ] coach item catalog from https://archive.org/details/custom-avatar-items 2026-09-15 23:27:18 -04:00
Devin Zuczek 8d638072b4 [cdn] allow 2025 store assets 2026-09-15 22:24:44 -04:00
Devin Zuczek 40c434c296 [api] fix 2025 friends notification 2026-09-15 22:08:37 -04:00
Devin Zuczek 3e49ebb0ec [econ] custom item purchases 2026-09-15 21:38:15 -04:00
Devin Zuczek 741604be24 [api] migrate custom avatar items for incoming RRO items 2026-09-15 20:39:24 -04:00
Devin Zuczek 823dd57c0c [store] swap the rro/custom store, update custom shirt outfit ID 2026-09-15 19:35:59 -04:00
Devin Zuczek 7774975619 [api] add missing invention status endpoint 2026-09-15 16:32:03 -04:00
Devin Zuczek cce921e4cd [auth] record duid for now 2026-09-15 16:27:27 -04:00
Devin Zuczek 872bdfa64e [www] tweaks to mod panel 2026-09-15 13:47:05 -04:00
Devin Zuczek 27ebc95109 [www] beta mod panel 2026-09-15 13:11:19 -04:00
Devin Zuczek 71bb29b8c7 [api] fix a few 2023 compat issues 2026-09-15 11:22:33 -04:00
Devin Zuczek 3299f05940 [auth] cli add tokens to plus members 2026-09-14 22:33:12 -04:00
Devin Zuczek bacd359b61 [econ] consumables WIP 2026-09-14 16:33:47 -04:00
Devin Zuczek ba8423e438 [econ] currency WIP 2026-09-14 15:45:07 -04:00
Devin Zuczek 295ec7bc16 [rooms] switch owner endpoint 2026-09-14 13:36:41 -04:00
333 changed files with 727575 additions and 168527 deletions
+141 -4
View File
@@ -62,8 +62,20 @@ RECFLARE_DOMAIN=rec.example.com
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
# network shares one address. This is the one to raise (or zero out) if real players
# report being locked out.
# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash,
# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the
# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script.
# Signups that post no device id (the website's) aren't counted.
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password
# account with no Steam/Meta identity, which nothing but the caps above can limit, and a
# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an
# existing password account is never affected, nor is creating one by launching the game.
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
# RECFLARE_PASSWORD_SIGNUP=off
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
@@ -105,7 +117,8 @@ RECFLARE_DOMAIN=rec.example.com
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
# substitute's default one, and a target that doesn't exist leaves the original room in
# place. Following a friend or joining a specific instance is unaffected — those join a
# place. Only client builds newer than 20230414 are switched; the 2023 client and older
# enter the room they asked for. Following a friend or joining a specific instance is unaffected — those join a
# live instance, which is already in whichever room it was created in.
# RECFLARE_ROOM_REDIRECTS=2=MyHub
@@ -134,6 +147,26 @@ RECFLARE_DOMAIN=rec.example.com
# 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 pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead,
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
# use the pool above.
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
#
# The pool players on a build under staged rollout (RECFLARE_ROLLOUT_GAME_VERSIONS below,
# by default `20260323`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS too) are served from, same format,
# ids `rollout-1`, `rollout-2`, … — the new build gets its own voice servers while it rolls
# out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players
# use the pool above.
# RECFLARE_TACHYON_HOST_PORT_ROLLOUT=198.51.100.13:7777
#
# WHICH builds are served that rollout pool, when it should not simply follow the sign-in
# gate: same format as RECFLARE_ROLLOUT_GAME_VERSIONS (builds, commas; `off` for none;
# `20260323` and `20260323.14` name the same build). Unset, the rollout pool goes to the
# builds under staged rollout, so ending the rollout (ROLLOUT_GAME_VERSIONS=off) also moves
# those players onto the live pool. Set this to keep the 2026 build on its own voice
# servers after it opens to everyone — the 2025 and 2023 builds stay on the pool above.
# RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS=20260323
# 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
@@ -149,6 +182,15 @@ RECFLARE_DOMAIN=rec.example.com
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
# The most RecCenterTokens one gift from the staff card on a player's profile can carry
# (`www`). A guard against typos — a gift can't be taken back — not a limit on how much
# staff can give in total.
# RECFLARE_MAX_TOKEN_GIFT=10000
# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
# 15,090 XP in all (level 1 to 50).
# RECFLARE_MAX_XP_GIFT=100
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
@@ -185,6 +227,101 @@ RECFLARE_DOMAIN=rec.example.com
# 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.
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
# linked to, or CREATES one — the player then sets a password on the website to sign in to
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
#
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
# Add a bot user to the same Discord app, invite it to the server (no permissions needed),
# and store its token in the Secrets Store:
#
# wrangler secrets-store secret create <store-id> --name DISCORD_BOT_TOKEN \
# --scopes workers --remote
#
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if
# you don't want the sweep — it then does nothing. It never touches hasPlus.
# Which client BUILDS this server serves (`auth` and `api`, with `match` reading the rollout
# list). Both are comma-separated lists of builds — the `YYYYMMDD` of the client version;
# a copied `20250718.01` is read as `20250718` — or `off` for an empty list. Unset means
# the defaults in packages/domain/src/presence-db.ts (SUPPORTED_GAME_VERSIONS: the 2023
# and 2025 builds plus the alphas; ROLLOUT_GAME_VERSIONS: `20260323`).
#
# A build outside SUPPORTED is told to update by `api`'s version check AND refused a token
# by `auth` (every grant, staff included), so dropping a build here is how its players are
# made to upgrade. A build in ROLLOUT is served too, whether or not SUPPORTED names it, but
# only to staff and the Discord roles below. The lifecycle these exist for:
# 1. Rollout (both unset): the 2026 build is for staff and supporters only.
# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone
# (and joins the live voice pool unless TACHYON_ROLLOUT_GAME_VERSIONS above keeps it).
# 3. Forced upgrade: SUPPORTED_GAME_VERSIONS=20230414,20260323 (with `off` above) — the
# 2025 build is refused and has to update.
# To pull a rollout back instead, list neither: SUPPORTED_GAME_VERSIONS=20230414,20250718
# with ROLLOUT_GAME_VERSIONS=off.
# RECFLARE_SUPPORTED_GAME_VERSIONS=20230414,20250718,20260323
# RECFLARE_ROLLOUT_GAME_VERSIONS=20260323
# OPTIONAL: a staged rollout of a new client build to Discord supporters (`auth`). The
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — by default
# `20260323`, see RECFLARE_ROLLOUT_GAME_VERSIONS above. A login
# posting that build as `ver` is refused ("this build is limited to Discord supporters")
# unless the account is staff (developer or moderator — always let in) or its Discord link
# holds one of these roles; every other build is never touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one
# qualifies. Roles come from the snapshot the benefits claim and the daily sweep keep, so a
# player has to have claimed on the website, and a role granted today opens the gate once
# the sweep runs (or they re-claim). UNSET — or parsing to no roles — CLOSES the gate: nobody
# but staff signs in on the gated build until a role is named here. Accounts can't be CREATED on a
# gated build (a new account has no Discord link); players make one on a released build,
# link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
# `betastudio` role into their token exactly as it does for an account staff flagged
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
# settings page say "granted" for them too. The flag keeps working beside this, so staff
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
# parsing to no roles — grants nobody: the flag alone decides.
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
# apps/econ/wrangler.jsonc ships weekly (Monday 05:00 UTC); change that line for daily. ONE
# box per member per run — a member holding several mapped roles is paid the HIGHEST amount
# among them, not the sum, so the map reads as tiers. Same rules as ROLE_IDS above:
# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits
# claim and the daily sweep above keep, so it reaches only players who have claimed on the
# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the
# day they claimed). The website also pays the same box ONCE, on the spot, the first time an
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
# deploy, so create it even if you run no build host: the literal `off` disables cloud
# builds (the editor is told "Cloud building is not configured").
#
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
# --scopes workers --remote
#
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
# a mismatch leaves jobs queued forever with no error on either side — set this to the
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
+136 -20
View File
@@ -47,15 +47,30 @@
<game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
Those lists are DEFAULTS: the `SUPPORTED_GAME_VERSIONS` and `ROLLOUT_GAME_VERSIONS` worker
vars (`RECFLARE_*` in `.env`, reaching every worker) override them — read through
`supportedGameBuilds(env)` / `rolloutGameBuilds(env)`, never the constants, in anything
that gates on "is this build served / under rollout" (`auth` tokens, `api` versioncheck,
`match` rollout voice pool). A build outside the supported set is told to update by `api`
AND refused a token by `auth`; a rollout build is always served, but gated to staff and
Discord roles until `ROLLOUT_GAME_VERSIONS=off`. The rollout VOICE pool
(`TACHYON_HOST_PORT_ROLLOUT`) follows that list by default but `TACHYON_ROLLOUT_GAME_VERSIONS`
overrides it (`tachyonRolloutGameBuilds` in `match`), so a build can stay on its own Tachyon
servers after its rollout ends.
- `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.
- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`;
reaches this server via the [patch-2025](https://github.com/recflare/patch-2025) patch.
- `20260323` (`20260323.14`), `20250424` (`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.
A client reports `YYYYMMDD[.NN]`; the `.NN` is a same-day point release of the same build
and decides nothing. `gameBuild` strips it, the lists above hold bare dates, every gate
compares builds (`validateAndGetBuild` in `@repo/jwt` is the token form), and the
`room_instance` key is stored as the build so `20250718.01` and `20250718.2` share a
session. The token's `rn.ver` and presence's `appVersion` still echo the client's full
version. Builds 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>
@@ -100,8 +115,8 @@ inconsistency here without checking the client first.
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
`subRoomDataSave` is camelCase with a different field set from the PascalCase
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
`CurrentSave` embedded in the room (no moderation state or asset arrays;
version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct.
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
nothing. The key must be present (null before the first publish); read it via
@@ -115,15 +130,24 @@ inconsistency here without checking the client first.
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
serves. Three shapes of one save; keep them straight.
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
co-owners as such (a co-owner passes only by standing there). They list unpublished
staged saves, so they aren't public; but a visitor resolves which version an instance is
running from this list, so creator-only locks them out of loading the room. The grant
expires with the presence row.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a
PASCALCASE row like the list serves — NOT the camelCase projection the room save's
response uses (served that, the client showed a save with every value missing). It
differs from the list row only in how it names bundles: dependency entries in `UnitySubAssets`
are `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle appears only at top-level
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
them straight.
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
auth-gated and readable by the room's creator, a CO-OWNER, anyone whose live `presence`
row puts them in that room, or STAFF (a `developer`/`moderator` token). They list
unpublished staged saves, so they aren't public; but a co-owner opens the save history from
the room's settings without being in the room (creator-only showed them the buttons and a
list that 403'd), and a visitor resolves which version an instance is running from this
list, so managers-only locks them out of loading the room. The presence grant expires with
the presence row. The client reads `…/saves/no_unity_assets` BEFORE it matchmakes a private
instance, when the caller has no presence in the room yet, so presence alone cannot let a
non-owner create one: a developer creating a private instance of a room they hold no role
on was 403'd there until staff were let through.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
@@ -187,15 +211,107 @@ inconsistency here without checking the client first.
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`.
the field. Bites a chat message's `Contents` the same way.
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame
names — because the client raises its prompt about whoever that field names. Sending the
voter asks the room to kick the player who called the vote. Its `Data` is the posted
`Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it
once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON
printed where the reason belongs. `Data` is still a string on the wire — that part of the
rule above holds; a reason simply needs no escaping to satisfy it.
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
place and the top of a board must be 0. Its own slice says the same: it asks for the first
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
(99999) precisely because 0 is now a real rank, first place.
- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE
record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor`
and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a
FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has
those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per
`BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an
import is the export verbatim except that `CreatorAccountId` is forced to 1, the Coach — which
is what files it as stock content and whom a sale pays — and each save's `ThumbnailFileName`
is put under `avatar/`, where this server serves it from (`just cai-load` loads it;
`apps/api/scripts/import-custom-avatar-items.ts` writes a migration; re-importing an id
replaces it). The export's `Price` is mostly 0, and a purchase charges the record's `Price`,
so first-party prices are written into the export from the storefront dump
(`apps/econ/static/db/Watch_EnumValue_3.json`, joined on
`AvatarItemInfo.DownloadableAvatarItemId`) by `apps/api/scripts/price-custom-avatar-items.ts`
before loading. Serve both through one shape: the shirt carries
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
name their assetbundles and thumbnails by bare filename; storing the record does nothing
about serving those files.
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
builds' hashes, one stored save serves both targets, and the client refuses a download that
fails the hash it was given but skips the check on an empty one.
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
in the bag's one debit and is credited to the item's `CreatorAccountId` (pushed to them as a
`StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in
`inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item
still comes in a gift box, like any purchase: its `GiftPackage` names it by
`CustomAvatarItemId` with `AvatarItemDesc`/`EquipmentPrefabName`/etc. empty — without a box
the purchase shows the player nothing. `BypassGiftPackages` skips it as it does any line.
A custom line may carry a `Gift` block: ownership and the box go to `ToPlayerId` (announced
with `GiftPackageReceivedImmediate`), the buyer still pays and the creator is still paid, and
the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own. `GET /econ/customAvatarItems/v1/owned` is that table
joined to the item record PLUS everything the caller created (drafts included): a creator
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
- A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends
`ReportCategory` as an enum NAME (`Discriminatory`), where every other report sends the
number. It names no player: the reported one is the message's sender, read from `message`.
The name is mapped onto the numeric `KickReportCategory` the `report` table stores
(`chatReportCategory`); only `Discriminatory` has been observed, so an unmapped name is
filed as 0 with the name kept in the details — extend the alias table as more are seen.
The reply is the `{ success, error: "" }` envelope the other reports use, which is an
ASSUMPTION here: what the client does with this response has not been observed.
- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`:
`/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
refresh relationship state: sent alone, the request "worked" server-side and the target
saw nothing. Same pattern as the cheer.
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame;
`www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar
item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds
one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The
client routes the box on that field before it reads what else the box names, so a 0 sends it
looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
every worker that mints a box, not only `econ`.
- A ban's `Duration` (`api`: `/api/PlayerReporting/v1/moderationBlockDetails`, and the
`ModerationKick` frame `www`'s staff ban sends) is the seconds LEFT as of the response — the
client counts it down from receipt. It is NOT the ban's full span paired with
`TimeoutStartedAt` (start + duration): served that way, the screen read correctly only in
the kick frame, sent at the instant the span and the remainder coincide, and every later
sign-in showed the whole ban still to run. `TimeoutStartedAt` stays null for a ban; where
other implementations set it at all, it names the start of a voice-chat timeout. Other
implementations with timed bans keep their set-time private and serve the remainder on
every read; `banned_at` here is the staff panel's audit trail, not the client's.
- `unity_asset` holds two kinds of bundle under one shape: Rec Room Studio builds (UGC, the
`studio` worker writes them, filenames `studio/<roomId>/…`) and FIRST-PARTY bundles captured
from the official game and loaded by hand (`unity/<roomId>/…`). Only the Studio ones may be
folded onto `CurrentSave.UnitySubAssets` (`rooms`: `GET /rooms/:id`, the save reads) —
`attachStudioUnityAssets` filters on `isStudioBundleFilename`. The 2025 client resolves a
first-party bundle through `POST /unity_assets/baked/bulk` (which serves both kinds); handed
it inline in the five-key `{ Target, Version, Filename, Hash }` shape it takes that instead,
cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads
nothing. Every imported first-party room broke when the splice moved from the Studio-only
table to `unity_asset`, with every response looking correct.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+108 -1
View File
@@ -51,6 +51,113 @@ bun runx admin grant-developer --account 1 --revoke
bun runx admin grant-moderator --username alice --remote
```
### `grant-studio` — grant or revoke Rec Room Studio upload access
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
```sh
bun runx admin grant-studio --username alice
bun runx admin grant-studio --account 42 --revoke
```
### `reload-plus` — credit every Plus subscriber's tokens
Also exposed directly as `just reload-plus`.
```sh
just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing
just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production
```
Adds `<amount>` RecCenterTokens (currency type 2) to the `balance` row of every account
whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write.
Subscribers are found through the indexed `has_plus` column on `account` (auth migration
0009), so the reload is one statement however many accounts there are. Nothing schedules
it: run it when the subscription's tokens are due, and note that running it twice credits
twice.
A subscriber who has never loaded the game has no balance row yet. They get one holding
their signup grant **plus** the reload, so the grant is not lost — the grant amount is
`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default
of 10000. Prints every credited account with its resulting balance.
This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config;
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
`account` table in it.
### `catalog load` — reload the item catalog
```sh
just catalog-load # sf3-2025.json → local (merge)
just catalog-load --remote # production
just catalog-load --replace # also drop rows the file no longer lists
just catalog-load --dry-run # build and validate, write nothing
bun runx catalog check # validate the store file without touching a database
```
Fills econ's `catalog` table from `apps/econ/static/storefronts/sf3-2025.json`, the 2025
store as the game is served it, and nothing else. Re-run it whenever that file changes (after
`runx storefront build`). Each row's `catalog_id` is its listing's `PurchasableItemId` and its
`listing` column the listing verbatim, which is what
`POST /api/storefronts/v1/purchasableitems` serves. Avatar items, consumables, skins and
first-party custom avatar items all load; loot boxes, token bundles and consumable packs do
not. A merge leaves rows the file no longer lists in place with no `catalog_id` or `listing`;
`--replace` deletes them. The inventory does not read this table, so either is safe.
### `cai-load` — load the first-party custom avatar items
```sh
bun runx admin cai-load # 2025-1-cai.json + studio87-extras.json → local
bun runx admin cai-load --remote
bun runx admin cai-load --file path/to/export.json --dry-run # --file takes one or more
just cai-load --remote # the same, as a recipe
```
Loads exports of `CustomAvatarItem` records (each a JSON array, a `{ Results }` page, or one
record) into the `custom_avatar_item` table. With no `--file` it reads
`apps/econ/static/db/2025-1-cai.json`, then `studio87-extras.json` — the official-store items
the 2025 export lacks, fetched from another server's `customAvatarItems/v1/bulk` and priced
from the storefront dump (an item it lists with no price is free). A later export only ADDS:
an id an earlier one already holds is skipped, so the extras never overwrite a 2025 item. A
duplicate within one export is refused. Each record is stored as the row's JSON with its
`CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what
files it as stock content (the storefront tab searches by creator) and what a purchase
pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves
those images from (a name already prefixed is left alone). It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the
players' own shirts, which share the table, are untouched. Re-running is safe. The load is
verified by counting rows and probing ids from across the export; it fails loudly rather
than report a partial load. `--dry-run` reads and validates the export and prints the
statement count without writing.
The saves in an export name their assetbundles by bare filename; the load stores the
names only.
A record's `Price` is what a purchase charges, and the official export mostly says 0. The
prices come from a storefront dump instead, `apps/econ/static/db/Watch_EnumValue_3.json`
(a `{ StoreItems }` page with price and rarity, joined on
`GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`). Before loading
a fresh export, write those prices into it:
```sh
bun apps/api/scripts/price-custom-avatar-items.ts \
apps/econ/static/db/2025-1-cai.json apps/econ/static/db/Watch_EnumValue_3.json
just cai-load --remote
```
The script rewrites the export in place with the base token price (`CurrencyType` 2),
ignoring any sale. It warns about every price it changes that wasn't 0 before. The
storefront only prices items already in the export; nothing is added from it. An item the
storefront doesn't list keeps its old price, unless that price is 0, which would make it
free to buy. Those items (the Cryptid Creek event rewards and the Evergrown Hip Pack) get the
catalog's rarity-50 price, `PRICE_BY_RARITY[50]` in `apps/econ/src/catalog-load.ts`. The
script names every item the storefront doesn't list. The
committed `2025-1-cai.json` is already priced. Rarity isn't carried over, since the record
has no field for it.
### `lookup` — print an account
```sh
@@ -65,7 +172,7 @@ the account has a password, the developer role, and the moderator role.
### Selecting an account
Every command targets exactly one account, by **either**:
Every command except `reload-plus` and `cai-load` targets exactly one account, by **either**:
- `--account <id>` — numeric account id
- `--username <name>` — username (case-insensitive)
+22 -4
View File
@@ -10,7 +10,7 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
the real thing — services scale,
fail, and deploy independently — instead of collapsing everything into a single
giant server.
- **It's free/cheap to run a lot of service.**
- **It's free/cheap to run a lot of services.**
Cloudflare Workers' free tier is keyed
to usage, not to the number of Workers — so whether you deploy 1 service or all
36, the baseline cost is the same. You only start paying once usage crosses the
@@ -43,13 +43,16 @@ services but would require small code changes.
## Prerequisites
**You must have all these requirements or RecFlare deployment will fail!**
**You must have all these requirements or RecFlare will not work!**
- node 24 (https://nodejs.org)
- pnpm (install with `npm install -g pnpm`)
- bun (https://bun.sh)
- jq/awk/sed (on Windows try `winget jq` etc.)
- A Cloudflare account with a zone (domain) you control, for deploying.
- A Cloudflare account with a zone (domain) you control, for deploying
- **For 2023:** A Photon Cloud Realtime and Voice account OR a custom Photon server (Luxon, OpenPhoton)
- **For 2025:** A Photon Cloud Realtime account AND a Tachyon server, which is a custom fork of Dissonance voice that Rec Room developed. See https://discord.com/channels/1524485206550118460/1544055485336915979
If you don't provide a Tachyon server address during deployment, your server will still work but without voice.
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
Rec Room client is very chatty. Frequent testing may exhaust that quota. The $5/month
@@ -59,6 +62,8 @@ See https://developers.cloudflare.com/workers/platform/pricing/#workers
## Getting Started
**WSL** is preferred for deploying if you are on Windows. Only Linux has been fully tested.
**Install dependencies:**
We use [Just](https://github.com/casey/just) for convenience. This will install all dependencies across the microservices.
@@ -67,7 +72,7 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
You do not have to use `just` but then you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
@@ -79,6 +84,8 @@ cp .env.example .env
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
Go through the rest of the file and set the variables as needed.
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
@@ -133,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
long random string in later when you set up a build host, and give that host the same
string.
```bash
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
```
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
+29 -2
View File
@@ -71,7 +71,7 @@ preview:
deploy *args:
bun turbo deploy "$@"
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
# Apply D1 migrations (every worker with a `migrate` script). Defaults to --remote; pass `-- --local`
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
[group('2. local dev')]
[positional-arguments]
@@ -109,10 +109,37 @@ update *args:
runx *args:
bun runx "$@"
# Admin account tools (set-password, clear-password, grant-developer, lookup).
# Admin account tools (set-password, clear-password, grant-developer, grant-plus, lookup).
# Run `just admin --help` for usage and examples. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
admin *args:
bun runx admin "$@"
# Credit every Rec Room Plus subscriber's RecCenterTokens balance by <amount>. Defaults to
# --local; pass --remote for production, --dry-run to only list who'd be credited. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
reload-plus *args:
bun runx admin reload-plus "$@"
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json, then
# studio87-extras.json, by default) into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
# Defaults to --local; pass --remote for production, --dry-run to only validate. Prices come
# from the storefront dump via apps/api/scripts/price-custom-avatar-items.ts. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
cai-load *args:
bun runx admin cai-load "$@"
# Merges by default; --replace drops rows the file no longer lists. Defaults to --local; pass
# --remote for production, --dry-run to only validate. Run it whenever sf3-2025.json changes.
# Reload the item catalog (econ `catalog` table) from apps/econ/static/storefronts/sf3-2025.json
[group('4. utility')]
[positional-arguments]
[no-cd]
catalog-load *args:
bun runx catalog load "$@"
+19 -7
View File
@@ -40,18 +40,30 @@ See [RecFlare Client](https://github.com/djdevin/recflare-client) for the offici
These game builds are supported:
| Build | Manifest | Support |
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20250424.01` | | Alpha |
| `20231207` | | Alpha |
| `20230616` | | Alpha |
| Build | Client version | Manifest | Support |
| ---------- | -------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718` | `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20260323` | `20260323.14` | | Alpha |
| `20250424` | `20250424.01` | | Alpha |
| `20231207` | `20231207` | | Alpha |
| `20230616` | `20230616` | | Alpha |
A build is the `YYYYMMDD` date of the client version; the `.NN` after it is a same-day
point release and is ignored everywhere, so `20250718.01`, `20250718.2` and `20250718`
are all the beta build and share game sessions.
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.
The table is the default. An operator narrows it with `RECFLARE_SUPPORTED_GAME_VERSIONS`
in `.env` (a build left out is told to update and refused a sign-in, which is how players
are moved to a newer build), and `RECFLARE_ROLLOUT_GAME_VERSIONS` names the builds under
staged rollout to Discord supporters — `20260323` by default, `off` to open it to
everyone (`RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS` keeps a build on the rollout voice
servers past that). See `.env.example`.
Generally speaking any client that effectively rewrites the nameserver with the
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.
+38 -38
View File
@@ -20,41 +20,41 @@ A small `ns` worker itself serves this discovery document at the
apex/`ns` host and isn't listed within it. Each implemented worker has its own
`README.md` under `apps/<name>/` documenting its routes.
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | --------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth token issuance (`/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data (R2) |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | — | Not yet implemented |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | — | Website/Panel |
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth tokens, plus Studio device login (`POST /connect/deviceauthorization`, device-code grant on `/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data, and Studio room bundles at `/unityasset/{filename}` (R2, D1 lookup). The editor's `studiocdn` host is this same app on a combined deploy |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | `studio` | `GET /cloud-builds/for-room` lists that room's cloud builds. `POST /cloud-builds/from-editor` stores a locally built Windows and Android asset-bundle pair as a finished cloud build on the latest save, and sets `BecameRRStudioRoomAt` the first time. The game loads those bundles from **Rooms** (`CurrentSave.UnitySubAssets`, `GET …/unityasset`) and **CDN** `/unityasset/{filename}`. `GET /collaboration/owners-in-room` lists co-owners whose presence is in that room and subroom. Device login stays on **Auth**; `GET /account/me` stays on **Accounts**; the approval page is **WWW** `/device` |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | `www` | Website, including the Studio sign-in page at `/device` |
+99 -29
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
createAccount,
DEFAULT_USERNAME_CHANGES,
defaultAccount,
getAccount,
getAccountByUsername,
@@ -28,6 +29,7 @@ import {
BannerImageRequest,
BioRequest,
BioResponse,
BulkIdsRequest,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
@@ -51,6 +53,7 @@ import {
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -78,9 +81,6 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Username changes a fresh account starts with (until one has been consumed). */
const DEFAULT_USERNAME_CHANGES = 3
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
@@ -102,6 +102,69 @@ async function formField(c: Context<App>, name: string): Promise<string> {
return typeof value === 'string' ? value : ''
}
/**
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
* them all, which is the whole point here.
*/
async function formFields(c: Context<App>, name: string): Promise<string[]> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const value = body[name]
const values = Array.isArray(value) ? value : [value]
return values.filter((v): v is string => typeof v === 'string')
}
/**
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
* ids ride.
*/
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
return {
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
'comma-separated lists. Every requested id appears in the response — ids with no',
'stored row get a synthesized default.',
].join(' '),
...(source === 'query'
? {
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
}
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}
}
/**
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
*/
async function bulkAccounts(c: Context<App>) {
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
const ids = [
...new Set(
raw
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
/**
* Project a stored account into the public account DTO — the client's camelCase
* shape, excluding private fields like `email` (surfaced only by /account/me).
@@ -284,43 +347,49 @@ const app = new Hono<App>()
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
// Exact lookup by username — what the site's `/u/<name>` profile pages and the staff
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
// before this existed they went through the prefix search above and filtered the
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
// as the official API's `GET /account/username/{username}`.
.get(
'/account/bulk',
'/account/username/:name',
describeRoute({
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
'id appears in the response — ids with no stored row get a synthesized default.',
].join(' '),
summary: 'A single public account, by exact username',
description:
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
name: 'name',
in: 'path',
required: true,
description: 'The username, exactly (case-insensitive)',
schema: { type: 'string' },
},
],
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
responses: {
200: json(AccountDto, 'The public account'),
404: { description: 'No account has that username' },
},
}),
async (c) => {
// Reads repeated `id` query params; also accept a comma-separated list.
const ids =
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
const name = c.req.param('name').trim().replace(/^@/, '')
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
return c.json(toAccountDto(account))
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
// The 2023 client asks for its friends list as a POST with the ids in a
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
// moved off the URL because a few hundred friends overflow it. GET is the same
// lookup and stays for callers (and docs) that prefer it.
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
.get(
'/account/:id/bio',
describeRoute({
@@ -470,7 +539,8 @@ const app = new Hono<App>()
responses: {
200: json(SuccessResponse, 'Updated'),
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
description:
'Empty, over 15 characters, outside the display name charset, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
+16 -5
View File
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
displayNameRejection,
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
@@ -181,12 +182,12 @@ export const CreateAccountRequest = z.object({
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
const nameCheck = (label: string, max: number) =>
const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
z
.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
const rejection = rejectionFor(value)
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
@@ -197,21 +198,31 @@ const nameCheck = (label: string, max: number) =>
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
displayName: nameCheck('display name', displayNameRejection)
.min(1)
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
`Trimmed; Latin letters, digits, spaces and .,'!?_&()#@:+- only, max ${MAX_DISPLAY_NAME_LENGTH}, no profanity. Empty or invalid is rejected (400)`
),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
username: nameCheck('username', (value) => nameRejection(value, 'username', MAX_USERNAME_LENGTH))
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
),
})
/**
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
* sends its whole friends list this way); each value may also be a comma-separated list.
*/
export const BulkIdsRequest = z.object({
id: z
.union([z.string(), z.string().array()])
.describe('Repeatable; each value may be a comma-separated list of ids'),
})
export const EmailRequest = z.object({
email: z
.string()
+94 -12
View File
@@ -97,6 +97,48 @@ describe('public endpoints', () => {
expect(accounts[1].username).toBe('Player2')
})
test('POST /account/bulk reads the ids from a form body', async () => {
// The 2023 client asks for its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1&id=2&id=3',
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
expect(accounts[0].username).toBe('Coach')
})
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
// the later statements ran, not just the first.
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual(ids)
expect(accounts.at(-1)?.username).toBe('Coach')
})
test('POST /account/bulk returns one entry per id, deduped', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
// Negative ids are real in the client's list and synthesize like any other.
body: 'id=1&id=-1&id=1',
})
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
expect(accounts[1].username).toBe('Player-1')
})
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
expect(res.status).toBe(200)
@@ -111,6 +153,24 @@ describe('public endpoints', () => {
expect(await empty.json()).toEqual([])
})
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
expect(res.status).toBe(200)
const account = (await res.json()) as { accountId: number; username: string }
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
// A leading `@` is tolerated — staff fields take names in that form.
const at = await exports.default.fetch(
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
)
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
// A prefix is NOT a match: that's what `/account/search` is for.
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
expect(prefix.status).toBe(404)
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
expect(missing.status).toBe(404)
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
})
test('GET /account/:id/bio returns an empty bio', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
@@ -230,7 +290,7 @@ describe('auth-gated endpoints', () => {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// These names carry no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
@@ -647,11 +707,13 @@ describe('auth-gated endpoints', () => {
'GET /account/bulk',
'GET /account/me',
'GET /account/search',
'GET /account/username/{name}',
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/bulk',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
@@ -698,8 +760,9 @@ describe('auth-gated endpoints', () => {
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
// a username is alphanumeric (`nameRejection`), a display name takes the wider charset
// the client's own box does (`displayNameRejection`). The limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
@@ -744,25 +807,44 @@ describe('name, email and bio validation', () => {
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
test('PUT /account/me/displayname holds a name to the client charset, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
const put = (displayName: string) =>
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// The Rec Room build's own rule: ^[\p{Latin}0-9 .,'!?_&()#@:+-]+$
for (const displayName of ['has space', 'punct!', 'café', "O'Neil_Jr.", '(a)&b#1@c:+-,?']) {
expect((await put(displayName)).status, displayName).toBe(200)
}
for (const displayName of [
'semi;colon',
'star*',
'sla/sh',
'Привет',
'名前',
'smile😀',
'a'.repeat(16),
]) {
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
expect((await put(displayName)).status, displayName).toBe(400)
}
// Trimmed before it is checked, so a name of nothing but spaces is an empty one.
expect((await put(' ')).status).toBe(400)
// A swear set off by the punctuation the charset now allows is still a swear.
for (const displayName of ['shit lord', 'Shit_Lord', 'fuck.you', 'fuck!']) {
expect((await put(displayName)).status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
expect((await put('a'.repeat(15))).status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
+1 -4
View File
@@ -39,10 +39,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+28 -12
View File
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
import {
AUTHED,
@@ -34,8 +34,8 @@ import type { App } from './context'
*
* - Game AI is a SERVER-side feature. This server cannot provide it, so both its reads are
* refused and the client hides the feature.
* - Roomie runs on the CLIENT and only asks this service what it may spend, so the budget
* reads are granted in full. The session that would actually reach a model
* - Roomie's native 2026 companion gets zero energy and no generated output audio;
* older clients keep the full budget. The session that would actually reach a model
* (`/realtime-session/create`) is where it stops.
* - Maker AI meters model usage in dollars. Nothing here bills, so every figure is zero.
*/
@@ -163,26 +163,27 @@ const app = new Hono<App>()
}
)
// Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client
// and only asks this service how much energy it has, so the honest answer for a server
// that meters nothing is "as much as you can count".
// The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising
// full energy selects a conversation backend we do not provide. Grant access with
// zero energy and the client's recognized no-credit result instead. A successful
// access response still opens a realtime session even when its energy is zero.
// Preserve the older client's budget and recharge-field contract.
.get(
'/roomieai/user/access',
describeRoute({
tags: ['Roomie AI', '2025'],
summary: 'The caller’s Roomie AI energy budget',
description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never',
'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no',
'spend to recharge from.',
'The 20260323 build receives the recognized no-credit result and zero energy for its native',
'companion mode. Its DTO has no recharge field. Older clients retain the full',
'int32-max budget and null recharge time. No model session is created.',
'',
'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI',
'check answers with. The two are different shapes on purpose — don’t unify them.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomieAiAccess, 'The energy budget — always granted, always full'),
200: json(RoomieAiAccess, 'Build-specific conversation access and companion fallback'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -190,6 +191,21 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The build, not the version: every point release of 20260323 has the companion mode.
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
if (build === '20260323') {
return c.json({
success: false,
error_id: 'AI.InsufficientRoomieAICreditNoSub',
error: 'AI conversation is disabled. Roomie companion mode is available.',
value: {
MaxEnergyFromSubscriptions: 0,
EnergyLeft: 0,
OutputAudioEnabled: false,
},
})
}
return c.json({
success: true,
error_id: null,
@@ -389,7 +405,7 @@ app.get(
'refusals, because the features fail at different points. Game AI is a server-side',
'feature this server cannot provide, so both its reads refuse. Roomie and Maker AI',
'only ask what the caller may SPEND, which nothing here meters, so those reads are',
'granted in full; the refusal lands instead on `POST /realtime-session/create`, the',
'granted with a build-specific budget; conversation refuses on `POST /realtime-session/create`, the',
'one call whose real answer is a working credential rather than a description of one.',
'',
'The refusals are 200s carrying `success: false`, which is the shape the client',
+9 -8
View File
@@ -71,22 +71,23 @@ export const HealthResponse = z.object({
/**
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
* the maximum a signed 32-bit int holds (see INT32_MAX).
* the flat Game AI refusal above. 2026 uses the no-credit fallback with zero energy;
* older builds retain their full signed-32-bit budget and recharge field.
*/
export const RoomieAiAccess = z.object({
success: z.literal(true),
error_id: z.null(),
error: z.null(),
success: z.boolean(),
error_id: z.string().nullable(),
error: z.string().nullable(),
value: z.object({
MaxEnergyFromSubscriptions: z
.int()
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
.describe('Zero for the 2026 companion; int32 max for older builds'),
EnergyLeft: z.int().describe('Zero for the 2026 companion; int32 max for older builds'),
NextSubscriptionEnergyRechargeAt: z
.string()
.nullable()
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
.optional()
.describe('Null on older builds; omitted for the 2026 DTO'),
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
}),
})
+31 -2
View File
@@ -27,10 +27,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, 'rn.ver': version })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -96,6 +96,35 @@ describe('GET /gameai/user/access', () => {
})
describe('GET /roomieai/user/access', () => {
it('selects the 2026 no-credit companion fallback without model credentials', async () => {
const headers = await bearer('42', '20260323.14')
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: false,
error_id: 'AI.InsufficientRoomieAICreditNoSub',
error: 'AI conversation is disabled. Roomie companion mode is available.',
value: { MaxEnergyFromSubscriptions: 0, EnergyLeft: 0, OutputAudioEnabled: false },
})
// Entitlement to local activities must never mint a model credential.
const session = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ AIType: 'Roomie' }),
})
expect(session.status).toBe(200)
expect(await session.json()).toMatchObject({ success: false, value: null })
})
it('preserves the older build budget', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, {
headers: await bearer('42', '20250718.01'),
})
expect(await res.json()).toMatchObject({
value: { EnergyLeft: 2147483647, OutputAudioEnabled: true },
})
})
// Granted, unlike the Game AI check: Roomie runs on the client and only asks for its
// energy budget, which nothing here meters.
it('grants an int32-max energy budget', async () => {
+1 -4
View File
@@ -16,10 +16,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+8
View File
@@ -10,3 +10,11 @@ declare namespace Cloudflare {
mainModule: MainModule
}
}
// Vite serves a `?raw` import as the file's text. Used by the tests to read a data
// migration (0032, the custom-item price cap) as a STRING and run its UPDATE against the
// schema they build from SCHEMA_DDL, which the migration files never touch.
declare module '*.sql?raw' {
const content: string
export default content
}
+3 -3
View File
@@ -4,9 +4,9 @@
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
-- every response. A decline is recorded rather than deleted, so the client can show
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go, 3 Pending (a bulk
-- invite's row for a player who hasn't answered yet). Only Going counts toward the
-- event's `AttendeeCount`, which is recomputed from this table on every response. A decline is recorded rather than deleted, so the client can show
-- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace).
--
@@ -0,0 +1,24 @@
-- Who handed a ban down, and when. A ban lives on the `report` row it rests on (see
-- 0009_report_ban.sql), which recorded the decision but not the deciding: there was no way
-- to tell which moderator set `banned`, and no way to tell WHEN they set it. Generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `banned_at` is not bookkeeping. The client's block screen reads `TimeoutStartedAt` and
-- `Duration` as a PAIR — the block runs from the start for the duration — and
-- `moderationBlockDetails` had only the report's `created_at` to offer as that start. So a
-- ban applied to a month-old report told the banned player their block began a month ago,
-- and the duration (computed from there to `ban_expires`) came out a month short: a 7-day
-- ban on a 30-day-old report reads as already served. With this column the ban's own start
-- is recorded and `banBlockDetails` uses it, falling back to `created_at` so every row
-- written before this migration keeps behaving exactly as it did.
--
-- Both are NULL on an unbanned row, and are CLEARED when a ban is lifted — the report
-- itself survives, as it does for `banned`/`ban_expires`. So a non-null `banned_at` means
-- "a ban is or was in force from here", which is the same thing `banned = 1` means.
--
-- Neither column is indexed. `banned_by_player_id` is read per-row when a moderator looks
-- at a ban, never filtered on; the ban reads that exist go by player or by the ban flag
-- (idx_report_banned). Add an index alongside a query that needs one.
ALTER TABLE report ADD COLUMN banned_by_player_id INTEGER;
ALTER TABLE report ADD COLUMN banned_at TEXT;
@@ -0,0 +1,7 @@
-- File every custom avatar item under OutfitType 105 (`RecRoom.Avatars.OutfitType.CustomShirt`).
-- Creation left `outfit_type` at the column's default of 0 (Hat), and the store's
-- user-generated-content tab searches `GET /api/customAvatarItems/v1/search?outfitTypes=105`
-- ALONE, so the tab sat empty over a full catalog. The custom shirt is the one kind of custom avatar item the
-- client can make, so every row written before this migration is one. Creation now writes
-- 105 itself (src/custom-avatar-items-db.ts, OUTFIT_TYPE_CUSTOM_SHIRT).
UPDATE custom_avatar_item SET outfit_type = 105 WHERE outfit_type = 0;
@@ -0,0 +1,69 @@
-- Custom avatar items become a JSON blob per row, the layout every other entity table on this
-- database uses (`invention`, `event`, `room`, `account`, `club`): the client's `CustomAvatarItem`
-- record verbatim in `data`, with the fields the queries filter and sort on exposed as generated
-- (virtual) columns. Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Why: the flat table (0015) had one NOT NULL column per field of the player-made shirt, and the
-- FIRST-PARTY items being imported are the same record with those four fields NULL
-- (`BaseAvatarItemId`, `BaseAvatarItemColor`, `DesignFilename`, `ThumbnailImageFilename` — a
-- studio-built item has no base item and no design PNG) plus fields the table had no home for:
-- `CurrentSaves` (one built Unity assetbundle per body type, which is how the client renders
-- them), `Tags`, `CustomBadgeMetadata`, `RankedEntityId`. Storing the record whole means the
-- import is the record as exported, and a field the source adds later is served without a
-- migration. `CurrentSaves` stays embedded rather than in a child table: the client reads a
-- save off its item and picks one by `BodyType`; nothing looks a save up by its bare id.
--
-- The generated columns keep the names the flat table had, so the indexes and every query
-- read the same. `name_lower`/`description_lower` back the store's search, which matches the
-- needle as a case-folded substring of either. Booleans come out of `json_extract` as 1/0
-- (see 0008), so `is_featured = 1` keeps working.
--
-- The existing player-made rows are carried across by rebuilding the DTO the old `toDto`
-- built from their columns — the same record the routes were already serving — with the
-- fields the flat table never had filled in as it served them (empty saves and tags, null
-- badge/ranking, `RankedEntityId` = the item id, as the official record carries it).
CREATE TABLE custom_avatar_item_new (
data TEXT NOT NULL,
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
);
INSERT INTO custom_avatar_item_new (data)
SELECT json_object(
'CustomAvatarItemId', custom_avatar_item_id,
'CreatorAccountId', creator_account_id,
'Name', name,
'Description', description,
'Price', price,
'Accessibility', accessibility,
'ForceCannotPublish', json(iif(force_cannot_publish, 'true', 'false')),
'IsFeatured', json(iif(is_featured, 'true', 'false')),
'IsRecRoomApproved', json(iif(is_rec_room_approved, 'true', 'false')),
'BaseAvatarItemId', base_avatar_item_id,
'BaseAvatarItemColor', base_avatar_item_color,
'DesignFilename', design_filename,
'ThumbnailImageFilename', thumbnail_image_filename,
'CreatedAt', created_at,
'ModifiedAt', modified_at,
'PreviewOrientation', preview_orientation,
'OutfitType', outfit_type,
'CurrentSaves', json('[]'),
'Tags', json('[]'),
'CustomBadgeMetadata', NULL,
'RankedEntityId', custom_avatar_item_id,
'RankingContext', NULL
) FROM custom_avatar_item;
DROP TABLE custom_avatar_item;
ALTER TABLE custom_avatar_item_new RENAME TO custom_avatar_item;
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id);
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
+20
View File
@@ -0,0 +1,20 @@
-- Vote-to-kick ballots: one row per vote cast, appended, never updated.
--
-- Generated from apps/api/src/votes-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `response` is 1 for yes (kick them) and 0 for no. A player who votes twice leaves two
-- rows: the tally counts DISTINCT voters and reads only each one's latest ballot, so
-- re-posting a vote cannot carry one, and a voter may change their mind.
--
-- Rows are keyed to `game_session_id` (a `room_instance` id) and outlive the session.
-- Nothing prunes them; instance ids are not reused, so an old session's ballots can never
-- be counted into a new one's.
CREATE TABLE IF NOT EXISTS room_vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
response INTEGER NOT NULL,
voter_id INTEGER NOT NULL,
voted_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id);
+40
View File
@@ -0,0 +1,40 @@
-- The audit log — one row per privileged action a staff account took. Owned by the `api`
-- worker; WRITTEN by `notify`, which records every call to its admin-gated `/internal/*`
-- endpoints here. Generated from packages/domain/src/audit-db.ts (AUDIT_LOG_SCHEMA_DDL) —
-- keep in sync.
--
-- This is not the worker log. `logger.info` lines are for reading during an incident: they
-- are sampled, they expire, and nothing can be asked of them later. The actions this table
-- exists for leave no other trace on the database at all — a maintenance broadcast is a
-- WebSocket frame and then nothing, and a coach message to every online player is
-- deliberately not stored (see 0019_notification.sql) — so without a row here there is no
-- answer at all to "who sent that, and when", a question that tends to arrive weeks later.
--
-- `player_id` is the ACTOR: the staff account that made the call, never whoever it was
-- aimed at. A target, where there is one, lives inside `data` with the rest of what was
-- asked for. The broadcasts have no target at all, so a column for one would be null on
-- half the table and could not be the thing every row is filed under.
--
-- `data` is JSON as TEXT, and deliberately unstructured: each action records what it was
-- asked to do, and those shapes have nothing in common beyond being worth keeping. It is
-- nullable because an action whose whole content is its name has nothing to put there, and
-- null says that where `{}` would only look like a payload that went missing. Capped at
-- 4096 characters by the writer, since a request body's size is the caller's to choose;
-- over that the row keeps the head of the JSON and says `truncated`.
--
-- `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the server
-- (unlike `notification`'s, which is handed to the client) and nothing deletes from this
-- table, so there are no freed rowids to be reused. Reads order by it rather than by
-- `date`, which ties when two actions land in the same millisecond.
CREATE TABLE IF NOT EXISTS audit_log (
audit_log_id INTEGER PRIMARY KEY,
player_id INTEGER NOT NULL,
action TEXT NOT NULL,
data TEXT,
date TEXT NOT NULL
);
-- The two ways anyone asks this table anything: everything one staffer did, and every time
-- a given action was taken. Both newest-first, which is the order an audit trail is read in.
CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC);
@@ -0,0 +1,24 @@
-- Reporting a CHAT MESSAGE (`POST /api/chatreport/createChatReport`) reuses the report
-- table, as the event, invention and custom-avatar-item reports before it do: same fields,
-- same moderation life — a moderator acting on one sets `banned` on the row exactly as they
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `chat_message_id` names the reported message; NULL on every other kind of report. It
-- joins `event_id`, `invention_id` and `custom_avatar_item_id`, and all four are mutually
-- exclusive: a row names one of those things or none of them (an ordinary player report),
-- which is what tells the kinds apart.
--
-- The message id is the WHOLE reference. The client also posts the `ChatThreadId`, which is
-- not stored: `message.chat_message_id` is unique across every thread (see the `chat`
-- worker's 0001_message.sql), so the thread is one lookup away and a stored copy could only
-- ever disagree with it.
--
-- The row's `reported_player_id` is the message's SENDER (`message.sender_player_id`), read
-- from the message rather than sent by the client — the body names no player at all, and
-- the column is NOT NULL. No `room_id`: a chat thread is not in a room.
--
-- NOT indexed, for the reason 0016 and 0017 give: it is written on every report of this kind
-- and read by nothing. Every moderation read goes by player (`idx_report_reported`) or by
-- the ban flag. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN chat_message_id INTEGER;
@@ -0,0 +1,21 @@
-- Marks the ballot that CALLED a vote-to-kick. Generated from src/votes-db.ts (SCHEMA_DDL) —
-- keep in sync.
--
-- The client posts the call and every answer to the same endpoint with the same body, so
-- nothing on the wire says which ballot started a vote. `init` is 1 on the first ballot cast
-- against a player in a session while no vote on them is open (VOTE_WINDOW_MS), 0 on every
-- answer. Two things read it:
-- - the tally counts only ballots from the open vote's `init` row on, so an expired vote's
-- yes ballots cannot carry the next one;
-- - the caller throttle: a player who called a vote within VOTE_CALL_COOLDOWN_MS may not
-- call another (`idx_room_vote_caller`). Answering is never throttled.
--
-- Backfill: each (session, player)'s first ballot is taken as its call. Those sessions are
-- over, so this only keeps the caller history honest.
ALTER TABLE room_vote ADD COLUMN init INTEGER NOT NULL DEFAULT 0;
UPDATE room_vote SET init = 1
WHERE id IN (SELECT MIN(id) FROM room_vote GROUP BY game_session_id, player_id);
CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1;
@@ -0,0 +1,17 @@
-- Index the browse/search feed (`searchInventions`) in the order it pages: newest first.
-- Without it every page — however small — parsed the JSON of every published invention,
-- sorted the lot in a temp B-tree and threw all but one page away. With it the query walks
-- this index in order and stops after OFFSET + LIMIT rows.
--
-- PARTIAL, over exactly the rows `VISIBLE_IN_FEEDS` admits, so it holds only what a feed can
-- show. SQLite uses a partial index only when the query's WHERE contains each of these terms
-- as written, so this WHERE must track `VISIBLE_IN_FEEDS` in src/inventions-db.ts term for
-- term — change one and the index silently stops being used. The key is the same
-- `json_extract` expression the query orders by, which is what lets it replace the sort.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
WHERE is_published = 1
AND hide_from_player = 0
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2;
+27
View File
@@ -0,0 +1,27 @@
-- A never-reused id sequence for player events. Owned by the `api` worker; generated
-- from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Event ids used to be `MAX(id) + 1` over the `event` table, which hands the NEWEST
-- event's id straight back out once it is deleted: delete an event, create another,
-- and the new one wears the old one's id — and inherits everything still filed under
-- it that lives outside the event's own rows (a `PlayerEventInvitation` message's
-- `player_event_id`, the client's own cache of the old event). An event id has to be
-- unique for all time, like the other ids here.
--
-- SQLite's AUTOINCREMENT is exactly that guarantee: the counter in `sqlite_sequence`
-- only ever goes up, whatever rows are deleted. `createEvent` draws an id by inserting
-- a row here and reading it back; the row itself is throwaway (it is deleted once
-- used — only the counter matters).
--
-- The seed row moves the counter past every event that already exists: inserting an
-- explicit id into an AUTOINCREMENT table raises the sequence to at least that id, so
-- the first draw after this migration is one past the current max, exactly what the
-- old query would have produced. On an empty table it inserts nothing.
CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
-- HAVING, not WHERE: an aggregate over an empty table still yields one (NULL) row, and
-- a NULL id would draw 1 from the counter, making the first real event 2.
INSERT INTO event_id_seq (id)
SELECT MAX(id) FROM event HAVING MAX(id) IS NOT NULL;
@@ -0,0 +1,41 @@
-- Who LIFTED a ban, and when. Owned by the `api` worker; generated from src/reports-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- A lift clears `banned`, `ban_expires`, `banned_by_player_id` and `banned_at` (see
-- 0020_report_ban_audit.sql), which leaves the row looking exactly like a report nobody
-- ever acted on. On the staff panel that read as "Ban…" next to a report whose ban a
-- colleague had deliberately lifted an hour earlier, and a second moderator re-banned the
-- player without knowing. The `audit_log` still had the lift, but nothing reads it per row.
--
-- So the lift now signs the row: `unbanned_by_player_id` is the moderator who lifted the
-- ban and `unbanned_at` when they did. Both are NULL until a ban is lifted, and are CLEARED
-- again by a re-ban (the row describes the ban's current state, and a standing ban has not
-- been lifted). A non-null `unbanned_at` on a `banned = 0` row therefore means "somebody
-- decided this player should be back in the game", which is what the panel shows.
--
-- Rows lifted before this migration are backfilled from `audit_log` (0024), where every
-- lift has been recorded as an `unban` row naming the report in `data.reportId` — the
-- latest such row per report, since a report can be banned and lifted more than once.
-- Only rows not banned NOW are touched: a re-ban after a lift has cleared the lift.
--
-- Neither column is indexed: read per row on the panel's tables, never filtered on.
ALTER TABLE report ADD COLUMN unbanned_by_player_id INTEGER;
ALTER TABLE report ADD COLUMN unbanned_at TEXT;
UPDATE report
SET unbanned_by_player_id = (
SELECT player_id FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
ORDER BY audit_log_id DESC LIMIT 1
),
unbanned_at = (
SELECT date FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
ORDER BY audit_log_id DESC LIMIT 1
)
WHERE banned = 0
AND EXISTS (
SELECT 1 FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
);
@@ -0,0 +1,25 @@
-- Cover the friend-list read (`getFriendIds` in @repo/domain, and the inlined copy in the
-- friends-filtered leaderboard). Owned by the `api` worker; generated from
-- packages/domain/src/relationships-db.ts (RELATIONSHIP_SCHEMA_DDL) — keep in sync.
--
-- The query is `relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`, which
-- SQLite already splits into one probe per side of the OR. But `relationship_type` was in
-- neither index and neither index carried the other side's id, so every row the player
-- appears in — of ANY type — cost an index read plus a table-row read, and the non-friends
-- were then discarded. Those rows accumulate: an unfriend keeps the row as type 0, ignoring
-- or muting a stranger inserts a type 0 row, pending requests are rows. And the query runs
-- on every matchmake and every disconnect (the presence push to friends), so D1 row reads
-- were roughly 2 x (all relationships) x (matchmakes).
--
-- These two indexes cover each half: seek to (player, Friend), read the other id off the
-- index, never touch the table. Reads become one per actual friend.
--
-- (target_id, relationship_type, requester_id) subsumes the old (target_id) index, so that
-- one goes. The unique (requester_id, target_id) index stays — it is the pair constraint
-- and what the single-pair lookups use.
CREATE INDEX IF NOT EXISTS idx_relationship_requester_type
ON relationship (requester_id, relationship_type, target_id);
CREATE INDEX IF NOT EXISTS idx_relationship_target_type
ON relationship (target_id, relationship_type, requester_id);
DROP INDEX IF EXISTS idx_relationship_target;
@@ -0,0 +1,32 @@
-- Restore the UNIQUE pair index on `relationship`.
--
-- Generated from packages/domain/src/relationships-db.ts (SCHEMA_DDL) — keep in sync.
-- Migration 0001 declared `idx_relationship` and 0030 counts on it staying ("it is the
-- pair constraint"), but the deployed table did not have it: an audit of sqlite_master
-- against every worker's migrations found it and the two `room_instance` unique indexes
-- (rooms 0022) missing, with every other declared index present. Every relationship
-- write is select-then-insert (`upsertPair`, `setRelationshipFlag`), so this index is the
-- only thing that stops a replayed or concurrent request from filing a pair twice —
-- which shows up as a doubled friend entry, with `findPair` reading whichever twin the
-- planner returns.
--
-- A UNIQUE index refuses to build over duplicates, so any are merged first: the oldest
-- row of each (requester_id, target_id) group survives, carrying the MAX of every field
-- across the group — for the flags that is an OR, and for `relationship_type` it is the
-- furthest-along state (3 Friend > 2 Received > 1 Sent > 0 None). Then the rest go. Both
-- statements are no-ops on a table with no duplicates.
UPDATE relationship SET
relationship_type = (SELECT MAX(r.relationship_type) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_favorited = (SELECT MAX(r.requester_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_ignored = (SELECT MAX(r.requester_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_muted = (SELECT MAX(r.requester_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_favorited = (SELECT MAX(r.target_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_ignored = (SELECT MAX(r.target_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_muted = (SELECT MAX(r.target_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id)
WHERE id IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id HAVING COUNT(*) > 1);
DELETE FROM relationship
WHERE id NOT IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id);
@@ -0,0 +1,18 @@
-- Player-made custom shirts are capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE` in
-- src/custom-avatar-items-db.ts): the create and the edit now refuse a price above it, and
-- this lowers every existing player-made row that is already over it to exactly the cap.
--
-- Only PLAYER-MADE rows are touched, told apart by `BaseAvatarItemId`: a player-made shirt
-- is built on a base item, while a first-party item (imported from the official export,
-- Coach-authored) has it NULL and is priced from the storefront dump, where several sit
-- well above 1000. Those keep their prices.
--
-- `ModifiedAt` is bumped as the edit endpoint bumps it, since the record did change.
-- `price` is a generated column off `data`, so rewriting the JSON is the whole update.
UPDATE custom_avatar_item
SET data = json_set(data,
'$.Price', 1000,
'$.ModifiedAt', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE json_extract(data, '$.BaseAvatarItemId') IS NOT NULL
AND price > 1000;
@@ -0,0 +1,11 @@
-- Inventions are capped at 1000 tokens (`MAX_INVENTION_PRICE` in src/inventions-db.ts): the
-- three writes a creator has (`v3/publish`, `v4/publish`, `v1/updateprice`) now refuse a
-- price above it, and this lowers every existing row already over it to exactly the cap.
-- Drafts included — a draft's price is what its next publish would list it at.
--
-- `ModifiedAt` is left alone, as `v1/updateprice` leaves it: a reprice is not an edit of
-- the invention. `price` is not a generated column here, so the JSON is the whole update.
UPDATE invention
SET data = json_set(data, '$.Price', 1000)
WHERE json_extract(data, '$.Price') > 1000;
+16
View File
@@ -0,0 +1,16 @@
-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as
-- the event, invention, custom-avatar-item and chat-message reports beside it do: same
-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and
-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and
-- `chat_message_id`.
--
-- The request has no body, so the row is three facts: the reporter (the caller), the image,
-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image.
-- `report_category` stays 0 and `details` NULL: the client sends neither.
--
-- NOT indexed, like the other kind columns: written on every report of this kind and read by
-- nothing. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN image_id INTEGER;
@@ -0,0 +1,18 @@
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
-- room read — asks this table for the published badges of the rooms on the page, and without an
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
-- page. At the time of writing it was half of the database's row reads.
--
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
-- a partial index only when the query's WHERE contains its term as written, and an expression
-- index only when the query names the same expression, so the query must keep BOTH
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
-- change either and the index silently stops being used. The key is an expression rather than
-- a new generated column so the query reads the same whether or not this migration has run yet
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
WHERE outfit_type = 1000;
@@ -0,0 +1,56 @@
/**
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
* `custom_avatar_item` table with them, the way `apps/rooms/migrations/0002_import_rooms.sql`
* seeds the rooms.
*
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
*
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
*
* For a load that needs no migration and no deploy — the usual case — use
* `just admin cai-load` instead, which runs the same statements straight against a database.
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
* an existing id REPLACED, nothing deleted.
*
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
* in prices from the storefront dump.
*
* The saves name assetbundles by bare filename; the migration does nothing about those files,
* which have to be served from wherever the client fetches them.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import {
customAvatarItemUpsertStatements,
readCustomAvatarItemExport,
} from '../src/custom-avatar-items-load'
const [input, number] = process.argv.slice(2)
if (!input || !number || !/^\d{4}$/.test(number)) {
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
process.exit(2)
}
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
const records = readCustomAvatarItemExport(
JSON.parse(readFileSync(input, 'utf8').replace(/^/, ''))
)
const lines = [
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
'-- Each record is the official `CustomAvatarItem`, filed under the Coach account (CreatorAccountId 1);',
'-- an existing id is replaced.',
'',
...customAvatarItemUpsertStatements(records),
]
const out = decodeURIComponent(
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
)
writeFileSync(out, lines.join('\n') + '\n')
console.log(`wrote ${records.length} records to ${out}`)
@@ -0,0 +1,83 @@
/**
* Fills in `Price` on an export of first-party custom avatar items from a storefront dump,
* rewriting the export in place so `import-custom-avatar-items.ts` / `just admin cai-load`
* carry the price into the table.
*
* bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>
*
* The export (e.g. `apps/econ/static/db/2025-1-cai.json`) mostly carries `Price: 0`. The
* storefront (e.g. `Watch_EnumValue_3.json`, a `{ StoreItems: [...] }` page) prices the same
* items, joined on `GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`.
* The price taken is the base token price (`CurrencyType` 2), ignoring any sale. The storefront
* only prices what the export already holds: nothing is added from it.
*
* An item the storefront doesn't list keeps the price it had, unless that price is 0, which
* would make it free to buy. Those are the Cryptid Creek event items and the Evergrown Hip Pack,
* all rarity-50 rewards that were never sold, so they get the catalog's rarity-50 price.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { priceForRarity } from '../../econ/src/catalog-load'
const TOKENS = 2
/** The rarity an item missing from the storefront is priced as, when it has no price. */
const UNLISTED_RARITY = 50
interface StoreItem {
GiftDrop?: { AvatarItemInfo?: { DownloadableAvatarItemId?: string | null } | null }
Prices?: Array<{ CurrencyType: number; Price: number }>
}
const [exportPath, storefrontPath] = process.argv.slice(2)
if (!exportPath || !storefrontPath) {
console.error(
'usage: bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>'
)
process.exit(2)
}
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
const items: Array<{ CustomAvatarItemId: string; Name: string; Price: number }> =
readJson(exportPath)
const storefront: { StoreItems: StoreItem[] } = readJson(storefrontPath)
const prices = new Map<string, number>()
for (const storeItem of storefront.StoreItems) {
const id = storeItem.GiftDrop?.AvatarItemInfo?.DownloadableAvatarItemId?.toLowerCase()
const price = storeItem.Prices?.find((p) => p.CurrencyType === TOKENS)?.Price
if (id && price !== undefined && !prices.has(id)) {
prices.set(id, price)
}
}
let priced = 0
let changed = 0
const unmatched: string[] = []
for (const item of items) {
let price = prices.get(item.CustomAvatarItemId.toLowerCase())
if (price === undefined) {
unmatched.push(item.Name)
if (item.Price) continue
price = priceForRarity(UNLISTED_RARITY)
}
priced++
if (item.Price !== price) {
if (item.Price) {
console.warn(`${item.Name}: ${item.Price} -> ${price}`)
}
item.Price = price
changed++
}
}
writeFileSync(exportPath, JSON.stringify(items, null, 2) + '\n')
console.log(`priced ${priced}/${items.length} items (${changed} changed) in ${exportPath}`)
if (unmatched.length) {
console.log(
`not in the storefront (kept their price, or rarity ${UNLISTED_RARITY} if free): ${unmatched.join(', ')}`
)
}
+2
View File
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes)
.route('/', socialRoutes)
.route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
+91
View File
@@ -206,3 +206,94 @@ export async function isPlayerBlocked(
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
/**
* An account a ban on someone else would also reach, and what links the two.
*
* `via` is never `'account'` here: this is the answer to "who ELSE", so the account asked
* about is excluded by the query rather than returned as a match on itself.
*/
export interface LinkedAccount {
accountId: number
username: string | null
via: Exclude<BanVia, 'account'>
/**
* What matched — the shared platform id, or the shared IP address. Shown to the
* moderator so an IP match can be judged on its face: an address that reads as a
* carrier or campus range is a different proposition from a residential one.
*/
value: string
}
/**
* Which OTHER accounts a ban on `accountId` would also block — the preview a moderator
* sees before handing one down (`www`: `GET /api/staff/players/:id/linked`).
*
* This is `resolveBan` read backwards. That function asks "does any ban reach this
* caller", driving from the few banned reports inward, because it runs on every matchmake
* and every token grant. This one asks "who would this ban reach", driving from one
* account's identities outward — a question with no hot path, asked once when a moderator
* is about to act, and the only way to see the IP arm's blast radius BEFORE it lands
* rather than from a support ticket afterwards.
*
* Honours the same `arms`, so a preview on a server running `BAN_EVASION_MATCH=platform`
* doesn't list households that would not in fact be blocked. A disabled arm contributes
* nothing rather than being listed-but-flagged: the question is who gets blocked, and the
* answer under that setting is that they don't.
*
* The username comes off the account blob, which is `auth`'s — read here for the same
* reason the resolution query reads it: the moderator needs to know WHO, and an id alone
* makes the preview unreadable. A missing blob yields null rather than dropping the row;
* the block would still reach that account.
*
* An account matched by BOTH arms appears twice, once per arm. Deliberate: the arms are
* separate evidence, and "shares a Steam identity AND an address" is worth seeing as two
* lines. Platform rows sort first, being the arm that proves more.
*/
export async function linkedAccounts(
db: D1Database,
accountId: number,
arms: BanMatchArms = DEFAULT_BAN_MATCH_ARMS
): Promise<LinkedAccount[]> {
if (!arms.ip && !arms.platform) return []
const { results } = await db
.prepare(
`WITH me AS (
SELECT
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
FROM account WHERE account_id = ?1
),
ips AS (
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
)
SELECT p.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'platform' AS via,
p.platform_id AS value
FROM platform_account p
JOIN ids ON ids.platform = p.platform AND ids.platform_id = p.platform_id
LEFT JOIN account a ON a.account_id = p.account_id
WHERE ?2 = 1 AND p.account_id <> ?1
UNION
SELECT a.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'ip' AS via,
ips.ip AS value
FROM account a, ips
WHERE ?3 = 1 AND a.account_id <> ?1
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
ORDER BY via DESC, accountId`
)
.bind(accountId, arms.platform ? 1 : 0, arms.ip ? 1 : 0)
.all<LinkedAccount>()
return results
}
+16
View File
@@ -17,6 +17,22 @@ export type Env = SharedHonoEnv & {
DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: string
/**
* The client builds `/api/versioncheck/v4` answers "current" for, as a comma-separated
* list of builds (`20230414,20250718`), or `off` for none. Unset means the default
* `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the root `.env`
* (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so `auth` refuses a token to exactly the builds
* this flags for an update. Read through `supportedGameBuilds`, which also folds in
* every build under rollout (below).
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout (`RECFLARE_ROLLOUT_GAME_VERSIONS`), same format;
* `off` ends the rollout. Unset means the default `ROLLOUT_GAME_VERSIONS` in
* @repo/domain. A rollout build is always "current" here — it is `auth` that decides who
* may sign in on it — so the version check never sends a supporter to update.
*/
ROLLOUT_GAME_VERSIONS?: string
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
DB: D1Database
+315 -123
View File
@@ -1,42 +1,96 @@
/**
* Custom avatar items — player-designed items built on a base catalog item — on the
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
* DTO, so a row maps straight onto the response.
* Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built
* on a base catalog item, and the first-party items imported from the official service, which
* are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a
* base item and a design PNG.
*
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
* those bucket keys, which the `img` worker serves back by key.
* A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the
* `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries
* filter and sort on exposed as generated (virtual) columns. So a row IS the response, an
* import is the exported record as-is, and a field the source adds later is served without a
* migration. The flat one-column-per-field table this replaced (0015) could not hold a
* first-party item at all: its base-item and filename columns were NOT NULL, and it had no home
* for the saves.
*
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
* applied under its own `migrations_table`).
* `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its
* item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id
* (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table
* because the client points at one by bare id; that does not apply here.
*
* The two uploads that accompany a player's creation (the design blob and the thumbnail PNG)
* live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; `ThumbnailImageFilename` and
* `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A
* first-party item has both null and is rendered from its saves' assetbundles instead.
*
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt
* as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`).
*/
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
data TEXT NOT NULL,
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
]
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
/**
* One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`:
* a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads
* to render the item, plus the thumbnail it shows in the store. An item carries one per
* `BodyType` it was built for; the client picks the save matching the wearer's body.
* `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as
* stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same
* save, different casing and field set; keep the two apart.
*/
export interface CustomAvatarItemSave {
CustomAvatarItemSaveId: number
CustomAvatarItemId: string
UnityAssetId: string
BodyType: number
OutfitType: number
QAState: number
CreatedAt: string
ModifiedAt: string
Description: string | null
ThumbnailFileName: string
AdditionalConfiguration: string
UnityAsset: string
UnityAssetHash: string
UnityAsset2: string | null
UnityAsset2Hash: string | null
/** Studio builds persist separate target hashes; legacy imports omit these. */
QuestUnityAssetHash?: string | null
QuestUnityAsset2Hash?: string | null
}
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
export interface CustomAvatarItemTag {
TagType: number
Value: string
}
/**
* The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are
* null on a first-party item, which has no base item or design PNG: it is rendered from
* `CurrentSaves` instead, which is empty on a player-made shirt.
*/
export interface CustomAvatarItem {
CustomAvatarItemId: string
CreatorAccountId: number
@@ -47,19 +101,52 @@ export interface CustomAvatarItem {
ForceCannotPublish: boolean
IsFeatured: boolean
IsRecRoomApproved: boolean
BaseAvatarItemId: number
BaseAvatarItemColor: string
DesignFilename: string
ThumbnailImageFilename: string
BaseAvatarItemId: number | null
BaseAvatarItemColor: string | null
DesignFilename: string | null
ThumbnailImageFilename: string | null
CreatedAt: string
ModifiedAt: string
PreviewOrientation: number
RankingContext: null
OutfitType: number
CurrentSaves: never[]
CurrentSaves: CustomAvatarItemSave[]
Tags: CustomAvatarItemTag[]
CustomBadgeMetadata: unknown
RankedEntityId: string
PurchaseInfo: null
}
/**
* `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom
* avatar item the client can make. The enum (unobfuscated in the client) is banded by body
* region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101,
* Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs
* Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search
* as a bare `IEnumerable<int>` — nothing on the wire ties 105 to the name.
*
* This is what the store's user-generated-content tab searches for, ALONE
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
* and the tab sat empty over a full catalog. The imported first-party items carry their own
* slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query.
*
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
* `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped.
*/
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
/**
* The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and
* the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the
* client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party
* items are not bound by it: their prices come off the storefront dump and several sit
* above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made
* rows, told apart by `BaseAvatarItemId`).
*/
export const MAX_PLAYER_ITEM_PRICE = 1000
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
export interface CreateCustomAvatarItemInput {
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
@@ -73,87 +160,101 @@ export interface CreateCustomAvatarItemInput {
accessibility: number
designFilename: string
thumbnailImageFilename: string
/** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */
outfitType?: number
}
interface Row {
custom_avatar_item_id: string
creator_account_id: number
name: string
description: string
price: number
accessibility: number
force_cannot_publish: number
is_featured: number
is_rec_room_approved: number
base_avatar_item_id: number
base_avatar_item_color: string
design_filename: string
thumbnail_image_filename: string
created_at: string
modified_at: string
preview_orientation: number
outfit_type: number
data: string
}
/**
* What a stored record may lack and the response must carry: an imported record predates
* some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never
* stored. Everything the record does carry wins over these.
*/
const DTO_DEFAULTS = {
CurrentSaves: [] as CustomAvatarItemSave[],
Tags: [] as CustomAvatarItemTag[],
CustomBadgeMetadata: null,
RankingContext: null,
PurchaseInfo: null,
} as const
/**
* A stored row's JSON as the client's record. Exported for the `econ` worker's owned-items
* read, which joins `inventory_custom` to this table and must serve the same shape.
*/
export function parseCustomAvatarItem(data: string): CustomAvatarItem {
const stored = JSON.parse(data) as CustomAvatarItem
return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null }
}
function toDto(row: Row): CustomAvatarItem {
return {
CustomAvatarItemId: row.custom_avatar_item_id,
CreatorAccountId: row.creator_account_id,
Name: row.name,
Description: row.description,
Price: row.price,
Accessibility: row.accessibility,
ForceCannotPublish: row.force_cannot_publish === 1,
IsFeatured: row.is_featured === 1,
IsRecRoomApproved: row.is_rec_room_approved === 1,
BaseAvatarItemId: row.base_avatar_item_id,
BaseAvatarItemColor: row.base_avatar_item_color,
DesignFilename: row.design_filename,
ThumbnailImageFilename: row.thumbnail_image_filename,
CreatedAt: row.created_at,
ModifiedAt: row.modified_at,
PreviewOrientation: row.preview_orientation,
RankingContext: null,
OutfitType: row.outfit_type,
CurrentSaves: [],
PurchaseInfo: null,
}
return parseCustomAvatarItem(row.data)
}
/** Inserts a new custom avatar item and returns it as the client's DTO. */
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
export async function createCustomAvatarItem(
db: D1Database,
input: CreateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem> {
const ts = now.toISOString()
const record: Omit<CustomAvatarItem, 'PurchaseInfo'> = {
CustomAvatarItemId: input.customAvatarItemId,
CreatorAccountId: input.creatorAccountId,
Name: input.name,
Description: input.description,
Price: input.price,
Accessibility: input.accessibility,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: input.baseAvatarItemId,
BaseAvatarItemColor: input.baseAvatarItemColor,
DesignFilename: input.designFilename,
ThumbnailImageFilename: input.thumbnailImageFilename,
CreatedAt: ts,
ModifiedAt: ts,
PreviewOrientation: 0,
OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT,
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
RankedEntityId: input.customAvatarItemId,
RankingContext: null,
}
const row = await db
.prepare(
`INSERT INTO custom_avatar_item (
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
created_at, modified_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
RETURNING *`
)
.bind(
input.customAvatarItemId,
input.creatorAccountId,
input.name,
input.description,
input.price,
input.accessibility,
input.baseAvatarItemId,
input.baseAvatarItemColor,
input.designFilename,
input.thumbnailImageFilename,
ts
)
.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data')
.bind(JSON.stringify(record))
.first<Row>()
if (!row) throw new Error('custom_avatar_item insert returned no row')
return toDto(row)
}
/**
* Stores a record as exported from the official service (the first-party items), verbatim,
* replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands.
* This is what the generated import migration does in SQL; it is here for tests and tooling.
*/
export async function importCustomAvatarItem(
db: D1Database,
record: Omit<CustomAvatarItem, 'PurchaseInfo'> & { PurchaseInfo?: unknown }
): Promise<CustomAvatarItem> {
const { PurchaseInfo: _purchaseInfo, ...stored } = record
const row = await db
.prepare(
`INSERT INTO custom_avatar_item (data) VALUES (?1)
ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data
RETURNING data`
)
.bind(JSON.stringify(stored))
.first<Row>()
if (!row) throw new Error('custom_avatar_item import returned no row')
return toDto(row)
}
/**
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
@@ -178,6 +279,8 @@ export interface UgcPurchasableItem {
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
* request — the item table has no room; what the client wants it for is still unknown.
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
* `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has
* none at the item level, so the first save's thumbnail stands in.
*/
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
return {
@@ -185,7 +288,7 @@ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPur
ItemId: item.CustomAvatarItemId,
Name: item.Name,
Description: item.Description,
ImageName: item.ThumbnailImageFilename,
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
RoomId: roomId,
Price: item.Price,
PurchaseCurrencyId: null,
@@ -200,15 +303,71 @@ export async function getCustomAvatarItems(
ids: string[]
): Promise<CustomAvatarItem[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
.bind(...ids)
.prepare(
`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (SELECT value FROM json_each(?1))`
)
.bind(JSON.stringify(ids))
.all<Row>()
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
return ids.flatMap((id) => byId.get(id) ?? [])
}
/**
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
* `/avatar/quest/<name>.assetbundle`.
*/
export const QUEST_ASSET_PREFIX = 'quest/'
/**
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
* client and is read as that. The rest of the enum has not been observed.
*/
export const UNITY_ASSET_TARGET_QUEST = 2
/**
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
* target not seen yet, or none at all — is served the PC names, which is what was served
* before targets were read.
*/
export function isQuestAssetTarget(target: string | null | undefined): boolean {
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
}
/**
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
* returned as it came.
*/
function questAsset<T extends string | null>(name: T): T | string {
if (name === null || !name.endsWith('.assetbundle')) return name
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
}
/**
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
* the bare names, which are what every other target is served. The hashes are left as
* stored. A player-made shirt has no saves and comes back unchanged.
*/
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
return {
...item,
CurrentSaves: item.CurrentSaves.map((save) => ({
...save,
UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2),
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
})),
}
}
/**
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
@@ -221,7 +380,7 @@ export async function listFeaturedCustomAvatarItems(
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
@@ -241,7 +400,7 @@ export async function listHotCustomAvatarItems(
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE accessibility != 0
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
@@ -251,9 +410,10 @@ export async function listHotCustomAvatarItems(
/**
* The "Coach" system account — this server's stock content is authored by it, the same id the
* `econ` worker attributes a self-buy or an anonymous gift to.
* `econ` worker attributes a self-buy or an anonymous gift to. Lives in the loader module so
* the CLI can name it without Workers types; re-exported here for the worker-side callers.
*/
export const COACH_ACCOUNT_ID = 1
export { COACH_ACCOUNT_ID }
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
export interface CustomAvatarItemSearch {
@@ -264,8 +424,21 @@ export interface CustomAvatarItemSearch {
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
*/
outfitTypes?: number[]
/** Whether items authored by the Coach — this server's stock content — are included. */
/**
* WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the
* Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both.
* The client browses the store's two tabs with this — its storefront call says `True`
* and its user-generated-content call says `False` — so "include" reads as a toggle
* between them, never as a superset.
*/
includeCoachItems?: boolean
/**
* Only items this account authored — how the client lists what one player has made (a
* profile's creations, `creatorAccountId=<id>&includeCoachItems=False&take=1000`). Combines
* with the rest as one more AND; naming the Coach here with `includeCoachItems=false`
* matches nothing, as the two conditions read.
*/
creatorAccountId?: number
/** Lowest price to include, inclusive. */
minPrice?: number
/** Highest price to include, inclusive. */
@@ -286,15 +459,23 @@ export const SEARCH_MAX_TAKE = 200
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
* has `fromCreator` for that).
*
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
* Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and
* would miss half of what players type. `%` and `_` in the needle are escaped, so searching for
* a literal one finds it instead of matching everything.
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring,
* or its `CustomAvatarItemId` whole. Both sides are lowered rather than relying on `LIKE`, which
* folds case for ASCII only and would miss half of what players type. `%` and `_` in the needle
* are escaped, so searching for a literal one finds it instead of matching everything.
*
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
* an absent parameter as "match nothing" would empty the store.
*
* `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's
* items alone, `false` everyone else's alone, absent both. The client's storefront tab sends
* `True` and its user-generated-content tab sends `False`, and the two must not overlap — read
* as "stock plus players'", the storefront would show every player's item too.
*
* `creatorAccountId` narrows to one author. It was ignored until it was noticed that a player's
* creations list (which sends it) was serving the whole player-made catalog under every name.
*
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
@@ -318,8 +499,13 @@ export async function searchCustomAvatarItems(
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
// `name_lower`/`description_lower` are generated columns, lowered once at the row rather
// than per query. An id typed in whole finds its item too, since the client searches by
// `CustomAvatarItemId` as well as by text.
const exact = bind(needle.toLowerCase())
where.push(
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
`(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` +
` OR lower(custom_avatar_item_id) = ${exact})`
)
}
@@ -327,9 +513,14 @@ export async function searchCustomAvatarItems(
if (outfitTypes.length > 0) {
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
}
if (search.includeCoachItems === false) {
if (search.includeCoachItems === true) {
where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`)
} else if (search.includeCoachItems === false) {
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
}
if (search.creatorAccountId !== undefined) {
where.push(`creator_account_id = ${bind(search.creatorAccountId)}`)
}
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
@@ -337,7 +528,7 @@ export async function searchCustomAvatarItems(
const offset = bind(skip)
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
ORDER BY created_at DESC, custom_avatar_item_id
LIMIT ${limit} OFFSET ${offset}`
)
@@ -360,7 +551,7 @@ export async function listCustomAvatarItemsByCreator(
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item
`SELECT data FROM custom_avatar_item
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
ORDER BY created_at DESC, custom_avatar_item_id`
)
@@ -379,8 +570,9 @@ export interface UpdateCustomAvatarItemInput {
}
/**
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
* null keep their value (the client sends every field, nulling the untouched ones).
* Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. Fields
* the caller leaves null keep their value (the client sends every field, nulling the
* untouched ones).
* Returns the updated item, or null when no row has that id.
*/
export async function updateCustomAvatarItem(
@@ -391,14 +583,14 @@ export async function updateCustomAvatarItem(
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare(
`UPDATE custom_avatar_item SET
name = COALESCE(?2, name),
description = COALESCE(?3, description),
price = COALESCE(?4, price),
accessibility = COALESCE(?5, accessibility),
modified_at = ?6
`UPDATE custom_avatar_item SET data = json_set(data,
'$.Name', coalesce(?2, json_extract(data, '$.Name')),
'$.Description', coalesce(?3, json_extract(data, '$.Description')),
'$.Price', coalesce(?4, json_extract(data, '$.Price')),
'$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')),
'$.ModifiedAt', ?6)
WHERE custom_avatar_item_id = ?1
RETURNING *`
RETURNING data`
)
.bind(
id,
@@ -418,7 +610,7 @@ export async function deleteCustomAvatarItem(
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
@@ -430,7 +622,7 @@ export async function getCustomAvatarItem(
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
+171
View File
@@ -0,0 +1,171 @@
/**
* Turning an export of custom avatar items into `custom_avatar_item` rows — the loader half of
* the table, used by `runx admin cai-load` (in @repo/tools) and by the migration generator
* (scripts/import-custom-avatar-items.ts) rather than by the worker.
*
* Separate from `custom-avatar-items-db.ts` for the reason `catalog-load.ts` is separate from
* `catalog-db.ts` in econ: that module types its queries with `D1Database`, a Workers type,
* and the loader runs in a plain Node CLI that has no such types. Everything here is pure data
* mapping with no imports, so both sides can use it.
*
* A row is the record as JSON (see 0022_custom_avatar_item_json.sql), so a load is the export
* verbatim: no per-field mapping, and a field the source adds later rides along. Three things
* change on the way in: `PurchaseInfo`, a store-side projection the worker fills at read time,
* is stripped; `CreatorAccountId` is FORCED to the Coach account; and each save's
* `ThumbnailFileName` is put under the `avatar/` prefix — see {@link customAvatarItemRowLiteral}.
*/
/**
* The "Coach" system account — this server's stock content is authored by it, the same id the
* `econ` worker attributes a self-buy or an anonymous gift to. Defined here rather than in
* `custom-avatar-items-db.ts` (which re-exports it) so the loader can name it without pulling
* in Workers types.
*/
export const COACH_ACCOUNT_ID = 1
/**
* Where a save's thumbnail is served from. The export names it bare
* (`f2y1ndzuvm5ke2hjmn4cwfwfl.png`); this server keeps those images under `avatar/` in the
* image bucket, so the name is prefixed on the way in and the client asks for the right key.
*/
export const SAVE_THUMBNAIL_PREFIX = 'avatar/'
/** The least an export record must carry to be a row. Everything else is kept as-is. */
export interface CustomAvatarItemExportRecord {
CustomAvatarItemId: string
PurchaseInfo?: unknown
[key: string]: unknown
}
/**
* The records in an export, whether it is a bare JSON array, a `{ Results: [...] }` page, or a
* single record. Refuses a record without an id and a repeated id — a repeat in one upsert
* statement is an error at the database, and a repeat across statements would silently keep
* whichever came last, which is a defect in the export rather than a choice to make for it.
*/
export function readCustomAvatarItemExport(parsed: unknown): CustomAvatarItemExportRecord[] {
const records: unknown[] = Array.isArray(parsed)
? parsed
: parsed &&
typeof parsed === 'object' &&
Array.isArray((parsed as { Results?: unknown }).Results)
? (parsed as { Results: unknown[] }).Results
: [parsed]
const seen = new Set<string>()
return records.map((record, i) => {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
throw new Error(`record ${i} is not an object`)
}
const id = (record as { CustomAvatarItemId?: unknown }).CustomAvatarItemId
if (typeof id !== 'string' || id === '') {
throw new Error(`record ${i} has no CustomAvatarItemId`)
}
if (seen.has(id)) throw new Error(`duplicate CustomAvatarItemId in export: ${id}`)
seen.add(id)
return record as CustomAvatarItemExportRecord
})
}
/**
* A save's `ThumbnailFileName` under {@link SAVE_THUMBNAIL_PREFIX}. Idempotent — a name that
* already carries the prefix is left alone — so loading a file that was itself dumped from
* this table does not stack a second `avatar/`. Anything that is not a non-empty string
* (null, absent) is returned as it came.
*/
export function prefixSaveThumbnail(name: unknown): unknown {
if (typeof name !== 'string' || name === '') return name
return name.startsWith(SAVE_THUMBNAIL_PREFIX) ? name : SAVE_THUMBNAIL_PREFIX + name
}
/**
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
* emptied by hand; given `""` it skips the check. A hash that is already null — the
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
*/
export function blankAssetHash(hash: unknown): unknown {
return typeof hash === 'string' ? '' : hash
}
/**
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
* three things rewritten.
*
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
* imported item is first-party content by definition, and the creator id is what makes it so
* here: the storefront tab finds stock items by `creator_account_id = 1`
* (`includeCoachItems=True`), and a sale pays the creator. An export from another server
* carries THAT server's creator ids — an item filed under one of those would vanish from the
* storefront into the user-generated tab, and its price would be paid to whichever local
* account happened to share the number.
*
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
* untouched.
*/
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
const { PurchaseInfo: _purchaseInfo, ...stored } = record
const saves = Array.isArray(stored.CurrentSaves)
? stored.CurrentSaves.map((save: unknown) => {
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
const fields = save as Record<string, unknown>
return {
...fields,
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
// Only where the export carries the key, so a save is never GIVEN a field.
...('UnityAssetHash' in fields && {
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
}),
...('UnityAsset2Hash' in fields && {
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
}),
}
})
: stored.CurrentSaves
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
}
/**
* The largest statement a load builds, in bytes of SQL. D1 caps a single statement at 100 KB,
* and a record with three built saves runs to a few KB, so rows are grouped by size rather
* than by count — a fixed count would fit the small records and overflow on the large ones.
*/
export const CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES = 64 * 1024
/**
* The statements that upsert `records` into `custom_avatar_item`, each a multi-row INSERT
* within {@link CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES}. An id already in the table has its
* row REPLACED (`ON CONFLICT ... DO UPDATE`), so a re-run with a corrected export lands and a
* re-run with the same one changes nothing. Nothing is deleted: rows the export does not
* mention — the players' own shirts among them — are left alone.
*/
export function customAvatarItemUpsertStatements(
records: CustomAvatarItemExportRecord[],
maxBytes = CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES
): string[] {
const head = 'INSERT INTO custom_avatar_item (data) VALUES\n'
const tail = '\nON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;'
const statements: string[] = []
let rows: string[] = []
let bytes = 0
const flush = (): void => {
if (rows.length === 0) return
statements.push(head + rows.map((r) => `\t(${r})`).join(',\n') + tail)
rows = []
bytes = 0
}
for (const record of records) {
const literal = customAvatarItemRowLiteral(record)
// A single oversized record still gets its own statement rather than being dropped.
if (rows.length > 0 && bytes + literal.length > maxBytes) flush()
rows.push(literal)
bytes += literal.length + 4
}
flush()
return statements
}
+197 -49
View File
@@ -18,6 +18,7 @@
*/
import {
Accessibility,
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_DURATION_MS,
@@ -25,8 +26,8 @@ import {
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
* seed rows).
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql +
* 0010_event_tag.sql + 0028_event_id_seq.sql, sans any seed rows).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS event (
@@ -58,6 +59,12 @@ export const SCHEMA_DDL: string[] = [
PRIMARY KEY (event_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
// The id sequence: AUTOINCREMENT so an id is never handed out twice, whatever gets
// deleted. See {@link nextEventId}. The migration also seeds it past the existing
// events; on a fresh database there are none, so the seed is omitted here.
`CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
)`,
]
/**
@@ -67,19 +74,29 @@ export const SCHEMA_DDL: string[] = [
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
* declining is recorded rather than deleted so the client can show the player their own
* answer (and so changing your mind is an update, not an insert).
*
* `pending` is not an answer: it's the row a bulk invite writes for the invitee, before
* they've said anything. It's what the client shows as an open invitation — an invite
* that landed as Going looked like the player had already accepted. Only the server
* writes it; `respond` won't take it as a `Type`.
*/
export const EVENT_RESPONSE = {
going: 0,
interested: 1,
cantGo: 2,
pending: 3,
} as const
/** The response types, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
/** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */
const EVENT_ANSWER_VALUES: number[] = [
EVENT_RESPONSE.going,
EVENT_RESPONSE.interested,
EVENT_RESPONSE.cantGo,
]
/** Whether a number is one of the three response types. */
/** Whether a number is one of the three answers a player can give (never `pending`). */
export function isEventResponseType(value: number): boolean {
return EVENT_RESPONSE_VALUES.includes(value)
return EVENT_ANSWER_VALUES.includes(value)
}
/**
@@ -174,9 +191,9 @@ interface EventRow {
}
/**
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
* tag, and the client parses each in exactly one place.
* A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase
* form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves —
* three casings of one tag, and the client parses each in exactly one place.
*/
export interface PlayerEventEnvelopeTag {
Tag: string
@@ -190,9 +207,11 @@ export interface PlayerEventEnvelopeTag {
*
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
* one to the other build leaves the event's tag chips empty — the decoder drops what it
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
* (20230414 and older) parses `["celebration"]` and anything newer parses
* `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips
* empty — the decoder drops what it can't read rather than erroring.
* {@link toEventResult} picks; nothing else should. `TagModifyResult.Tags` is NOT part of
* this: it is a `List<string>` to every build.
*/
export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[] | PlayerEventEnvelopeTag[]
@@ -218,17 +237,18 @@ export interface PlayerEventResult {
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for.
*
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
* `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414
* when set, the 2023 build's bare names when not. It changes nothing else —
* `TagModifyResult.Tags` is a name list to both builds.
*/
export function toEventResult(
event: PlayerEvent,
tags: EventTag[] = [],
legacyTags = false
pairedTags = false
): PlayerEventResult {
const names = tags.map((t) => t.tag)
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
const carried = pairedTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return {
PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0,
@@ -620,14 +640,11 @@ export async function createEvent(
creatorPlayerId: number,
input: EventInput
): Promise<PlayerEvent> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
.first<{ next: number }>()
const id = await nextEventId(db)
const now = Date.now()
const startTime = input.startTime ?? eventTime(now)
const event: PlayerEvent = {
PlayerEventId: row?.next ?? 1,
PlayerEventId: id,
CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0,
@@ -665,46 +682,51 @@ export async function createEvent(
* per player per event, so changing your mind is an update rather than a second RSVP.
* The event's `AttendeeCount` is recomputed from the table afterwards.
*
* Returns the updated event, or null when there's no such event. Anyone who can see an
* event may respond to it, the creator included (they're already Going from create, and
* nothing stops them declining their own event).
* Returns the updated event and the player's response row as it now stands (the route
* pushes that row on the `PlayerEventResponseChanged` frame), or null when there's no
* such event. Anyone who can see an event may respond to it, the creator included
* (they're already Going from create, and nothing stops them declining their own event).
*/
export async function setEventResponse(
db: D1Database,
eventId: number,
playerId: number,
status: number
): Promise<PlayerEvent | null> {
): Promise<{ event: PlayerEvent; response: EventAttendeeRow } | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
await db
const response = await db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4
RETURNING rowid AS id, *`
)
.bind(eventId, playerId, status, eventTime(Date.now()))
.run()
.first<EventAttendeeRow>()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return updated
// An upsert with RETURNING always yields the row it inserted or updated.
return { event: updated, response: response! }
}
/**
* Add invited players to an event as Going — the bulk invite. Returns the updated
* Add invited players to an event as Pending — the bulk invite. Returns the updated
* event (with its recounted `AttendeeCount`) and the rows actually created, or null
* when there's no such event.
*
* An invite only ever INSERTS: a player who already has a row keeps the answer they
* gave, so being invited can't flip a decline back to Going, and re-inviting the same
* player is a no-op rather than a reset. Since the rows land as Going, the invited
* count toward `AttendeeCount` from the moment they're invited — see the route.
* gave, so being invited can't reset an answer to Pending, and re-inviting the same
* player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't
* accepted anything yet, so they don't count toward `AttendeeCount` until they answer
* through `respond` — see the route.
*
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
* inserts nothing and returns nothing. That's what the route notifies on — a player
* whose existing answer was left alone gets no frame, because nothing changed for them.
* inserts nothing and returns nothing. That's who the route sends the invitation Message
* to — a player whose existing answer was left alone gets nothing, because nothing
* changed for them.
*
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
* still returns the event.
@@ -728,7 +750,7 @@ export async function inviteToEvent(
ON CONFLICT (event_id, player_id) DO NOTHING
RETURNING rowid AS id, *`
)
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
.bind(eventId, playerId, EVENT_RESPONSE.pending, at)
)
)
const added = inserts.flatMap((r) => r.results)
@@ -738,6 +760,22 @@ export async function inviteToEvent(
return { event: updated, added }
}
/**
* The players who said they're GOING to an event — who the `PlayerEventUpdated` and
* `PlayerEventDeleted` frames go to. Interested, declined and Pending rows are left out:
* an invitee who hasn't accepted isn't attending, so nothing about the event has changed
* for them. The creator is included through their own Going row from create.
*/
export async function getGoingPlayerIds(db: D1Database, eventId: number): Promise<number[]> {
const { results } = await db
.prepare(
'SELECT player_id FROM event_attendee WHERE event_id = ?1 AND status = ?2 ORDER BY player_id'
)
.bind(eventId, EVENT_RESPONSE.going)
.all<{ player_id: number }>()
return results.map((r) => r.player_id)
}
/** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
const row = await db
@@ -829,12 +867,31 @@ export async function updateEvent(
return updated
}
/**
* Draw the next event id. Sequential and NEVER reused: the id comes from the
* `event_id_seq` AUTOINCREMENT counter, which only ever goes up, rather than from
* `MAX(id) + 1` over the events themselves — that handed the newest event's id straight
* back out once it was deleted, so a replacement event wore the old one's id and
* inherited whatever was still filed under it (an invitation message's `PlayerEventId`,
* the client's cache). The drawn row is thrown away in the same batch; only the counter
* matters, and SQLite keeps that in `sqlite_sequence` regardless of the rows.
*/
async function nextEventId(db: D1Database): Promise<number> {
const [drawn] = await db.batch<{ id: number }>([
db.prepare('INSERT INTO event_id_seq DEFAULT VALUES RETURNING id'),
db.prepare('DELETE FROM event_id_seq'),
])
const id = drawn.results[0]?.id
if (id === undefined) throw new Error('event_id_seq returned no id')
return id
}
/**
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
* attendee counts and the `#tag` search would still find. Event ids are assigned in
* sequence and never reused, but orphan rows would still be counted against whatever id
* they name.
* attendee counts and the `#tag` search would still find. Event ids are drawn from a
* sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is
* retired with it — but orphan rows would still be counted against whatever id they name.
*
* Answers the event as it was, so the caller can report what it deleted; `null` when there
* was no such event.
@@ -869,10 +926,9 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
.bind(...ids)
.prepare(`SELECT data FROM event WHERE id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(ids))
.all<EventRow>()
const byId = new Map<number, PlayerEvent>()
for (const r of results) {
@@ -897,6 +953,72 @@ export async function getEventsByCreator(
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* One entry of a profile's `Responses` — the event beside the player's RSVP to it. The
* event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record.
*/
export interface PlayerEventWithResponse {
PlayerEvent: PlayerEventBase
PlayerEventResponse: PlayerEventResponse
}
/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */
export interface PlayerPublicEvents {
Created: PlayerEventBase[]
Responses: PlayerEventWithResponse[]
}
/**
* A player's PUBLIC events, as their profile shows them to anyone: the ones they created,
* and the ones they are attending — answered Going; a maybe, a decline and an unanswered
* invitation are nobody else's business — each soonest first. An event's creator holds a
* Going row for it, so their own events appear under both keys.
*
* Only `Accessibility` Public events are served, and FINISHED ones are left out, as the
* browse feed and the room shelf do: this is what the player is going to, not a history.
*/
export async function getPublicEventsByPlayer(
db: D1Database,
playerId: number,
now = Date.now()
): Promise<PlayerPublicEvents> {
const at = eventTime(now)
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
db
.prepare(
`SELECT data FROM event
WHERE creator_player_id = ?1 AND end_time >= ?2
AND json_extract(data, '$.Accessibility') = ?3`
)
.bind(playerId, at, Accessibility.Public),
db
.prepare(
`SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at
FROM event_attendee a
JOIN event e ON e.id = a.event_id
WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3
AND json_extract(e.data, '$.Accessibility') = ?4`
)
.bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public),
])
return {
Created: created.results
.map((r) => JSON.parse(r.data) as PlayerEvent)
.sort(bySoonest)
.map(toEventBase),
Responses: responses.results
.map((r) => ({
event: JSON.parse(r.data) as PlayerEvent,
response: toEventResponse(r as EventAttendeeRow),
}))
.sort((a, b) => bySoonest(a.event, b.event))
.map(({ event, response }) => ({
PlayerEvent: toEventBase(event),
PlayerEventResponse: response,
})),
}
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
@@ -904,10 +1026,9 @@ export async function getEventsByCreator(
*/
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
if (clubIds.length === 0) return []
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
.bind(...clubIds)
.prepare(`SELECT data FROM event WHERE club_id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(clubIds))
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
@@ -956,9 +1077,35 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Best attended first — the count of players who answered Going (`AttendeeCount`, the
* number the client shows), which is exactly what the sort is meant to rank. Interested
* and Can't-go don't count, as they don't toward the displayed count either. Ties fall
* back to soonest first so the order stays stable across pages.
*/
function byAttendance(a: PlayerEvent, b: PlayerEvent): number {
return b.AttendeeCount - a.AttendeeCount || bySoonest(a, b)
}
/**
* The orders the search serves, by the `sort` the client sends: `StartTime` (soonest
* first, the default) and `Attendance` (most Going replies first).
*/
export type EventSort = 'StartTime' | 'Attendance'
/**
* Reads the `sort` query into an {@link EventSort}, case-insensitively. Anything
* unrecognised — or nothing at all — is the default order, soonest first, rather than
* a 400: the browse screen has always sent this param and been served regardless.
*/
export function parseEventSort(raw: string | undefined): EventSort {
return raw?.toLowerCase() === 'attendance' ? 'Attendance' : 'StartTime'
}
/**
* Event search — the browse query on the player-events screen. Term by term, an empty
* query browsing everything upcoming; paginated via skip/take, soonest first.
* query browsing everything upcoming; paginated via skip/take, in the order `sort`
* names ({@link EventSort}): soonest first by default, or most Going replies first.
*
* A term is matched one of two ways, and the `#` decides which:
*
@@ -979,7 +1126,8 @@ export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number
take: number,
sort: EventSort = 'StartTime'
): Promise<PlayerEvent[]> {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
@@ -1011,5 +1159,5 @@ export async function searchEvents(
)
}
return events.sort(bySoonest).slice(skip, skip + take)
return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take)
}
+159 -28
View File
@@ -18,14 +18,19 @@
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
import {
getAccountByUsername,
getInventionAcquisitionCounts,
getOwnedInventionIds,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
* feed's query and `is_published`/`hide_from_player` most of the "is this in the store"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones);
* json_extract of a JSON `true` is 1, so those columns are 1/0 — and NULL when the key is
* missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
@@ -39,6 +44,13 @@ export const SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
// Backs `searchInventions`' newest-first paging (migrations/0027). Its WHERE must match
// `VISIBLE_IN_FEEDS` term for term or SQLite won't use it.
`CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
WHERE is_published = 1
AND hide_from_player = 0
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2`,
`CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
@@ -314,11 +326,20 @@ export function toInventionV9(invention: SavedInvention): InventionV9Dto {
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
// A record saved through `v6/save` stores no `UgcVersion`, and the client's byte
// defaults to 0 — a generation no client writes (every `v9/save` sends 1). The 2025
// client lists nothing that declares 0: these were on the wire and invisible.
UgcVersion: invention.UgcVersion ?? 1,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
// A published invention cannot be private. Everything published through
// `v3/publish` still stores the 0 a save mints (see INVENTION_ACCESSIBILITY), which
// this DTO — it has no `IsPublished` — would read as Private; serve it as Public.
Accessibility:
invention.IsPublished && invention.Accessibility === INVENTION_ACCESSIBILITY.private
? INVENTION_ACCESSIBILITY.public
: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
@@ -575,8 +596,19 @@ export async function getInventionsByCreator(
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*
* Each row is the stored record WITH the client's `RRInvention` laid over it
* ({@link toInventionV9}). The 2025 client decodes this list into that flat DTO, and a
* key the record lacks defaults silently: no `LatestVersionNumber` reads as a current
* version newer than the latest, no `UgcVersion` as generation 0 whatever was saved, and
* a published record's stored `Accessibility` 0 as Private. The stored keys that DTO has
* no member for (`CurrentVersion`, `IsPublished`, `Tags`, …) stay on the row: its decoder
* drops them, and the older client still reads them.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
export async function getMyInventions(
db: D1Database,
playerId: number
): Promise<ListedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
@@ -585,9 +617,51 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
return [...byId.values()]
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.map(withClientInvention)
}
/** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */
type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
function withClientInvention(invention: SavedInvention): ListedInvention {
return { ...invention, ...toInventionV9(invention) }
}
/** The most creators one `v1/fromcreators` call may name — D1 caps a statement's binds. */
const MAX_PORTFOLIO_CREATORS = 50
/**
* A creator's portfolio (`v1/fromcreators?id=…`) — the shelf on their profile, and the
* "from creators you follow" row. Newest first, paged in SQL via skip/take.
*
* Only PUBLISHED inventions (minus hidden ones), whoever asks — the creator included. A
* creator's drafts are on `v2/mine` and nowhere else; this is what they MADE and put out,
* not what they bought or are still working on. Unlisted ones ARE here: unlisted means
* "not in the store", and a portfolio is not the store.
*/
export async function getInventionsByCreators(
db: D1Database,
creatorPlayerIds: number[],
skip: number,
take: number
): Promise<ListedInvention[]> {
const creators = [...new Set(creatorPlayerIds)].slice(0, MAX_PORTFOLIO_CREATORS)
const limit = Math.max(take, 0)
if (creators.length === 0 || limit === 0) return []
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE creator_player_id IN (${creators.map((_, i) => `?${i + 3}`).join(', ')})
AND is_published = 1 AND hide_from_player = 0
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ?1 OFFSET ?2`
)
.bind(limit, Math.max(skip, 0), ...creators)
.all<InventionRow>()
return results.map((r) => withClientInvention(JSON.parse(r.data) as SavedInvention))
}
/**
@@ -657,6 +731,14 @@ export async function ownsAllInventions(
* and a tag appears in no name or description, so those searches find nothing. Matching tags
* needs them out of the JSON blob and into something indexable first; until then this stays a
* text search rather than one that scans every row to look at its tags.
*
* A value starting with `@` IS special: `@djdevin` is a search for that player's inventions —
* the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by
* username, case-insensitively, through the indexed `username_lower`. Any terms after the
* `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody
* has matches nothing rather than falling back to a text search for "@djdevin": a player who
* typed an `@` meant a person, and a list of inventions whose descriptions mention them is not
* what they asked for.
*/
export async function searchInventions(
db: D1Database,
@@ -673,11 +755,20 @@ export async function searchInventions(
/** 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)}`
for (const term of value
const terms = value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)) {
.filter(Boolean)
if (terms[0]?.startsWith('@')) {
const username = terms.shift()!.slice(1)
const creator = username ? await getAccountByUsername(db, username) : null
if (!creator) return []
where.push(`creator_player_id = ${bind(creator.accountId)}`)
}
for (const term of terms) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
@@ -851,17 +942,20 @@ export const INVENTION_PERMISSION = {
} 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.
* Whether a PUBLISHED invention is in the store, which `v4/publish` sets and nothing before
* it did. The publish sheet sends one of two values, and both publish:
*
* 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.
* - `public` (1) — published and in the store: search and the browse feeds list it.
* - `unlisted` (2) — published but NOT in the store. It is still published, so it is on the
* creator's portfolio (`v1/fromcreators`) and reachable by id; it just isn't for browsing.
*
* 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.
* `private` (0) is what a save mints and what `v2/unpublish` puts back — the state of an
* invention that isn't published at all. Whether anyone else can see an invention is
* `IsPublished`; this field only decides, of the published ones, which the store shows.
*
* Every record published through the older `v3/publish` also still carries 0, since that
* endpoint names no accessibility. So the store filter excludes `unlisted` by name rather
* than requiring `public` — the latter would read all of those as private and drop them.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
@@ -870,9 +964,12 @@ export const INVENTION_ACCESSIBILITY = {
} 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.
* The "is this in the store" test search and the browse feeds share: published, not hidden
* by moderation, and not unlisted. A creator's portfolio deliberately does NOT apply the
* last term (see `getInventionsByCreators`), nor do the by-id reads.
*
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
*/
const VISIBLE_IN_FEEDS = [
'is_published = 1',
@@ -982,9 +1079,42 @@ export async function publishInvention(
return updated
}
/**
* Unpublish an invention (`v2/unpublish`) — back to what a save mints: not published, and
* `Accessibility` private, so the next publish sets both afresh. Buyers keep it (`v2/mine`
* lists a bought invention whatever its state), and `FirstPublishedAt` stays: it dates the
* FIRST publish, which still happened. The permission and price are left for the next
* publish to restate or keep. Returns the updated invention, or null when there's no such row.
*/
export async function unpublishInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
IsPublished: false,
Accessibility: INVENTION_ACCESSIBILITY.private,
}
await writeInvention(db, updated)
return updated
}
/**
* The most tokens an invention may sell for, inclusive. Enforced on every price a player
* can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the
* client has no ceiling of its own. The same number as the custom-shirt cap
* (`MAX_PLAYER_ITEM_PRICE`), kept separate because they are separate policies that happen
* to agree. migrations/0033_invention_price_cap.sql lowered the rows already over it.
*/
export const MAX_INVENTION_PRICE = 1000
/**
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
* null when there's no such row; the caller rejects negative prices.
* null when there's no such row; the caller rejects negative prices and ones over
* {@link MAX_INVENTION_PRICE}.
*/
export async function setInventionPrice(
db: D1Database,
@@ -1148,10 +1278,11 @@ export async function getInventionsByIds(
inventionIds: number[]
): Promise<SavedInvention[]> {
if (inventionIds.length === 0) return []
const placeholders = inventionIds.map((_, i) => `?${i + 1}`).join(', ')
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100
// bound variables, and the top-today window or a big collection easily passes that.
const { results } = await db
.prepare(`SELECT data FROM invention WHERE id IN (${placeholders})`)
.bind(...inventionIds)
.prepare(`SELECT data FROM invention WHERE id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(inventionIds))
.all<InventionRow>()
const byId = new Map<number, SavedInvention>()
+238 -51
View File
@@ -143,7 +143,7 @@ export const BacktraceConfig = z.object({
SampleRate: z.int(),
LogLineCount: z.int(),
CaptureNativeCrashes: z.int(),
AMRThresholdMS: z.int(),
ANRThresholdMs: z.int(),
MessageCount: z.int(),
MessageRegex: z.string(),
VersionRegex: z.string(),
@@ -310,6 +310,40 @@ export const ProgressionDto = z.object({
XP: z.int(),
})
/**
* The caller's progress through one progression event (`/api/progressionEvents/record/:id`).
* A stub for now: every counter is 0 and nothing is collected.
*/
export const ProgressionEventRecordDto = z.object({
AccountId: z.int().describe('The caller'),
ProgressionEventId: z.int().describe('The event requested'),
Xp: z.int(),
GameMinutesToday: z.int(),
RewardsCollected: z.int(),
BonusRewardsCollected: z.int(),
XpBoostLastPurchasedAt: z.string().nullable().describe('ISO-8601 UTC; null when never bought'),
PremiumRewardsCollected: z.int(),
LimitedRewardCollectionState: z.int(),
})
/**
* One XP boost a player bought and has running, as `GET /api/purchasableXpBoosts/activations`
* lists it. Documented from the reference shape; nothing here sells or stores boosts yet.
*/
export const XpBoostActivationDto = z.object({
PurchasableXpBoostLedgerEntryId: z.int().describe('The purchase this activation came from'),
PurchasableXpBoostId: z.string().describe('GUID of the boost product bought'),
XpMultiplier: z.number().describe('XP is multiplied by this while the boost runs, e.g. 2.0'),
StartTime: z.string().describe('ISO-8601 UTC'),
EndTime: z.string().describe('ISO-8601 UTC'),
})
/** `GET /api/purchasableXpBoosts/activations` — a paged list of the caller's active boosts. */
export const XpBoostActivationsResponse = z.object({
Results: XpBoostActivationDto.array(),
TotalResults: z.int().describe('The number of entries in `Results`'),
})
/**
* The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
* counter the cheer increments: `Anonymous` is spent on the notification it triggers and
@@ -361,7 +395,11 @@ export const CheerPlayerResponse = z.object({
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z.number().int().optional(),
Price: z
.number()
.int()
.optional()
.describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE); 0 when left out'),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
@@ -374,7 +412,36 @@ export const CreateCustomAvatarItemRequest = z.object({
design: z.string().describe('The design blob (binary file part)'),
})
/** The client's `CustomAvatarItem` record. */
/**
* One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the
* Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and
* the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string.
* Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save,
* different casing and field set.
*/
export const CustomAvatarItemCurrentSave = z.object({
CustomAvatarItemSaveId: z.int(),
CustomAvatarItemId: z.string(),
UnityAssetId: z.string(),
BodyType: z.int(),
OutfitType: z.int(),
QAState: z.int(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
Description: z.string().nullable(),
ThumbnailFileName: z.string(),
AdditionalConfiguration: z.string().describe('JSON-in-a-string'),
UnityAsset: z.string(),
UnityAssetHash: z.string(),
UnityAsset2: z.string().nullable(),
UnityAsset2Hash: z.string().nullable(),
})
/**
* The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made
* shirt has a base item and a design PNG and no saves; a first-party item has the four
* base/filename fields null and is rendered from `CurrentSaves` instead.
*/
export const CustomAvatarItemDto = z.object({
CustomAvatarItemId: z.string(),
CreatorAccountId: z.number().int(),
@@ -385,16 +452,19 @@ export const CustomAvatarItemDto = z.object({
ForceCannotPublish: z.boolean(),
IsFeatured: z.boolean(),
IsRecRoomApproved: z.boolean(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string(),
DesignFilename: z.string(),
ThumbnailImageFilename: z.string(),
BaseAvatarItemId: z.number().int().nullable(),
BaseAvatarItemColor: z.string().nullable(),
DesignFilename: z.string().nullable(),
ThumbnailImageFilename: z.string().nullable(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
PreviewOrientation: z.number().int(),
RankingContext: z.null(),
OutfitType: z.number().int(),
CurrentSaves: z.array(z.unknown()),
CurrentSaves: z.array(CustomAvatarItemCurrentSave),
Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })),
CustomBadgeMetadata: z.unknown().nullable(),
RankedEntityId: z.string(),
PurchaseInfo: z.null(),
})
@@ -402,7 +472,7 @@ export const CustomAvatarItemDto = z.object({
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional(),
Price: z.number().int().nullable().optional().describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE)'),
Accessibility: z.number().int().nullable().optional(),
})
@@ -531,7 +601,9 @@ export const InventionV9Dto = z.object({
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
UgcVersion: z
.int()
.describe('The UGC format the blob was written in; 1 when the save named none'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
@@ -668,6 +740,43 @@ export const UpdateInventionMetadataRequest = z.object({
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
})
/**
* `POST /api/inventions/v2/update` JSON body — the newer client's counterpart to
* `v1/update`'s query string. PascalCase, and nullable the way `v2/metadata`'s is: a null
* field keeps what the invention already has, so this is a patch rather than a replace.
*
* Only `InventionId` and `Permission` are confirmed from a live capture (the permission
* picker sends exactly those two). The rest are accepted defensively — they are the fields
* `v1/update` took that `v2/metadata` does not — so that a build sending one gets it
* applied instead of silently dropped.
*/
export const UpdateInventionRequest = 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. Unlike `v4/publish`, null LEAVES IT ALONE rather than ' +
'falling back to UseOnly — an edit that names no permission is not a demotion'
),
AllowTrial: z
.boolean()
.nullable()
.optional()
.describe('Whether other players may trial it; null leaves it alone'),
Name: z
.string()
.nullable()
.optional()
.describe('3–24 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'),
})
/**
* `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.
@@ -687,12 +796,22 @@ export const PublishInventionRequest = z.object({
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
.describe(
'Public 1 publishes it into the store; Unlisted 2 publishes it but keeps it out of search and the feeds'
),
Price: z
.int()
.nullable()
.optional()
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
.describe(
'Price in tokens, at most 1000 (MAX_INVENTION_PRICE); null leaves it as it is, a ' +
'negative one is ignored, and one over the cap refuses the publish'
),
})
/** `POST /api/inventions/v2/unpublish` JSON body — the id and nothing else. */
export const UnpublishInventionRequest = z.object({
InventionId: z.int().describe('The invention to unpublish; the caller must have created it'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
@@ -716,7 +835,7 @@ export const InventionDeleteResult = z.object({
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
Price: z.int().describe('Must be >= 0'),
Price: z.int().describe('Tokens, 0–1000 (MAX_INVENTION_PRICE)'),
})
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
@@ -779,7 +898,7 @@ export const GeneratedGift = z.object({
ConsumableItemDesc: z.string(),
AvatarItemDesc: z.string(),
FriendlyName: z.string(),
AvatarItemType: z.int(),
AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'),
EquipmentPrefabName: z.string(),
EquipmentModificationGuid: z.string(),
CurrencyType: z.int(),
@@ -810,6 +929,13 @@ export const BulkCustomAvatarItemsRequest = z.object({
customAvatarItemIds: z
.array(z.string())
.describe('The ids to resolve; repeat the field once per id'),
unityAssetTarget: z
.int()
.optional()
.describe(
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
),
})
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
@@ -985,6 +1111,14 @@ export const SanitizeRequest = z.object({
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** Studio sends repeated Values form fields and consumes this result envelope. */
export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) })
export const BulkAllowedResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(),
})
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
@@ -1074,15 +1208,16 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
* image.
*
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
* is the lowercase `{ tag, type }` the v1 read serves.
* minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and
* that build, every older one, and any caller whose token names no build get the tag
* NAMES. Neither is the lowercase `{ tag, type }` the v1 read serves. `TagModifyResult.Tags`
* is a name list to every build.
*/
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
.describe(
'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
'The event’s tags: `{ Tag, Type }` pairs for a build newer than 20230414, names for ' +
'that build and older'
),
})
@@ -1100,7 +1235,7 @@ export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z.object({
Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('The tags the event now carries'),
Tags: z.array(z.string()).describe('The tags the event now carries — names to every build'),
}),
})
@@ -1188,7 +1323,9 @@ export const PlayerEventResponseDto = z.object({
'When the answer that stands was given — a changed answer updates the row, so this ' +
'moves with it rather than recording the player’s first response'
),
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
Type: z
.int()
.describe('0 Going, 1 Interested, 2 Can’t go, 3 Pending (invited, not yet answered)'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
@@ -1261,6 +1398,19 @@ export const PlayerEventsAll = z.object({
),
})
/**
* `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry
* the 17-key base event; a `Responses` entry pairs it with the player's RSVP.
*/
export const PlayerPublicEvents = z.object({
Created: z.array(PlayerEventBaseDto).describe('Public events the player created'),
Responses: z
.array(
z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto })
)
.describe('Public events the player is attending, each beside their Going RSVP'),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
ContinuationToken: z.string().describe('Empty = no next page'),
@@ -1284,8 +1434,9 @@ export const VoteToKickReason = z.object({
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `Duration` as the seconds LEFT as of the request (`ban_expires` minus now; int32 max for
* a permanent ban) — the client counts it down from receipt, so it is never the full span.
* `TimeoutStartedAt` stays null for a ban. Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
@@ -1305,7 +1456,7 @@ export const ModerationBlockDetails = z.object({
Duration: z
.int()
.describe(
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
'Seconds LEFT on the block as of this request (the client counts it down from receipt); 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
GameSessionId: z.int(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
@@ -1325,7 +1476,7 @@ export const ModerationBlockDetails = z.object({
.string()
.nullable()
.describe(
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
'Always null — the client does not pair it with `Duration` for a ban (it names the start of a voice-chat timeout, which this server never hands out)'
),
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
@@ -1363,6 +1514,27 @@ export const CreateReportRequest = z.object({
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/chatreport/createChatReport` form body — a report against one chat message.
* Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the
* reporter (the bearer token) nor the reported player (the message's sender) is in the body.
*/
export const CreateChatReportRequest = z.object({
ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'),
ChatThreadId: z
.string()
.optional()
.describe('The thread the message is in. Accepted and unused — the message names its own'),
ReportCategory: z
.string()
.optional()
.describe(
'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' +
'player report sends a number. Mapped onto the numeric category the table stores'
),
ReportDescription: z.string().optional().describe('The free-text description the reporter typed'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
@@ -1388,12 +1560,18 @@ export const CreateWarningRequest = z.object({
* 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 caller’s own vote, e.g. `True`'),
PlayerId: z
.string()
.describe(
'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names'
),
Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'),
Reason: z
.string()
.optional()
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
.describe(
'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent'
),
GameSessionId: z.string().describe('The room instance both players are standing in'),
})
@@ -1408,6 +1586,18 @@ export const InstantKickRequest = z.object({
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
})
/** `POST /api/PlayerReporting/v1/roomModKick` form body — one player out of one instance. */
export const RoomModKickRequest = z.object({
PlayerId: z.string().describe('Account id of the player to kick'),
GameSessionId: z
.string()
.describe('The room instance (game session) to eject them from — they must be standing in it'),
Reason: z
.string()
.optional()
.describe('Free text, shown to the kicked player in the kick message'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
@@ -1454,29 +1644,6 @@ export const SavedImageDto = z.object({
CommentCount: z.int(),
})
/**
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
* other two.
*/
export const ImageMetadataDto = z.object({
SavedImageId: z.int(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
PlayerId: z.int(),
RoomId: z.int().describe('0 when the photo was not taken in a room'),
PlayerEventId: z.int().describe('0 when it belongs to no event'),
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
Description: z.string().describe('Empty string, never null'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
@@ -1497,6 +1664,14 @@ export const ImagesPlayerDto = z.object({
RoomId: z.int().nullable(),
})
/**
* The bulk lookup's projection (`GET /api/images/v5/bulk`): {@link ImagesPlayerDto} plus a
* `ClubId`, always null.
*/
export const ImagesBulkDto = ImagesPlayerDto.extend({
ClubId: z.int().nullable().describe('Always null — images are not filed under clubs'),
})
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
SavedImageId: z.int(),
@@ -1532,9 +1707,21 @@ export const UploadImageResponse = z.object({
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
})
/** `DELETE /api/images/v1/deletesaved` JSON body. */
/** `POST /api/images/v1/deletesaved` JSON body. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })
/** `POST /api/images/v2/modifyaccessibility` JSON body. */
export const ModifyImageAccessibilityRequest = z.object({
ImageName: z.string(),
Accessibility: z.int().describe('0 private, 1 public'),
})
/** `POST /api/images/v1/modifydescription` JSON body. */
export const ModifyImageDescriptionRequest = z.object({
ImageName: z.string(),
Description: z.string().nullable().describe('The new caption; null or empty clears it'),
})
/**
* `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
+388 -17
View File
@@ -7,17 +7,28 @@
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
* doesn't clash with the other workers' migrations that share the database).
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql,
* 0025_report_chat_message.sql and 0034_report_image.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database).
*
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
* are not called by any endpoint on this worker. They back the staff panel served by
* `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with
* no counterpart in the real service, and those live on `www` rather than on the workers
* that reimplement the game's own API. The SQL stays here, with the table that owns it.
*
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
* a row with none of them is an ordinary player report. They are three columns rather than
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
* `chat_message_id`, with the message's SENDER as the reported player — see
* `POST /api/chatreport/createChatReport`. A reported IMAGE is the fifth: `image_id`, with
* the player who took it as the reported player — see `POST /api/images/v1/{id}/report`.
* The five id columns are mutually exclusive; a row
* with none of them is an ordinary player report. They are separate columns rather than one
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
*
* 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. It is
@@ -32,10 +43,12 @@
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql +
* 0034_report_image.sql).
*
* None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written
* on every report of its kind and read by nothing — no query here filters on any of them,
* None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
* needs it.
@@ -56,7 +69,13 @@ export const SCHEMA_DDL: string[] = [
ban_expires TEXT,
event_id INTEGER,
invention_id INTEGER,
custom_avatar_item_id TEXT
custom_avatar_item_id TEXT,
banned_by_player_id INTEGER,
banned_at TEXT,
chat_message_id INTEGER,
unbanned_by_player_id INTEGER,
unbanned_at TEXT,
image_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
@@ -103,6 +122,45 @@ export interface ReportRow {
* it does not know who made it.
*/
custom_avatar_item_id: string | null
/**
* The moderator who set `banned` on this row, or NULL when nobody has (and cleared
* again when a ban is lifted). Read per-row by the staff panel; nothing filters on it.
*/
banned_by_player_id: number | null
/**
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
* when the report was filed, and the two can be months apart. The staff panel's audit
* trail and its ordering (`getBansInForce`); the client's block screen does NOT count
* from it (see `banBlockDetails`). Rows written before 0020_report_ban_audit.sql carry
* NULL and fall back to `created_at` where a date is needed.
*/
banned_at: string | null
/**
* The chat message this report is against, or NULL for any other kind — mutually
* exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See
* `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER,
* read from the `message` table, because the body names no player. The thread the client
* also posts is not kept — a message id is unique across threads, so it is the whole
* reference.
*/
chat_message_id: number | null
/**
* The moderator who LIFTED the ban that once stood on this row, or NULL when none has
* been lifted — including while one stands: a re-ban clears it, with `unbanned_at`. A
* lift wipes the four ban columns above, so without this pair a lifted ban is
* indistinguishable from a report nobody acted on, and the staff panel offered to ban
* again. Read per row by the panel; nothing filters on it.
*/
unbanned_by_player_id: number | null
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
unbanned_at: string | null
/**
* The saved image (photo) this report is against, or NULL for any other kind — mutually
* exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the
* request has no body, so `reported_player_id` is the image's `PlayerId` (who took it),
* read from the image, and there is no category or description to store.
*/
image_id: number | null
}
/**
@@ -126,6 +184,10 @@ export interface NewReport {
inventionId?: number | null
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
customAvatarItemId?: string | null
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
chatMessageId?: number | null
/** Set only when reporting an IMAGE; never set alongside the four above. */
imageId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -135,8 +197,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id, invention_id, custom_avatar_item_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
event_id, invention_id, custom_avatar_item_id, chat_message_id, image_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
RETURNING *`
)
.bind(
@@ -151,7 +213,9 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
new Date().toISOString(),
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null
input.customAvatarItemId ?? null,
input.chatMessageId ?? null,
input.imageId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
@@ -159,7 +223,11 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
return row!
}
/** Every report filed against a player, newest first. Backs a future moderation view. */
/**
* Every report filed against a player, newest first — unpaged, because it is the whole
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
* feed. `searchReports` is the paged, filtered form for looking across accounts.
*/
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
@@ -217,6 +285,17 @@ export async function isPlayerBanned(
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
* the ban was handed down, which is NOT the report's `created_at`; the staff panel shows
* and sorts by it. A lift clears all four columns together: an unbanned row must
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
* `banned_at` still set would read as a ban to anything checking the timestamp.
*
* A lift instead SIGNS `unbanned_by_player_id`/`unbanned_at` with the same `bannedBy` and
* the same instant, and a re-ban clears those two — a standing ban has not been lifted.
* That is what lets the staff panel tell "a colleague let this player back in" from
* "nobody has acted on this report", so a second moderator doesn't undo the first.
*
* Returns the updated row, or null when there is no report with that id — so the caller
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
* changes count, hence RETURNING).
@@ -224,11 +303,303 @@ export async function isPlayerBanned(
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null } = {}
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {}
): Promise<ReportRow | null> {
const banned = options.banned ?? true
const now = new Date().toISOString()
return db
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
.prepare(
`UPDATE report
SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5,
unbanned_by_player_id = ?6, unbanned_at = ?7
WHERE id = ?1
RETURNING *`
)
.bind(
reportId,
banned ? 1 : 0,
banned ? (options.banExpires ?? null) : null,
banned ? (options.bannedBy ?? null) : null,
banned ? now : null,
banned ? null : (options.bannedBy ?? null),
banned ? null : now
)
.first<ReportRow>()
}
/**
* A page of reports matching a moderator's filters — the staff panel's search
* (`www`: `GET /api/staff/reports`).
*
* Every filter is optional and ANDed; an empty filter set is the whole table, newest
* first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is
* already chronological, and it is the primary key — ordering by the timestamp column,
* which is unindexed, would sort the whole result set. The date window is applied to
* `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and
* `to` is EXCLUSIVE, which is what makes a day-boundary window composable.
*
* `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing
* what has been actioned wants the expired ones too (`getBansInForce` is the other
* question). The count is a second statement rather than a window function, so the caller
* can page without re-deriving the total — it counts the same predicate, unpaged.
*/
export interface ReportSearch {
reportedPlayerId?: number | null
reporterPlayerId?: number | null
reportCategory?: number | null
/** true = banned rows only, false = unbanned only, null/undefined = both. */
banned?: boolean | null
/** ISO-8601; inclusive lower bound on `created_at`. */
from?: string | null
/** ISO-8601; EXCLUSIVE upper bound on `created_at`. */
to?: string | null
}
/** A page of search results, plus how many rows the filters match in total. */
export interface ReportPage {
reports: ReportRow[]
total: number
}
export async function searchReports(
db: D1Database,
filters: ReportSearch = {},
page: { skip?: number; take?: number } = {}
): Promise<ReportPage> {
// Built as a parallel list of clauses and binds so a filter is added in one place and
// can't drift between the page query and the count query, which share both.
const clauses: string[] = []
const binds: Array<number | string> = []
const where = (sql: string, value: number | string) => {
binds.push(value)
clauses.push(sql.replace('?', `?${binds.length}`))
}
if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId)
if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId)
if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory)
if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0)
if (filters.from) where('created_at >= ?', filters.from)
if (filters.to) where('created_at < ?', filters.to)
const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`
// Clamped rather than trusted: `take` reaches this from a query string, and an
// unbounded one would hand a moderator's browser the entire table.
const take = Math.min(Math.max(page.take ?? 50, 1), 200)
const skip = Math.max(page.skip ?? 0, 0)
const [rows, count] = await db.batch<ReportRow | { total: number }>([
db
.prepare(
`SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`
)
.bind(...binds, take, skip),
db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds),
])
return {
reports: rows.results as ReportRow[],
total: (count.results as Array<{ total: number }>)[0]?.total ?? 0,
}
}
/** One report by id, or null. The detail behind a search row. */
export async function getReportById(db: D1Database, reportId: number): Promise<ReportRow | null> {
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
}
/** A player who has collected reports, as the staff panel's triage list shows them. */
export interface ReportedPlayerTally {
playerId: number
/** Reports filed against them inside the window. */
reports: number
/**
* How many DIFFERENT accounts filed those reports. The number that actually means
* something: one player filing forty reports against someone they are feuding with
* should not outrank four unconnected players filing one each.
*/
distinctReporters: number
/** `created_at` of the most recent report in the window. */
lastReportAt: string
/** Whether a ban is in force against them right now — so the list can skip the done ones. */
bannedNow: boolean
}
/**
* The players with the most reports against them — the staff panel's triage list
* (`www`: `GET /api/staff/reports/top-reported`).
*
* WINDOWED by default (30 days), because the unwindowed version ossifies: an account that
* collected reports a year ago and stopped would sit at the top forever, above whoever is
* a problem this week. Pass `sinceDays: null` for all time.
*
* Ranked by distinct reporters FIRST and raw count second, for the reason
* `distinctReporters` exists at all. `minReports` keeps the long tail of single reports
* out — almost every report is a one-off, and a list of them is not a triage list.
*
* `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided
* in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather
* than with a round trip per player.
*/
export async function getTopReported(
db: D1Database,
options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {}
): Promise<ReportedPlayerTally[]> {
const now = options.now ?? new Date()
const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays
// An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps
// one shape rather than growing a second predicate.
const since =
sinceDays === null
? '0000-01-01T00:00:00.000Z'
: new Date(now.getTime() - sinceDays * 86_400_000).toISOString()
const minReports = Math.max(options.minReports ?? 3, 1)
const take = Math.min(Math.max(options.take ?? 50, 1), 200)
const { results } = await db
.prepare(
`SELECT
r.reported_player_id AS playerId,
COUNT(*) AS reports,
COUNT(DISTINCT r.reporter_player_id) AS distinctReporters,
MAX(r.created_at) AS lastReportAt,
EXISTS (
SELECT 1 FROM report b
WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1
AND (b.ban_expires IS NULL OR b.ban_expires > ?2)
) AS bannedNow
FROM report r
WHERE r.created_at >= ?1
GROUP BY r.reported_player_id
HAVING COUNT(*) >= ?3
ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC
LIMIT ?4`
)
.bind(since, now.toISOString(), minReports, take)
.all<Omit<ReportedPlayerTally, 'bannedNow'> & { bannedNow: number }>()
return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 }))
}
/**
* Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
* standing-bans list (`www`: `GET /api/staff/bans`).
*
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one
* player: a row whose `ban_expires` has passed has served its time and is not listed,
* though it stays as the record that it happened. A player with several in force appears
* once per ban — each is a separate report, and which report justified which ban is the
* point of the list.
*
* Ordered by WHEN THE BAN LANDED because the list's job is catching mistakes: the ban most
* likely to be wrong is the one just handed down, and a moderator who has slipped goes
* looking for it at the top. Ordering by severity instead (longest-lasting first, as this
* did) buries a fresh one-day ban under every permanent ban ever issued — exactly the
* wrong way round for the one read this list exists to serve.
*
* `COALESCE(banned_at, created_at)`: a ban set before 0020_report_ban_audit.sql added
* `banned_at` has only its report's date to sort by, which is the same fallback the block
* screen uses. Without the coalesce those rows would sort as NULL — all together at one
* end, in no order at all.
*/
export async function getBansInForce(
db: D1Database,
now: Date = new Date(),
take = 200
): Promise<ReportRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM report
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
ORDER BY COALESCE(banned_at, created_at) DESC, id DESC
LIMIT ?2`
)
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
.all<ReportRow>()
return results
}
// ---- Block details ----------------------------------------------------------
/**
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds LEFT, so a
* ban with no end gets the largest value the field holds — 68 years, which other
* implementations also send and the client's screen renders as permanent.
*/
export 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.
*/
export 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.
*
* Shared by the two places a player meets an account ban: `api`'s `moderationBlockDetails`
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
* handed down mid-session). Built once here so both screens describe the same ban.
*
* `Duration` is the seconds LEFT on the ban as of `now` — the time from the request to
* `ban_expires`, rounded up so a ban with a second to run never reads 0 — or
* `PERMANENT_BAN_DURATION` when there is no expiry. The client counts it down from the
* moment it receives it, with no reference to when the ban began: other implementations
* with timed bans keep the instant they set the block PRIVATE and serve `Duration` minus
* the elapsed time on every read.
* This once sent the FULL span from `banned_at` instead, on the theory that the client
* pairs it with `TimeoutStartedAt` (start + duration = end). It does not: the screen read
* correctly only at the instant the ban was handed down — when the full span IS the
* remainder, so the mid-session `ModerationKick` looked right — and every later sign-in
* showed the whole ban still to run.
*
* `TimeoutStartedAt` stays null. Other implementations don't send it for a ban; where it is
* set at all it names the start of a voice-chat timeout, which is what the name is about.
* `banned_at` is the audit trail and the staff panel's, not the client's.
*
* `now` is a parameter so the caller can pass the same instant it resolved the ban with
* (`getActiveBan`), and a ban that was in force at the read can't come out as 0 left.
*
* 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.
*/
export function banBlockDetails(ban: ReportRow, now: Date = new Date()) {
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: null,
}
}
+29 -15
View File
@@ -26,6 +26,8 @@
* don't clash with the other workers' migrations that share the database).
*/
import { bindPlaceholders, chunkForBinds } from '@repo/domain'
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation (
@@ -186,15 +188,22 @@ export async function getCheerCredits(
): Promise<Map<number, number>> {
const credits = new Map<number, number>()
if (playerIds.length === 0) return credits
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${placeholders})`
// The window cutoff is `?1`, so the ids start at `?2` and each chunk is one short of
// D1's bind cap — a bulk read over a friends list runs well past it.
const pages = await Promise.all(
chunkForBinds(playerIds, 1).map((chunk) =>
db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
)
.bind(windowCutoff(now), ...chunk)
.all<{ player_id: number; cheers_left: number }>()
)
.bind(windowCutoff(now), ...playerIds)
.all<{ player_id: number; cheers_left: number }>()
for (const row of results) credits.set(row.player_id, row.cheers_left)
)
for (const row of pages.flatMap((page) => page.results)) {
credits.set(row.player_id, row.cheers_left)
}
return credits
}
@@ -219,15 +228,20 @@ export async function getReputations(
now: Date = new Date()
): Promise<Reputation[]> {
if (accountIds.length === 0) return []
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const [{ results }, credits] = await Promise.all([
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<ReputationRow>(),
const [pages, credits] = await Promise.all([
// Chunked: the friends list behind `/api/playerReputation/v2/bulk` runs past D1's
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
Promise.all(
chunkForBinds(accountIds).map((chunk) =>
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${bindPlaceholders(chunk)})`)
.bind(...chunk)
.all<ReputationRow>()
)
),
getCheerCredits(db, accountIds, now),
])
const stored = new Map(results.map((r) => [r.account_id, r]))
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
const row = stored.get(id)
+390
View File
@@ -0,0 +1,390 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarSaveDto,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoomById,
publishAvatarSave,
} from '@repo/domain'
import { parseMultipart } from '../../../studio/src/multipart'
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
import { authedId, authedRoles } from '../http'
import type { Context } from 'hono'
import type { AvatarStudioSave } from '@repo/domain'
import type { App } from '../context'
const uuid = '[0-9a-fA-F-]{36}'
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const slots = new Set(AVATAR_STUDIO_SLOTS)
function validTags(value: unknown): boolean {
return (
value == null ||
(Array.isArray(value) &&
value.length <= 64 &&
value.every(
(t) =>
t &&
Number.isInteger(t.TagType) &&
t.TagType >= 0 &&
t.TagType <= 255 &&
typeof t.Value === 'string' &&
t.Value.length > 0 &&
t.Value.length <= 64
))
)
}
async function allowed(c: Context<App>, accountId: number, slot: number) {
const roles = await authedRoles(c)
return (
roles?.includes('betastudio') &&
(![104, 203].includes(slot) || roles.includes('developer')) &&
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
)
}
const safeName = (value: unknown): value is string =>
typeof value === 'string' &&
/^[a-zA-Z0-9_./-]+$/.test(value) &&
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
export const avatarStudioRoutes = new Hono<App>()
.get('/api/customAvatarItems/v1/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const rows = await c.env.DB.prepare(
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
)
.bind(id)
.all<{ data: string }>()
const all = rows.results.map((r) => JSON.parse(r.data))
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
})
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
return c.body(null, 403)
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
})
.post('/api/customAvatarItems/v1', async (c, next) => {
// Preserve the existing in-game custom-shirt contract, including its errors.
const form = await parseMultipart(c.req.raw.clone())
let body: Record<string, unknown>
try {
body = JSON.parse(String(form.metadata))
} catch {
return next()
}
if (
!body ||
typeof body !== 'object' ||
body.BaseAvatarItemId !== undefined ||
form.design ||
form.thumbnailImage
)
return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const slot = Number(body.OutfitType)
if (!Number.isInteger(slot) || !slots.has(slot))
return c.json(fail('Unsupported avatar item type.'), 400)
if (!(await allowed(c, id, slot)))
return c.json(fail('Avatar Studio authorization is required.'), 403)
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
if (
slot === 1000 &&
(!Number.isSafeInteger(badgeRoomId) ||
badgeRoomId < 0 ||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
)
return c.json(fail('Badge room is not managed by this account.'), 403)
if (
typeof body.Name !== 'string' ||
!body.Name.trim() ||
body.Name.length > 128 ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000))
)
return c.json(fail('Invalid item name or description.'), 400)
if (
body.Price !== undefined &&
body.Price !== null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
)
return c.json(fail('Invalid item price.'), 400)
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
return c.json(fail('Invalid accessibility.'), 400)
if (body.Accessibility && body.Accessibility !== 0)
return c.json(fail('Create the item privately and publish a built save first.'), 400)
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
const now = new Date().toISOString(),
itemId = crypto.randomUUID()
const item = {
CustomAvatarItemId: itemId,
CreatorAccountId: id,
Name: body.Name.trim(),
Description: body.Description ?? '',
Price: body.Price ?? 0,
Accessibility: body.Accessibility ?? 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
CreatedAt: now,
ModifiedAt: now,
PreviewOrientation: body.PreviewOrientation ?? 0,
RankingContext: null,
OutfitType: slot,
CurrentSaves: [],
Tags: body.Tags ?? [],
IsStudioAuthored: true,
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
RankedEntityId: itemId,
PurchaseInfo: null,
}
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(JSON.stringify(item))
.run()
return c.json(ok(item))
})
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
const itemId = c.req.param('id')
const existing = await avatarStudioItem(c.env.DB, itemId)
if (!existing?.IsStudioAuthored) return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
(body.Name != null &&
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
(body.Price != null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
!validTags(body.Tags)
)
return c.json(fail('Invalid avatar metadata.'), 400)
for (let attempt = 0; attempt < 5; attempt++) {
const item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (body.Accessibility && !item.CurrentSaves?.length)
return c.json(fail('Publish a built save before making the item public.'), 400)
const prior = JSON.stringify(item)
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
item.ModifiedAt = new Date().toISOString()
const result = await c.env.DB.prepare(
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
)
.bind(JSON.stringify(item), itemId, prior)
.run()
if (result.meta.changes) return c.json(ok(item))
}
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
})
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id) return c.body(null, 403)
const rows = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
)
.bind(itemId)
.all<AvatarStudioSave>()
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json(
ok({
Results: await Promise.all(
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
),
TotalResults: rows.results.length,
})
)
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
!Number.isInteger(body.BodyType) ||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
!safeName(body.ThumbnailFileName) ||
typeof body.AdditionalConfiguration !== 'string' ||
body.AdditionalConfiguration.length > 1024 * 1024 ||
(body.SaveDescription != null &&
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
)
return c.json(fail('Invalid avatar save.'), 400)
try {
JSON.parse(body.AdditionalConfiguration)
} catch {
return c.json(fail('Invalid additional configuration JSON.'), 400)
}
let filename: string, hash: string, unityId: string
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
const file = body.NewUnityAssetFile as Record<string, unknown>
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
if (!object || object.customMetadata?.ownerId !== String(id))
return c.json(fail('Source upload does not belong to this account.'), 403)
const checksum = object.checksums.sha256
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
if (!hash || file.Hash !== hash)
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
filename = file.Filename
unityId = crypto.randomUUID()
} else if (typeof body.ExistingUnityAssetId === 'string') {
const prior = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, body.ExistingUnityAssetId)
.first<AvatarStudioSave>()
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
filename = prior.source_filename
hash = prior.source_hash
// A source can be reused across builds, but compiled outputs belong to
// one body/configuration. Only metadata-only revisions share their output.
unityId =
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
? prior.unity_asset_id
: crypto.randomUUID()
} else return c.json(fail('A new or existing source asset is required.'), 400)
if (item.OutfitType === 1000) {
const config = JSON.parse(body.AdditionalConfiguration as string)
const badgeRoom = config?.d?.d?.r ?? 0
if (
config?.v !== 1 ||
config?.d?.o !== 1000 ||
!Number.isSafeInteger(badgeRoom) ||
badgeRoom < 0
)
return c.json(fail('Invalid badge configuration.'), 400)
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.json(fail('Badge room is not managed by this account.'), 403)
if (body.BodyType !== 4)
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
if (!source || source.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized badge source.'), 400)
const manifest = await source
.json<{
Version: number
MainAssetProjectRelativePath: string
Files: Array<{ p: string; n: string }>
}>()
.catch(() => null)
if (
!manifest ||
manifest.Version !== 3 ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!safeName(manifest.MainAssetProjectRelativePath) ||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
manifest.Files.some(
(f) =>
!f ||
!safeName(f.p) ||
!/^(Assets|Packages)\//.test(f.p) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid badge image source manifest.'), 400)
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
if (!image || image.size > 16 * 1024 * 1024)
return c.json(fail('Badge source image is missing or oversized.'), 400)
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
if (
!(
signature[0] === 137 &&
signature[1] === 80 &&
signature[2] === 78 &&
signature[3] === 71
) &&
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
)
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
}
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
(v) => v !== undefined && v !== null
)) {
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
const image = await c.env.CDN_ASSETS.get('image/' + name)
if (!image || image.customMetadata?.ownerId !== String(id))
return c.json(fail('Image upload does not belong to this account.'), 403)
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
// Storage's image upload and the Images service use different buckets.
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
}
const row = await c.env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
)
.bind(
itemId,
body.BodyType,
unityId,
filename,
hash,
body.ThumbnailFileName,
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
body.AdditionalConfiguration,
body.SaveDescription ?? null,
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
new Date().toISOString()
)
.first<{ id: number }>()
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
return c.json(ok(row!.id))
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const save = await avatarStudioSave(
c.env.DB,
item.CustomAvatarItemId,
Number(c.req.param('saveId'))
)
if (item.OutfitType === 1000 && save) {
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.body(null, 403)
}
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
return c.json(fail('Save has not completed a build.'), 400)
return c.json(ok(null))
})
+401 -73
View File
@@ -13,16 +13,20 @@ import {
MAX_BULK_OUTFIT_ACCOUNTS,
setOutfit,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import {
createCustomAvatarItem,
deleteCustomAvatarItem,
getCustomAvatarItem,
getCustomAvatarItems,
isQuestAssetTarget,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
@@ -31,6 +35,7 @@ import {
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreators,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
@@ -42,6 +47,7 @@ import {
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
MAX_INVENTION_PRICE,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
@@ -52,6 +58,7 @@ import {
setInventionTags,
toSaveResult,
toSaveResultV9,
unpublishInvention,
updateInvention,
} from '../inventions-db'
import {
@@ -79,6 +86,7 @@ import {
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionV9Dto,
InventionVersionDto,
json,
JsonArray,
@@ -102,8 +110,10 @@ import {
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UnpublishInventionRequest,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
@@ -149,6 +159,22 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
.filter((v) => v !== '')
}
/**
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
* so reading the form a second time costs nothing.
*/
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
const queried = c.req.query('unityAssetTarget')
if (queried !== undefined) return queried
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
const posted = key === undefined ? undefined : body[key]
const value = Array.isArray(posted) ? posted[0] : posted
return typeof value === 'string' ? value : undefined
}
/**
* 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 why not —
@@ -159,9 +185,7 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
async function creatorsInventionResult(
c: Context<App>,
inventionId: number
): Promise<
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
> {
): 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 }
@@ -217,7 +241,10 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
})
return rejected
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
: {
tags: normalizeInventionTags(autoTags, customTags),
tagResult: INVENTION_TAG_RESULT.success,
}
}
/**
@@ -227,8 +254,7 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
* 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 }
{ rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
/**
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
@@ -353,7 +379,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
// A token box holds no avatar item: null, or the client goes looking for type 0.
AvatarItemType: null,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
@@ -447,17 +474,27 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Create a custom avatar item',
description:
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
'(PNG) and `design` (the design PNG). Inserts a `custom_avatar_item` row owned ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope.\n\n' +
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
'which is what the store’s user-generated-content tab searches for.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row. Each file is ' +
'identified by its bytes — the client posts them under `.bin` names — and must be a ' +
'PNG or JPEG; the key’s extension and the stored content-type come ' +
'from that signature, and anything else is refused with a 400 before either file is ' +
'written.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
'0, is refused with a 400 rather than clamped.',
security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: {
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
400: json(
CustomAvatarItemResponse,
'Missing or malformed metadata / files, a file that is not an image, or a `Price` outside 0–1000'
),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
@@ -483,6 +520,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (meta.Price !== undefined && meta.Price !== null) {
if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price))
return fail('Price must be an integer')
if (meta.Price < 0) return fail('Price must be >= 0')
if (meta.Price > MAX_PLAYER_ITEM_PRICE)
return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
}
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env)
@@ -511,19 +555,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
}
// The parts' filenames and content-types are the uploader's claims (the client
// posts both as `.bin`); the `img` worker serves each back under whatever type is
// stored, so the extension and the stored type come from the bytes. Both are
// checked before either is written, so a refusal leaves nothing in R2.
const thumbnailBytes = new Uint8Array(await body.thumbnailImage.arrayBuffer())
const thumbnail = sniffImage(thumbnailBytes)
if (!thumbnail) return fail(`thumbnailImage is not a ${SNIFFED_IMAGE_FORMATS} image`)
const designBytes = new Uint8Array(await body.design.arrayBuffer())
const design = sniffImage(designBytes)
if (!design) return fail(`design is not a ${SNIFFED_IMAGE_FORMATS} image`)
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
// them back by key.
const customAvatarItemId = crypto.randomUUID()
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
const thumbnailImageFilename = `${prefix}-thumb.png`
const designFilename = `${prefix}-design.png`
const thumbnailImageFilename = `${prefix}-thumb${thumbnail.extension}`
const designFilename = `${prefix}-design${design.extension}`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
c.env.IMAGES.put(thumbnailImageFilename, thumbnailBytes, {
httpMetadata: { contentType: thumbnail.contentType },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
c.env.IMAGES.put(designFilename, designBytes, {
httpMetadata: { contentType: design.contentType },
}),
])
@@ -551,13 +606,14 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
'sends every field and nulls the ones it is not changing, so null means "leave ' +
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' +
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: {
200: json(CustomAvatarItemResponse, 'The updated item'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
@@ -600,6 +656,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0')
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
@@ -637,8 +696,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
if (!item) return fail(404, 'No such item')
// The row is gone; the objects follow. A missing key is a no-op for R2.
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
// The row is gone; the objects follow. A missing key is a no-op for R2. A first-party
// item has neither (it is rendered from its saves' assetbundles), so there is nothing
// to delete for one.
const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter(
(k): k is string => k !== null
)
if (keys.length > 0) await c.env.IMAGES.delete(keys)
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
@@ -681,10 +745,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'rather than no results, since the client sends every type it can render.',
'`minPrice`/`maxPrice` bound the price, inclusive.',
'`skip`/`take` page the results, `take` capped at 200.',
'`includeCoachItems=false` leaves out this server’s stock content.',
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
'asset variants are stored, and custom avatar items are the only item type there is.',
'`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s',
'stock content (account 1), `false` only player-made items, and leaving it out serves',
'both. The client’s storefront tab sends `True` and its user-generated-content tab',
'`False`, so the two never overlap.',
'`creatorAccountId` narrows to one author’s items — what a player’s creations list',
'sends (`creatorAccountId=<id>&includeCoachItems=False`); still published items only.',
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
'asset variants are stored, and custom shirts are the only item type there is.',
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
'0 (PC), any other value, and none at all get the bare filenames as stored.',
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.',
].join(' '),
@@ -735,9 +810,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
name: 'includeCoachItems',
in: 'query',
required: false,
description: 'Include the Coach’s stock items (default true)',
description:
'true: only the Coach’s stock items; false: only player-made items; absent: both',
schema: { type: 'boolean' },
},
{
name: 'creatorAccountId',
in: 'query',
required: false,
description: 'Only items this account made',
schema: { type: 'integer' },
},
{
name: 'unityAssetTarget',
in: 'query',
required: false,
description:
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
'Android/Oculus — which is served the `quest/` builds',
schema: { type: 'integer' },
},
],
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
}),
@@ -750,9 +842,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
?.map((v) => Number.parseInt(v, 10))
.filter((n) => Number.isInteger(n))
// Three-way: `True` is the Coach's side of the store, `False` the players', absent both.
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
// before comparing; anything that isn't recognisably false leaves the default alone.
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
// before comparing; anything that isn't recognisably true or false is treated as absent.
const coach = c.req.query('includeCoachItems')?.toLowerCase()
const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined
const int = (name: string): number | undefined => {
const raw = c.req.query(name)
@@ -761,16 +855,22 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return Number.isInteger(n) ? n : undefined
}
const items = await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
creatorAccountId: int('creatorAccountId'),
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
// The same switch the bulk lookup makes: a caller asking for the Quest target is
// pointed at the `quest/` builds of the assetbundles the saves name.
return c.json(
await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
isQuestAssetTarget(c.req.query('unityAssetTarget'))
? items.map(toQuestCustomAvatarItem)
: items
)
}
)
@@ -828,6 +928,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
'and the same spelling on the query string are both accepted, since the client’s ' +
'exact encoding here has not been pinned down.\n\n' +
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
'other value, and no target at all get the bare filenames as stored.\n\n' +
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
'client has been seen posting more than a screen could draw, and a miss is already ' +
'not an error here.',
@@ -853,8 +957,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// Unpublished items are held back from everyone but their creator — the same rule
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
// through this route that the feeds hide.
const visible = items.filter(
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
)
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
// under `quest/`, so the names are pointed there for a caller asking for that target.
return c.json(
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
isQuestAssetTarget(await bulkUnityAssetTarget(c))
? visible.map(toQuestCustomAvatarItem)
: visible
)
}
)
@@ -1385,15 +1496,18 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Publish an invention',
description:
'What puts an invention into search and the feeds. Sets the permission other ' +
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' +
'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' +
'400 rather than being clamped or dropped.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
intQuery('price', 'Price in tokens; negative is ignored'),
intQuery('price', 'Price in tokens, at most 1000; negative is ignored'),
],
responses: {
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
400: json(ErrorResponse, 'A price over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1405,6 +1519,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
@@ -1424,12 +1540,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Set an invention’s price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected.',
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).',
security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1445,6 +1561,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
@@ -1604,35 +1722,35 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
// portfolio, and the "from creators you follow" row, from.
//
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
// the client reads it as "this creator has published nothing" and renders an empty
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
// a filter on the invention table's creator column, the same feed shape as `toptoday`
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
// Published only, for every caller — a creator's own drafts are `v2/mine`'s alone.
.get(
'/api/inventions/v1/fromcreators',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by particular creators (stub)',
summary: 'Inventions by particular creators',
description:
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
'array for now, which the client renders as "nothing published" rather than as a ' +
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
parameters: [
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
'The inventions of the accounts named by `id` (repeatable), newest first — a ' +
'creator’s portfolio, and the "from creators you follow" row. Only PUBLISHED ' +
'inventions are listed, the caller’s own included: unpublished ones are served by ' +
'`v2/mine` alone. No `id` is an empty array.',
parameters: [intQuery('id', 'Creator account id; repeatable'), ...pageParams(100)],
responses: {
200: json(InventionDto.extend(InventionV9Dto.shape).array(), 'The creators’ inventions'),
},
}),
(c) => c.json([])
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByCreators(c.env.DB, inventionIdQuery(c), skip, take))
}
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
// name + description; absent → browse everything published; `@username` → that
// player's published inventions), newest first. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
//
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
// answer one page.
@@ -1645,10 +1763,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Published inventions matching `value`, newest first. `value` is split into terms ' +
'and every term must match, each against the name and the description. An absent ' +
'`value` browses everything published — that is the browse screen’s initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
'chips is treated as text and matches nothing.',
'request. A `value` starting with `@` names a player: `@djdevin` lists that ' +
'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' +
'username case-insensitively, with any further terms narrowing them as text; an ' +
'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' +
'browse screen’s filter chips is treated as text and matches nothing.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
stringQuery(
'value',
'Search text; absent browses everything; `@username` is a player’s inventions'
),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
@@ -1677,7 +1801,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The caller’s inventions'),
200: json(
InventionDto.extend(InventionV9Dto.shape).array(),
'The caller’s inventions — the stored record with the newer client’s flat DTO laid over it'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1793,6 +1920,49 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ success: true, error: '' })
}
)
// The newer client's cheer: the same body as `v1/cheer` and the same write, at a new
// path. It answers the `{ Value, Success, Error, error_id }` envelope the other v2+
// invention routes use, refusals in-band — an ASSUMPTION: only the request has been
// observed, not what the client reads off the reply.
.post(
'/api/inventions/v2/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention (v2)',
description:
'`v1/cheer` for the newer client: persists the caller’s cheer state and resyncs ' +
'the invention’s `CheerCount`; repeating the same state is idempotent. Answers ' +
'the `{ Value, Success, Error, error_id }` envelope with `Value` null, and a ' +
'refusal — a bad body, an unknown invention — is `Success: false` with a message ' +
'rather than an error status.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(InventionDeleteResult, 'The envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json(inventionDeleteResult('Unauthorized'), 401)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = body.InventionId
if (
typeof inventionId !== 'number' ||
!Number.isInteger(inventionId) ||
typeof body.Cheer !== 'boolean'
) {
return c.json(inventionDeleteResult('InventionId and Cheer are required'))
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json(inventionDeleteResult('No such invention'))
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json(inventionDeleteResult())
}
)
// Report an invention. Stored in the `report` table the player and event reports use —
// same fields, same moderation life — with `invention_id` set. See
// migrations/0016_report_invention.sql.
@@ -2026,9 +2196,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined
? null
: inventionLongDescriptionRejection(longDescription),
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
@@ -2055,6 +2223,112 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Edit an invention, as the newer client sends it: the `v1/update` write with a
// PascalCase body instead of a query string. What the permission picker posts —
// `{ InventionId, Permission }` — and so the endpoint that changes what other players
// may DO with an invention, where `v2/metadata` beside it changes what it SAYS.
//
// Creator only, which is the whole point of the gate: `GeneralPermission` is what lets
// another player edit, re-publish or charge for someone's build, so a write that took
// the caller's word for the id would let anyone hand themselves rights over any
// invention in the game.
.post(
'/api/inventions/v2/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an invention (v2)',
description:
'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' +
'query string. A patch, not a replace — every field but `InventionId` is nullable ' +
'and a null one is left as it is, since the client sends the whole shape on every ' +
'edit.\n\n' +
'**Creator only.** `Permission` decides what other players may do with the ' +
'invention — up to editing, re-publishing and charging for it — so only the ' +
'account that created it may set it.\n\n' +
'`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' +
'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' +
'`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' +
'unlike `v4/publish`, where null means UseOnly: publishing something with no ' +
'permission named is a decision, but editing it without naming one is not.\n\n' +
'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' +
'`v2/metadata`), as they are for `v1/update`.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' +
'unknown invention and someone else’s alike — is `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionRequest, '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. The gate is what makes this
// creator-only: it loads the invention and refuses anyone but the account that
// created it.
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 this 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. Same split `v2/metadata` makes.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// A number is the picker's own form; a string is accepted through the same parser
// `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or
// `"40"` lands on the same level. Anything else (null included) leaves it alone.
const permission =
typeof body.Permission === 'number'
? body.Permission
: typeof body.Permission === 'string'
? parsePermissionLevel(body.Permission)
: undefined
// Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule,
// since these are the same client's bodies.
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))
}
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
generalPermission: permission,
allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined,
name,
description,
longDescription,
imageName: edited('ImageName'),
})
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
// The tags are reported as the ones the invention HAS, not as ones this call
// changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest.
return c.json(toSaveResultV9(updated, updated.Tags ?? [], 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
@@ -2067,13 +2341,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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 ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says whether it ' +
'is in the store — 1 (Public) lists it in search and the feeds, 2 (Unlisted) ' +
'publishes it onto the creator’s portfolio and reachable by id but keeps it out ' +
'of both. A null `Price` leaves the price alone rather ' +
'than zeroing it, so re-publishing something that was for sale doesn’t 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 doesn’t re-date the first ' +
'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
'publish in-band.\n\n' +
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
@@ -2110,6 +2386,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
if (price !== undefined && price > MAX_INVENTION_PRICE)
return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`))
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
@@ -2122,6 +2400,56 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Unpublish an invention: `v4/publish` undone. Same body shape as `v2/delete` — the id
// and nothing else — and creator only.
//
// The reply is the `v9/save` envelope carrying the UPDATED invention, as `v4/publish`
// answers. That is an ASSUMPTION: what the client reads off this response has not been
// observed, only the request.
.post(
'/api/inventions/v2/unpublish',
describeRoute({
tags: ['Inventions'],
summary: 'Unpublish an invention',
description:
'Creator only. Takes the invention back out of search, the feeds and the ' +
'creator’s portfolio: `IsPublished` goes false and `Accessibility` back to 0, the ' +
'state a save mints, so the next `v4/publish` sets both afresh. Players who ' +
'already bought it keep it, the price and permission are left as they are, and ' +
'the first-publish date is not cleared.\n\n' +
'Answers the enveloped result `v4/publish` answers, carrying the unpublished ' +
'invention. Refusals are `Success: false` with a null `Value`.',
security: AUTHED,
requestBody: jsonBody(UnpublishInventionRequest, 'The invention to unpublish'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the unpublished invention under `Value`, or `Success: false` ' +
'with `Error` when it 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
)
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
const unpublished = await unpublishInvention(c.env.DB, gate.invention.InventionId)
if (unpublished === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(unpublished, unpublished.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.
+22 -17
View File
@@ -1,8 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
import { validateAndGetVersion } from '@repo/jwt'
import { GAME_VERSION, isSupportedGameVersion, supportedGameBuilds } from '@repo/domain'
import { validateAndGetBuild } from '@repo/jwt'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
@@ -73,15 +73,15 @@ export const configRoutes = new Hono<App>({ strict: false })
}),
(c) =>
c.json({
ReportBudget: 125,
ReportBudget: 0,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex: '^.*$',
VersionRegex: '.*',
SampleRate: 0,
LogLineCount: 0,
CaptureNativeCrashes: 0,
ANRThresholdMs: 0,
MessageCount: 0,
MessageRegex: '',
VersionRegex: '',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
@@ -106,13 +106,17 @@ export const configRoutes = new Hono<App>({ strict: false })
summary: 'Client version check',
description:
'Whether the client build is current. Compares the client’s `?v=` build against ' +
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
'client is on one of them, 1 when it is on some other build.',
'the builds we serve — the operator’s `SUPPORTED_GAME_VERSIONS` var (or the ' +
'default list in `@repo/domain`) plus every build under rollout ' +
'(`ROLLOUT_GAME_VERSIONS`): `VersionStatus` is 0 when the client is on one of ' +
'them, 1 when it is on some other build. The same lists decide which builds ' +
'`auth` hands a token to, so a build dropped from them is told to update here ' +
'and refused there.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
VersionStatus: isSupportedGameVersion(c.req.query('v'), supportedGameBuilds(c.env)) ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
@@ -140,8 +144,9 @@ export const configRoutes = new Hono<App>({ strict: false })
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
// client posted at login — since the request itself carries no version. Anything NEWER
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
// as strings, the same comparison `match` makes for cross-build joins.
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718`), so they order as
// strings, the same comparison `match` makes for cross-build joins — compared as BUILDS,
// so a same-day point release of the target (`20230414.01`) stays on the 2023 catalog.
//
// A request with no readable token version gets the 2023 catalog, the same body this
// route has always served: unauthenticated is not evidence of a newer client, and this
@@ -163,8 +168,8 @@ export const configRoutes = new Hono<App>({ strict: false })
responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') },
}),
async (c) => {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = version !== null && version > GAME_VERSION
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = build !== null && build > GAME_VERSION
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
}
)
+266 -50
View File
@@ -1,9 +1,9 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { Accessibility, GAME_VERSION } from '@repo/domain'
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt'
import { validateAndGetBuild } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
@@ -21,10 +21,13 @@ import {
getEventsByIds,
getEventsByRoom,
getEventTags,
getGoingPlayerIds,
getLiveEvents,
getPublicEventsByPlayer,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventSort,
parseEventTags,
parseEventTime,
searchEvents,
@@ -62,6 +65,7 @@ import {
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
PlayerPublicEvents,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
@@ -70,7 +74,11 @@ import {
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { StoredNotification } from '@repo/domain'
import type {
PlayerEventIdPayload,
PlayerEventResponsePayload,
} from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
@@ -105,12 +113,84 @@ async function notifyEventCreated(
}
/**
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added —
* what puts the event on their screen without a refetch, since an invite writes their
* response row for them.
* Push one frame to every player who is GOING to an event — the audience for a change
* to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
* merely invited, interested or declined has nothing on screen to update. The creator
* is among them through their own Going row, so their own edit or delete reaches their
* client too, the way `PlayerEventCreated` does.
*
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about.
* `playerIds` is passed in rather than read here because a delete has to read them
* BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
* doesn't stop the rest: the change is already stored by the time this runs.
*/
async function notifyGoing(
c: Context<App>,
playerIds: number[],
type: NotificationType,
payload: Record<string, unknown>,
playerEventId: number
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
for (const playerId of playerIds) {
try {
await hub.notifyPlayer(playerId, type, payload)
} catch (err) {
logger.error('failed to push player event notification', {
notificationType: type,
playerEventId,
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/**
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
* the whole-event update and every single-field edit alike — carrying the event as it
* now stands, in the same camelCase {@link toEventNotification} projection the
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
* accepts either casing).
*/
async function notifyEventUpdated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
await notifyGoing(
c,
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
NotificationType.PlayerEventUpdated,
payload,
event.PlayerEventId
)
}
/**
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
* deleted, so it leaves their screen. The frame is just the id — there is no event left
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
*/
async function notifyEventDeleted(
c: Context<App>,
playerIds: number[],
playerEventId: number
): Promise<void> {
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
}
/**
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
* refetch. This is the responder's own frame, carrying their own row.
*
* An INVITE does not send this. What an invitee gets is the invitation as a Message
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
* client populates the event from this frame only once they have actually responded.
* Sending it on invite made the event appear as if the player had already answered.
*
* The frame carries BOTH nested objects the client's decoder expects. That is not
* optional — several of its handlers dereference one level down with no null guard, so
@@ -120,30 +200,100 @@ async function notifyEventCreated(
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
* decoder accepts either casing, so the two need not agree.
*
* Hub failures are logged and swallowed, and one player's failure doesn't stop the
* rest: the invites are already stored by the time this runs.
* Hub failures are logged and swallowed: the answer is already stored by the time this
* runs.
*/
async function notifyResponseChanged(
c: Context<App>,
event: PlayerEvent,
response: EventAttendeeRow
): Promise<void> {
const payload = {
PlayerEvent: { ...toEventNotification(event) },
PlayerEventResponse: { ...toEventResponse(response) },
} satisfies PlayerEventResponsePayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
response.player_id,
NotificationType.PlayerEventResponseChanged,
payload
)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: response.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Tell each player a bulk invite just added about it: the invitation as a Message of
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
* `PlayerEventResponseChanged` frame is theirs only once they answer.
*
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about. One player's
* failure doesn't stop the rest: the invites are already stored by the time this runs.
*/
async function notifyInvited(
c: Context<App>,
inviterId: number,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const PlayerEvent = { ...toEventNotification(event) }
for (const row of added) {
const payload = {
PlayerEvent,
PlayerEventResponse: { ...toEventResponse(row) },
} satisfies PlayerEventResponsePayload
try {
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: row.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
}
}
/**
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
* the event id as a STRING (a Message's `Data` is always a string on the wire).
*
* The row is committed before the push; a hub failure is logged and swallowed, since the
* invite itself is already stored.
*/
async function pushEventInvitation(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
playerEventId: number
): Promise<void> {
let message: StoredNotification
try {
message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: MessageType.PlayerEventInvitation,
Data: String(playerEventId),
PlayerEventId: playerEventId,
})
} catch (err) {
logger.error('failed to store PlayerEventInvitation message', {
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
{ ...message }
)
} catch (err) {
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
notificationId: message.Id,
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -151,20 +301,22 @@ async function notifyInvited(
* Wrap an event in the v2 envelope for THIS caller's build.
*
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
* owes the 2023 build `["celebration"]` and anything newer `[{ Tag, Type }]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
* order as strings.
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
* order as strings — compared as BUILDS, with the client's point release dropped, so a
* same-day rebuild of the target stays on its side. `TagModifyResult.Tags` is a name list
* to every build — only the inline field moves.
*
* Like the other version gates here the claim is unverified — a client that lies about its
* build only empties its own tag chips.
*/
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = version !== null && version > GAME_VERSION
return toEventResult(event, tags, !isModernBuild)
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = build !== null && build > GAME_VERSION
return toEventResult(event, tags, isModernBuild)
}
/**
@@ -180,6 +332,7 @@ async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]
*
* These edits are creator-only like the whole-event update, and they go through the same
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
*/
function editEventField(
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
@@ -199,7 +352,9 @@ function editEventField(
if (input === null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
}
}
@@ -295,6 +450,31 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Another player's events, as their profile shows them: PUBLIC events only, and under
// `Responses` only the ones they are attending (Going). Public. Unlike the caller's own
// `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry
// is a PAIR — the event beside the RSVP record.
.get(
'/api/playerevents/v1/all/:playerId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'A player’s public events',
description:
'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' +
'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' +
'auth); finished events are left out, and a player with none — or an unknown id — ' +
'gets two empty lists.\n\n' +
'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' +
'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' +
'(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' +
'attending. A creator is Going to their own event, so it appears under both keys.',
parameters: [idParam('playerId', 'Player id')],
responses: { 200: json(PlayerPublicEvents, 'The player’s public events') },
}),
async (c) =>
c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10)))
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
@@ -449,14 +629,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
'Events that have already finished are left out — a name match on something that ' +
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
'ended last month is noise on a browse screen. Ordered by `sort` and paginated via ' +
'skip/take. A bare array.',
parameters: [
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
stringQuery(
'sort',
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
'served (soonest first); any other value sorts the same way'
'`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' +
'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' +
'any other value is served as `StartTime`'
),
...pageParams(50),
],
@@ -465,7 +646,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
return c.json(
await searchEvents(
c.env.DB,
c.req.query('query') ?? '',
skip,
take,
parseEventSort(c.req.query('sort'))
)
)
}
)
@@ -546,8 +735,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
'unknown event is a 404.',
'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
'and their new response row — this frame, not the invite, is what populates the ' +
'event on the client. An invitee answering Pending → Going gets it here like ' +
'anyone else.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
'player can give; an unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
@@ -570,9 +764,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404)
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
const result = await setEventResponse(c.env.DB, eventId, id, type)
if (result === null) return c.body(null, 404)
await notifyResponseChanged(c, result.event, result.response)
return c.json(await eventResult(c, result.event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -640,20 +835,30 @@ export const eventRoutes = new Hono<App>({ strict: false })
)
// Bulk invite — the "invite friends" button on an event. Adds the invited players to
// the same `event_attendee` table an RSVP writes to, as Going.
// the same `event_attendee` table an RSVP writes to, as Pending (3) — an open
// invitation, not an acceptance.
.post(
'/api/playerevents/v1/bulkInvite',
describeRoute({
tags: ['Events'],
summary: 'Invite players to an event',
description:
'Adds the invited players to the event as Going — the same `event_attendee` rows ' +
'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' +
'`AttendeeCount` immediately, without having answered.\n\n' +
'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
'`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
'`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
'Going made the invitation look already accepted on the client.)\n\n' +
'An invite never overwrites an answer: a player who already responded keeps what ' +
'they said, so inviting someone who declined does not flip them back to Going, and ' +
'they said, so inviting someone who declined does not reset them to Pending, and ' +
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
'are duplicate ids.\n\n' +
'Each newly invited player is sent the invitation as a Message of type 81 ' +
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
'sends once they answer, and is what populates the event on their client. ' +
'Players whose existing answer was left alone get nothing.\n\n' +
'The caller must be on the event themselves — its creator, or a player with a ' +
'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' +
'is not something a passer-by can do. Answers the same ' +
@@ -662,7 +867,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
security: AUTHED,
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
200: json(PlayerEventResultDto, 'The event (its attendee count is unchanged by an invite)'),
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'The caller is not on the event (empty body)' },
@@ -702,7 +907,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
]
const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, result!.event, result!.added)
await notifyInvited(c, id, result!.event, result!.added)
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -781,6 +986,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' +
'Everyone who was GOING to the event (the creator included) is pushed a ' +
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
'interested, declined and still-Pending invitees are not.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
@@ -804,7 +1012,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
const going = await getGoingPlayerIds(c.env.DB, eventId)
await deleteEvent(c.env.DB, eventId)
await notifyEventDeleted(c, going, eventId)
// Both payload fields are null here — the delete envelope is not the one the other
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
return c.json(EVENT_DELETED_RESULT)
@@ -857,6 +1068,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.\n\n' +
'Everyone who is GOING to the event (the creator included) is pushed a ' +
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
'still-Pending invitees are not. The single-field edits push the same frame.\n\n' +
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
'carries: moving the start alone still has to leave a window that ends after it ' +
'and runs no longer than a day against the STORED end.',
@@ -891,7 +1105,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
}
)
+41
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { validateStudioBuildValidationToken } from '@repo/jwt'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareString,
BulkAllowedRequest,
BulkAllowedResponse,
form,
idParam,
IsPureResponse,
json,
@@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}
)
// Studio checks all component text in one request, preserving duplicates and order.
.post(
'/api/sanitize/v2/isAllowed/bulk',
describeRoute({
tags: ['Gameplay'],
summary: 'Check Studio component text in bulk',
description:
'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.',
security: AUTHED,
requestBody: form(BulkAllowedRequest, 'Repeated Values fields'),
responses: {
200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'),
400: json(BulkAllowedResponse, 'Invalid form values'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
if (
(await authedId(c)) === null &&
!(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get()))
)
return unauthorized(c)
const body = await c.req.parseBody({ all: true }).catch(() => null)
const field = body?.Values
const values = field === undefined ? [] : Array.isArray(field) ? field : [field]
if (body === null || values.some((value) => typeof value !== 'string')) {
return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400)
}
return c.json({
Success: true,
Error: null,
Value: { ArePure: values.map((value) => !containsSwears(value as string)) },
})
}
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
+264 -54
View File
@@ -5,19 +5,25 @@ import {
createImage,
deleteImage,
getCheeredImageIds,
getImageById,
getImageByName,
getImagesByIds,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
mergePlayerSettings,
readPlayerSettings,
SavedImageType,
setImageAccessibility,
setImageCheer,
setImageDescription,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImageMetadata,
toImagesBulk,
toImagesPlayer,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import { authedId, unauthorized } from '../http'
import {
@@ -29,23 +35,27 @@ import {
ErrorResponse,
form,
idParam,
ImageMetadataDto,
ImagesBulkDto,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
ModifyImageAccessibilityRequest,
ModifyImageDescriptionRequest,
pageParams,
PhotoTaggingSettingRequest,
PhotoTaggingSettingResponse,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessErrorEnvelope,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
@@ -73,6 +83,17 @@ const typeFolder: Record<number, string> = {
*/
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
/**
* The owner's-view clause the player photo lists share in their docs. The lists are
* public, but they read a Bearer token if one is sent, and the one player it can unlock
* anything for is the player whose list it is.
*/
const OWN_VIEW_NOTE =
'Public, but reads a Bearer token if one is sent: a player asking for THEIR OWN list ' +
'gets their private photos in it too (`Accessibility` 0 — that is where the photos they ' +
'have not shared yet live). Any other player’s token, no token, or an invalid one serves ' +
'the public list; none of those is a 401.'
/**
* The preference a player has before they have ever set one. The value is an opaque enum
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
@@ -85,10 +106,7 @@ async function getPlayerSettings(
env: App['Bindings'],
accountId: number
): Promise<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
}
/** The caller's stored photo-tagging preference, or the default when they have none. */
@@ -104,19 +122,17 @@ async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number):
*
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
* and racing writers here means one player toggling two of their own options at once.
* would wipe the rest. Re-posting the value already stored writes nothing — the client
* does that freely, and KV writes are the cost.
*/
async function writePhotoTaggingSetting(
env: App['Bindings'],
accountId: number,
setting: number
): Promise<void> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${accountId}`,
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
)
await mergePlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, {
[PHOTO_TAGGING_KEY]: String(setting),
})
}
/**
@@ -204,12 +220,16 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
'avatar, persisted on the account row.',
'avatar, persisted on the account row.\n\n' +
'The file is identified by its bytes, not by the part’s filename or content-type: ' +
'it must be a PNG or JPEG, and the stored key’s extension and ' +
'content-type come from that signature. Anything else is refused with a 400 — the ' +
'`img` worker serves only those two formats, so it would never display.',
security: AUTHED,
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
responses: {
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
400: json(ErrorResponse, 'No file in the request, or the file is not an image'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
@@ -249,10 +269,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// The part's filename and content-type are the uploader's claims; the `img` worker
// serves the object back under whatever type is stored, so both the extension and
// the stored type come from the bytes. A body that is not an image is refused.
const bytes = new Uint8Array(await file.arrayBuffer())
const image = sniffImage(bytes)
if (!image) return c.json({ error: `image is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
@@ -260,10 +282,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
const name = typePrefix + datePrefix + crypto.randomUUID() + image.extension
await c.env.IMAGES.put(name, bytes, { httpMetadata: { contentType: image.contentType } })
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
@@ -297,8 +317,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
// image up by name, refuses unless the caller took it (PlayerId), then removes the
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
// 403 for someone else's.
.delete(
// 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to
// this path when a player deletes a photo from their gallery.
.post(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
@@ -338,6 +359,99 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Make one of the caller's photos public or private ({ ImageName, Accessibility }).
// Auth-gated and owner-only, like deletesaved; additionally refuses an image whose
// accessibility moderation has locked. Answers the same `{ success: true }` ack.
.post(
'/api/images/v2/modifyaccessibility',
describeRoute({
tags: ['Images'],
summary: 'Make one of the caller’s photos public or private',
description:
'Looks the image up by name and refuses unless the caller took it (or its ' +
'accessibility is locked), then sets `Accessibility` on the stored record: 0 ' +
'private, 1 public.',
security: AUTHED,
requestBody: jsonBody(ModifyImageAccessibilityRequest, 'The image and its new accessibility'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Accessibility not 0 or 1'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image, or its accessibility is locked'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Accessibility?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const accessibility = body?.Accessibility
if (accessibility !== 0 && accessibility !== 1) {
return c.json({ error: 'Accessibility must be 0 or 1' }, 400)
}
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
if (image.AccessibilityLocked) return c.json({ error: 'Accessibility is locked' }, 403)
await setImageAccessibility(c.env.DB, image, accessibility)
return c.json({ success: true })
}
)
// Set the caption on one of the caller's photos ({ ImageName, Description }).
// Auth-gated and owner-only, like deletesaved. An empty or null Description clears
// the caption (stored as null, the same as an upload without one).
.post(
'/api/images/v1/modifydescription',
describeRoute({
tags: ['Images'],
summary: 'Set the caption on one of the caller’s photos',
description:
'Looks the image up by name and refuses unless the caller took it, then sets ' +
'`Description` on the stored record. Null or empty clears it.',
security: AUTHED,
requestBody: jsonBody(ModifyImageDescriptionRequest, 'The image and its new caption'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Description not a string'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Description?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const raw = body?.Description ?? null
if (raw !== null && typeof raw !== 'string') {
return c.json({ error: 'Description must be a string' }, 400)
}
const description = raw === null || raw.trim() === '' ? null : raw
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
await setImageDescription(c.env.DB, image, description)
return c.json({ success: true })
}
)
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
@@ -373,15 +487,22 @@ export const imageRoutes = new Hono<App>({ strict: false })
// A player's photos — the public images that player has taken, newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
//
// Public, but a token is READ if one is sent: a player asking for their own list gets
// their private photos in it too (the profile they open on themselves is where those
// live), while anyone else's token — or none — serves the public list. A bad token is
// not a 401 here; it just reads as nobody.
.get(
'/api/images/v4/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A player’s photos',
description:
'The public images that player has taken, newest first. Serves the client’s ' +
'The public camera photos (`SavedImageType` 1) that player has taken, newest first — ' +
'not the outfit, room, invention or profile thumbnails filed under them. Serves the client’s ' +
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
'— the raw `SavedImage` renders blank thumbnails here.',
'— the raw `SavedImage` renders blank thumbnails here.\n\n' +
OWN_VIEW_NOTE,
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
}),
@@ -389,7 +510,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
const viewer = await authedId(c)
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take, viewer)
return c.json(images.map(toImagesPlayer))
}
)
@@ -401,7 +523,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Images'],
summary: 'A player’s photos, sortable',
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
description:
'v4 plus a `sort` option. Same `ImagesPlayer` projection, and the same owner’s view — ' +
'see the notes on v4.',
parameters: [
idParam('playerId', 'Account id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
@@ -414,7 +538,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
const viewer = await authedId(c)
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take, viewer)
return c.json(images.map(toImagesPlayer))
}
)
@@ -428,8 +553,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
tags: ['Images'],
summary: 'A player’s photo feed',
description:
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
'The public camera photos they took PLUS the ones they are tagged in, newest first — the ' +
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.\n\n' +
OWN_VIEW_NOTE +
' A photo someone else took and tagged them in stays public-only either way: its ' +
'privacy is the taker’s.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
}),
@@ -437,15 +565,17 @@ export const imageRoutes = new Hono<App>({ strict: false })
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
const viewer = await authedId(c)
const images = await getPlayerFeed(c.env.DB, playerId, skip, take, viewer)
return c.json(images.map(toImagesPlayer))
}
)
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// Global slideshow feed — the most recent public ShareCamera photos (Accessibility 1,
// Type 1) across all rooms, newest first, each joined to its creator's username and
// room name. Public (no auth): it only surfaces already-public images and backs the
// anonymous homepage slideshow. Private photos (Accessibility 0) never appear — the
// feed is anonymous, so there is no viewer it could be unlocked for. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
@@ -487,9 +617,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
// with what it asked for; an id with no record (or one that isn't public) is simply
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
//
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
// where the raw record comes up blank; this is a metadata lookup.
// Serves the `ImagesBulk` projection: the player lists' `ImagesPlayer` shape
// (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) plus a `ClubId`, always null.
// NOT the raw `SavedImage` that `v6` and the room feed serve.
//
// Public-only, as every image read here is: ids are sequential, so honouring whatever
// id is named would hand out private photos to anyone who counts.
@@ -502,12 +632,13 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
'array in request order. An id with no record, or one that is not public, is absent ' +
'from the answer rather than an error — the list can be shorter than the request. ' +
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
'player photo lists use.',
'Serves the `ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no ' +
'`TaggedPlayerIds`) plus a `ClubId` that is always null — not the raw `SavedImage` ' +
'that `v6` serves.',
parameters: [
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
],
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
responses: { 200: json(ImagesBulkDto.array(), 'The matching records, in request order') },
}),
async (c) => {
const ids =
@@ -516,27 +647,28 @@ export const imageRoutes = new Hono<App>({ strict: false })
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
return c.json(await getImagesByIds(c.env.DB, ids))
return c.json((await getImagesByIds(c.env.DB, ids)).map(toImagesBulk))
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
// Image metadata by filename, or 404 when there's no metadata row for that name.
//
// Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into.
// This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a
// `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type`
// and no `ClubId`, so it was silently dropping most of that.
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
'metadata row.\n\n' +
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
'`""`. Three shapes of one row; keep them straight.',
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
'`v6/{id}` serves, with `Description`, `RoomId` and `PlayerEventId` ' +
'null where the row holds null. 404s when the object exists but has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(ImageMetadataDto, 'The image’s metadata'),
200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
@@ -545,7 +677,85 @@ export const imageRoutes = new Hono<App>({ strict: false })
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(toImageMetadata(image)) : c.notFound()
return image ? c.json(image) : c.notFound()
}
)
// One image by id — the client opening a single photo it holds only the id of.
//
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
// which is the type the client decodes this route into — the same record `v6?name=`
// serves. NOT the `ImagesPlayer` projection of the player lists.
//
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
// 404 here, the same as an id that doesn't exist.
.get(
'/api/images/v6/:id{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'One image by id',
description:
'The stored `SavedImage` record for an image id — the raw record, as `v6?name=` ' +
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.',
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SavedImageDto, 'The image record'),
404: { description: 'No such image, or not the caller’s to see' },
},
}),
async (c) => {
const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10))
if (image === null) return c.notFound()
if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound()
return c.json(image)
}
)
// Report a saved image. Stored in the `report` table the player, event, invention, item
// and chat reports use — same moderation life — with `image_id` set. See
// migrations/0034_report_image.sql.
//
// The request has NO body: the path names the image and the token names the reporter,
// and that is the whole report. The reported player is whoever took the image, read from
// the image. The `{ success, error: "" }` reply is the envelope the other reports use,
// which is an ASSUMPTION here: what the client does with this response has not been
// observed.
.post(
'/api/images/v1/:id{[0-9]+}/report',
describeRoute({
tags: ['Images', 'Moderation'],
summary: 'Report a photo',
description:
'Files a report against a saved image, as a row in the same `report` table the ' +
'player, event, invention, custom-avatar-item and chat reports go to. There is no ' +
'request body: the row is the reporter (the caller), the image (`image_id`), and ' +
'`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' +
'description is stored, since none is sent. Nothing dedupes the rows, and an ' +
'unknown image is refused rather than filed against nobody.',
security: AUTHED,
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such image'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const imageId = Number.parseInt(c.req.param('id'), 10)
const image = await getImageById(c.env.DB, imageId)
if (image === null) return c.json({ success: false, error: 'No such image' }, 404)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: image.PlayerId,
imageId,
})
return c.json({ success: true, error: '' })
}
)
+429 -114
View File
@@ -2,17 +2,23 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canManageRoom,
canModerateRoom,
deletePresence,
countPlayersInInstance,
getPlayerIdsInInstance,
getPresence,
getPresences,
getRoomById,
getStoredRoomInstance,
MessageType,
refreshInstanceFullness,
movePlayerToDorm,
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The message being reported, and whether the reporter can see it — `chat` owns both tables
// on the shared database, so its reads are used rather than restated here.
import { getMessage } from '../../../chat/src/message-db'
import { isThreadMember } from '../../../chat/src/thread-db'
// 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'
@@ -21,6 +27,7 @@ import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateChatReportRequest,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
@@ -30,18 +37,25 @@ import {
JsonArray,
jsonBody,
ModerationBlockDetails,
RoomModKickRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
VoteToKickRequest,
} from '../openapi'
import { createReport, getActiveBan } from '../reports-db'
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
import {
countKickVotes,
getOpenVote,
isKickMajority,
isOnVoteCooldown,
recordRoomVote,
} from '../votes-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { ReportRow } from '../reports-db'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
@@ -65,6 +79,15 @@ function formField(
return c.req.query(name) || undefined
}
/**
* Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted
* beside them because the same field is an int elsewhere in this controller. Anything else —
* absent included — is a no, which is the safe way round: an unreadable answer must not count
* toward removing somebody.
*/
const isYesVote = (value: string | undefined): boolean =>
value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase())
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
@@ -79,6 +102,39 @@ const asFloat = (v: string | undefined): number | null => {
return Number.isNaN(n) ? null : n
}
/**
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
* them, rather than guessing at an enum nobody here has a copy of.
*/
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
discriminatory: KickReportCategory.CoCDiscrimination,
}
/**
* The numeric category a chat report is filed under. The chat report sends its category as
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
* what a ban's block screen names as the reason — so the name is mapped onto the same
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
*
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
* description.
*/
function chatReportCategory(raw: string | undefined): number | null {
if (raw === undefined) return KickReportCategory.Unknown
const name = raw.trim()
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
const key = name.toLowerCase()
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
if (alias !== undefined) return alias
for (const [member, value] of Object.entries(KickReportCategory)) {
if (typeof value === 'number' && member.toLowerCase() === key) return value
}
return null
}
/**
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
@@ -131,14 +187,17 @@ async function pushInstantKick(
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number
moderatorId: number,
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
): Promise<void> {
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: true,
Message: `You have been kicked from ${roomName}.`,
IsHostKick: isHostKick,
Message: reason
? `You have been kicked from ${roomName}. Reason: ${reason}`
: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
@@ -162,27 +221,23 @@ async function pushInstantKick(
}
/**
* 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}.
* What a vote-to-kick Message carries, and it is not what this once sent.
*
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
* straight through, and this mirrors it verbatim.
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
* raises its prompt about whoever that names, so sending the caller put the voter's own name
* on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame
* identifies the caller, and nothing needs to: the prompt is about the accused.
*
* `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.
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
* `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the
* prompt where the reason belongs.
*
* It is still a STRING on the wire, which is the part that has not changed: a Message's `Data`
* is a string like every other Message's, and an object there fails the client's decoder
* outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification
* rather than dropping the field. A reason is text, so it needs no escaping to satisfy that.
*/
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
@@ -225,67 +280,60 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
}
/**
* `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.
* Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a
* `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do.
*
* `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.
* It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and
* `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the
* player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the
* session they are in and nothing more, so they may walk straight back in, and a room that
* wants them gone for good bans them.
*
* `VoteKickReason` carries the reason the vote was called on, which is the field the client
* has for it — unlike the staff kicks, which leave it empty and put their text in `Message`.
*
* Best-effort, and deliberately after the ballot is recorded: the vote happened and was
* counted whatever the hub does next.
*/
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,
async function applyVoteKick(
c: Context<App>,
playerId: number,
gameSessionId: number,
reason: string,
carriedByAccountId: number
): Promise<void> {
try {
// Into their own dorm, not out of existence — see the note in `instantKick`.
await movePlayerToDorm(c.env.DB, playerId)
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
const roomName = typeof room?.Name === 'string' ? room.Name : 'this room'
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.VoteKick,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: false,
Message: `You have been kicked from ${roomName}.`,
PlayerIdReporter: carriedByAccountId,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: reason,
TimeoutStartedAt: null,
}
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[playerId],
NotificationType.ModerationKick,
{ ...frame }
)
} catch (err) {
logger.error('failed to carry out a vote kick', {
playerId,
gameSessionId,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -320,10 +368,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'matchmake refuses on (login still issues a token, so the client can reach this ' +
'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`Message` “Rule violation”, and `Duration` as the seconds LEFT on the ban as of ' +
'this request — the client counts it down from when it receives it, so it is ' +
'`ban_expires` minus now, rounded up, never the ban’s full span (2147483647, the ' +
'int32 max, for a permanent ban). `TimeoutStartedAt` stays null: the client does ' +
'not pair it with `Duration` for a ban, and no reference server sends it. ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the caller’s own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
@@ -346,8 +395,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ban = await getActiveBan(c.env.DB, id)
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
// One instant for both: the ban resolved as in force at `now` is described as of
// `now`, so it can't come out with 0 left.
const now = new Date()
const ban = await getActiveBan(c.env.DB, id, now)
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
}
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
@@ -482,10 +534,91 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// Report one chat message. Stored in the `report` table every other report uses — same
// fields, same moderation life — with `chat_message_id` set. See
// migrations/0025_report_chat_message.sql.
.post(
'/api/chatreport/createChatReport',
describeRoute({
tags: ['Moderation'],
summary: 'Report a chat message',
description:
'Files a report against one chat message. Stored as a row in the same `report` table ' +
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
'invention and custom-avatar-item reports — the same submission with the same ' +
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
'the message’s SENDER, read from the `message` table, since the body names no ' +
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
'thread the message is in — the same gate reading the message sits behind. A ' +
'message the caller cannot see answers exactly as one that does not exist, so the ' +
'route cannot be used to probe message ids or to file reports citing conversations ' +
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
'report and is refused.\n\n' +
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
'one a player report sends, and is mapped onto the numeric category the table ' +
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
'nothing is filed as 0 with the name kept at the front of the details. ' +
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
'string rather than null, on the rejected branches too.',
security: AUTHED,
requestBody: form(CreateChatReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
if (chatMessageId === null) {
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
}
// The message supplies the reported player, and its OWN thread is the one the
// membership check runs against — not the body's `ChatThreadId`, which would let a
// caller vouch for a message by naming a thread they happen to be in.
const message = await getMessage(c.env.DB, chatMessageId)
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
return c.json({ success: false, error: 'No such message' }, 404)
}
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
if (message.senderPlayerId <= 0) {
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
}
const rawCategory = formField(body, c, 'ReportCategory')
const category = chatReportCategory(rawCategory)
const description = formField(body, c, 'ReportDescription') ?? null
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: message.senderPlayerId,
reportCategory: category ?? KickReportCategory.Unknown,
details:
category === null
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
: description,
chatMessageId,
})
return c.json({ success: true, error: '' })
}
)
// 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.
// there. The call and every answer arrive here alike; the first ballot while no vote on
// that player is open CALLS one (`init`), and only a call is put to the room.
.post(
'/api/PlayerReporting/v3/voteToKick',
describeRoute({
@@ -501,17 +634,37 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'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 Message’s `Data` is a string on the wire, and an object there fails the ' +
"client’s 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' +
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
'client raises its prompt about whoever it names, so the caller there asks the room ' +
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
"actual:'{'`) and aborts the whole notification.\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 ' +
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' +
'strictly more than half the players standing in the session right now (a tie is not ' +
'a majority, and the count is of the room as it is, since players leave mid-vote). ' +
'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' +
're-posting a vote cannot carry one and a voter may change their mind.\n\n' +
'When it carries, the player is kicked exactly as a staff kick does it — moved into ' +
'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' +
'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' +
'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' +
'`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' +
'out in that case — the room is not asked a question that is already answered.\n\n' +
'The call and the answers arrive on this same endpoint with the same body. A ballot ' +
'posted while no vote on `PlayerId` is open in that session CALLS one: it is stored ' +
'with `init` 1 and is the only kind that puts the prompt to the room. A vote stays ' +
'open for 60 seconds; every ballot inside that window answers it — recorded and ' +
'tallied, but relayed to nobody — and only ballots from its call on are counted, so ' +
'an expired vote’s yes ballots cannot carry the next.\n\n' +
'Calling is THROTTLED: a player who called a vote in the last 5 minutes, in any ' +
'session, is refused with a 429. Answering is never throttled. A `No` cannot call a ' +
'vote — with none open it is refused with a 409.\n\n' +
'`Response` is the caller’s own vote, recorded but never relayed. Answers the same lowercase `{ success, error }` envelope as the ' +
'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,
@@ -521,6 +674,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
409: json(SuccessErrorEnvelope, 'A `No` with no vote open to answer'),
429: json(SuccessErrorEnvelope, 'The caller called a vote too recently'),
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
@@ -529,8 +684,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
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) {
@@ -541,6 +694,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
// The prompt's own text. Absent is an empty string rather than a refusal: a vote with
// no reason given is still a vote, and the client picks from a fixed list anyway.
const reason = formField(body, c, 'Reason') ?? ''
// 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.
@@ -557,6 +714,61 @@ export const moderationRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
}
// Call or answer? Nothing in the body says, so it is whether a vote on this player is
// already open here: with none, this ballot calls one.
const response = isYesVote(formField(body, c, 'Response'))
const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId)
const init = openVote === null
if (init) {
// A late "no" to a vote that has expired would otherwise call a new one.
if (!response) {
return c.json({ success: false, error: 'There is no vote in progress!' }, 409)
}
// Only calling is throttled — this is what stops one player putting vote after
// vote to the room. Answering someone else's vote never is.
if (await isOnVoteCooldown(c.env.DB, voterId)) {
return c.json({ success: false, error: 'You are calling votes too quickly!' }, 429)
}
}
// The caller's own ballot, recorded before the room is asked: they have voted, and a
// vote in a session holding only them and the player they called it on should carry on
// its own rather than waiting for an answer that can never come.
const ballot = await recordRoomVote(c.env.DB, {
gameSessionId,
playerId,
voterId,
response,
init,
})
// Counted against who is standing there RIGHT NOW: a majority is of the room as it is,
// not of the room as it was when the vote was called, and players leave mid-vote. Only
// THIS vote's ballots count — from its call on.
const yesVotes = await countKickVotes(
c.env.DB,
gameSessionId,
playerId,
openVote?.id ?? ballot.id
)
const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
if (isKickMajority(yesVotes, playerCount)) {
logger.info('vote to kick carried', {
playerId,
gameSessionId,
yesVotes,
playerCount,
carriedBy: voterId,
})
await applyVoteKick(c, playerId, gameSessionId, reason, voterId)
// The room is not asked a question that has already been answered.
return c.json({ success: true, error: '' })
}
// An answer is recorded and no more: the room was asked when the vote was called, and
// re-sending the prompt on every answer would raise it again for everyone.
if (!init) return c.json({ success: true, error: '' })
// 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.
@@ -569,15 +781,13 @@ export const moderationRoutes = new Hono<App>({ strict: false })
const results = await Promise.all(
audience.map((toPlayerId) =>
pushVoteToKick(c, {
FromPlayerId: voterId,
// The player being VOTED ON — the client raises its prompt about this id, so
// the caller's own id here asks the room to kick the wrong player.
FromPlayerId: playerId,
ToPlayerId: toPlayerId,
Type: MessageType.VoteToKick,
// An escaped JSON STRING, not a nested object — see VoteToKickData.
Data: voteToKickData({
PlayerId: playerIdField,
Response: '',
GameSessionId: gameSessionId,
}),
// The reason as plain text; see the note above the Message type.
Data: reason,
})
)
)
@@ -611,8 +821,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'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 ' +
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
'so their client lands where it already is instead of loading the dorm a second ' +
'time — and the instance they left frees a slot. They also get 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 ' +
@@ -672,14 +884,15 @@ export const moderationRoutes = new Hono<App>({ strict: false })
// 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)
// Moved to their own dorm rather than having their presence deleted: the client
// goes to the dorm either way, and with no presence to read it arrives not knowing
// where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the
// fullness of the instance they left, so this one no longer has to.
await movePlayerToDorm(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)
}
@@ -688,6 +901,108 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// ONE player kicked out of ONE live instance, from the in-room moderation menu, with a
// reason. The single-player sibling of `instantKick`, but form-encoded and gated more
// narrowly: the room's owner or a co-owner, or an account holding the staff role — not
// the room's Moderator (20) tier, which `instantKick` lets through.
//
// The cross-room guard is the same and it is the point: the instance names the room, the
// room is what the caller's authority is checked against, and the target's LIVE presence
// must put them in that very instance. Naming a player standing in some other room — even
// another instance of this one — kicks nobody, so authority over one room can never reach
// a session in another.
.post(
'/api/PlayerReporting/v1/roomModKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick one player out of a room instance, with a reason',
description:
'Ejects `PlayerId` from the live room instance `GameSessionId` (a `roomInstanceId`). ' +
'Form-encoded, unlike `instantKick`’s JSON; `Reason` is optional free text shown to ' +
'the kicked player in the kick message.\n\n' +
'Gated to the instance’s room: its creator or a co-owner, or any account whose token ' +
'carries the `moderator` / `developer` role. A room Moderator (20) or Host is NOT ' +
'enough here. Anyone else with a valid token gets a 403.\n\n' +
'The player must be standing in **that** instance — their live `presence` row must ' +
'name it. Otherwise nothing happens and the call answers `success: false`, so an ' +
'account id cannot reach into a session in another room, or another instance of this ' +
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
'so their client does not load the dorm twice — frees the slot they held, and sends ' +
'them an EPHEMERAL `ModerationKick` ' +
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
'true when the caller runs the room, false for a staff account acting in a room it ' +
'does not.\n\n' +
'Answers the lowercase `{ success, error }` envelope; the shape is unverified against ' +
'the real service.',
security: AUTHED,
requestBody: form(RoomModKickRequest, 'The session, the player and a reason'),
responses: {
200: json(
SuccessErrorEnvelope,
'`{ success: true, error: "" }`, or `success: false` when the player cannot be kicked'
),
400: json(SuccessErrorEnvelope, 'No `GameSessionId` or `PlayerId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller neither runs the room nor holds a staff role'),
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.parseBody().catch(() => ({}) as Record<string, unknown>)
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerId = asInt(formField(body, c, 'PlayerId'))
if (playerId === null) return c.json({ success: false, error: 'PlayerId is required' }, 400)
const reason = formField(body, c, 'Reason')?.trim() ?? ''
// The instance names the room, and the room carries the roles this is gated on.
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)
// The room's own owners first; the token's roles are only read when that fails.
const runsRoom = canManageRoom(room, moderatorId)
if (!runsRoom) {
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
}
if (playerId === moderatorId) {
return c.json({ success: false, error: 'You cannot kick yourself!' })
}
// Otherwise a co-owner, or a staffer, could throw the room's owner out of it.
if (canManageRoom(room, playerId)) {
return c.json({ success: false, error: 'You cannot kick an owner of this room!' })
}
// The cross-room guard: their LIVE presence must name this exact instance. Offline,
// expired, in another room, or in another instance of this room are all "not here".
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, playerId)
if (presence?.roomInstance?.roomInstanceId !== gameSessionId) {
return c.json({ success: false, error: 'That player is not in this game session!' })
}
// Into their own dorm, not out of existence — see the note in `instantKick`.
await movePlayerToDorm(c.env.DB, playerId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
reason,
isHostKick: runsRoom,
})
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
+169 -12
View File
@@ -13,21 +13,39 @@ import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import progressionEvent13 from '../../static/progression-events/Anime_Season_EventId_13.json'
import progressionEvent8 from '../../static/progression-events/Base_Event_EventId_8.json'
import progressionEvent2 from '../../static/progression-events/Coming_in_Hot_Progression_Event_Alpha_EventId_2.json'
import progressionEvent9 from '../../static/progression-events/Invasion_2_EventId_9.json'
import progressionEvent6 from '../../static/progression-events/Invasion_EventId_6.json'
import progressionEvent15 from '../../static/progression-events/Medieval_Season_EventId_15.json'
import progressionEvent5 from '../../static/progression-events/Progression_Event_Alpha_Repeat_EventId_5.json'
import progressionEvent3 from '../../static/progression-events/Rec_Room_EventId_3.json'
import progressionEvent12 from '../../static/progression-events/Science_Experiment_Restoration_EventId_12.json'
import progressionEvent7 from '../../static/progression-events/Science_Party_EventId_7.json'
import progressionEvent14 from '../../static/progression-events/Summer_Party_EventId_14.json'
import progressionEvent16 from '../../static/progression-events/Treat_Collection_Challenge_EventId_16.json'
import progressionEvent17 from '../../static/progression-events/Tricks_Challenge_EventId_17.json'
import progressionEvent18 from '../../static/progression-events/Yetis_Challenge_EventId_18.json'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BareInteger,
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
SetSelectedCheerRequest,
form,
idParam,
intQuery,
json,
JsonArray,
JsonObject,
ProgressionDto,
ProgressionEventRecordDto,
ReputationDto,
SetSelectedCheerRequest,
UNAUTHORIZED_RESPONSE,
XpBoostActivationsResponse,
} from '../openapi'
import {
addCheer,
@@ -46,6 +64,32 @@ import type { ReputationPayload } from '../../../notify/src/notification-payload
import type { App } from '../context'
import type { Reputation } from '../reputation-db'
/**
* The archived official progression events, by `ProgressionEventId` — each the event record
* exactly as the official API served it. They are held as TEXT and served byte for byte, not
* imported as JSON: a keepsake room's `RoomId` is a 64-bit number (`8364812036745705361`)
* that a JS parse rounds to a different room. The archive has gaps (no 1, 4, 10, 11).
*
* The text import is a `rules` entry in wrangler.jsonc, which TypeScript cannot see — it
* types a `.json` import as the parsed object — hence the cast.
*/
const PROGRESSION_EVENTS = new Map([
[2, progressionEvent2],
[3, progressionEvent3],
[5, progressionEvent5],
[6, progressionEvent6],
[7, progressionEvent7],
[8, progressionEvent8],
[9, progressionEvent9],
[12, progressionEvent12],
[13, progressionEvent13],
[14, progressionEvent14],
[15, progressionEvent15],
[16, progressionEvent16],
[17, progressionEvent17],
[18, progressionEvent18],
] as Array<[number, unknown]> as Array<[number, string]>)
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -255,6 +299,28 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}),
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
)
// How much XP the caller has earned today — what the client measures against its daily
// XP cap. A BARE INTEGER (the client decodes a plain `int`), not a progression record.
//
// Registered BEFORE `/progression/:id` below, which otherwise takes `xpEarnedToday` as
// an account id and answers `{ PlayerId: null, Level: 1, XP: 0 }` — the client's decoder
// threw on that (`expected:'Number Token', actual:'{'`).
//
// STUB: always 0. Nothing here records when XP was earned, only the running total. No
// auth while the answer is the same for everybody; it will need the caller once it is
// real, since the path names no player.
.get(
'/api/players/v1/progression/xpEarnedToday',
describeRoute({
tags: ['Progression'],
summary: 'XP the caller earned today (stub)',
description:
'The XP earned so far today, as a bare integer. Always `0` — XP is banked as a ' +
'running total and nothing records when it was earned.',
responses: { 200: json(BareInteger, 'A bare `0`') },
}),
(c) => c.json(0)
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
@@ -520,23 +586,114 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}
)
// The progression events running right now — the limited-time XP events the client shows
// The progression event running right now — the limited-time XP event the client shows
// a banner and a progress track for.
//
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
// fabricated event would draw a track that never fills. No auth — whether an event is
// running is the same fact for everybody, and the client asks while loading.
// STUB: a bare `18` — the id of the active event, or 0 when there is none (this once
// served -1 for the same meaning). 18 is hardcoded: it names an event the archive above
// holds, so the client can fetch its definition from `/event/18`, but nothing here
// tracks progress through it yet. No auth — whether an event is running is the same
// fact for everybody, and the client asks while loading.
.get(
'/api/progressionEvents/active',
describeRoute({
tags: ['Progression'],
summary: 'Progression events currently running (stub)',
summary: 'Progression event currently running (stub)',
description:
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
'server runs one — which the client reads as “no event” and skips the event UI, ' +
'where a 404 would stall the load. No auth: it is the same answer for every player.',
responses: { 200: json(JsonArray, 'Empty — no event is running') },
'The limited-time XP event in progress, as a bare event id (`0` would mean “no ' +
'active event”). Always `18` for now — a hardcoded archived event; nothing on this ' +
'server tracks progress through it yet. A 404 here would stall the load. No auth: ' +
'it is the same answer for every player.',
responses: { 200: json(BareInteger, 'A bare `18` — the hardcoded active event') },
}),
(c) => c.json([])
(c) => c.json(18)
)
// One progression event's definition — its reward track, keepsake rooms, dates and boost
// settings — served from the archive of official events above. Reading a definition says
// nothing about whether the event is running (`/active` above is a hardcoded 18), and no
// auth for the same reason as there: it is the same record for everybody.
.get(
'/api/progressionEvents/event/:id{[0-9]+}',
describeRoute({
tags: ['Progression'],
summary: 'A progression event’s definition',
description:
'The archived official event with this `ProgressionEventId`, verbatim: rewards, ' +
'keepsake room lists, start/end/collection times and boost settings. 404 for an ' +
'id the archive does not hold. No auth.',
parameters: [idParam('id', 'The `ProgressionEventId`')],
responses: {
200: json(JsonObject, 'The progression event'),
404: { description: 'No such event (empty body)' },
},
}),
(c) => {
const event = PROGRESSION_EVENTS.get(Number(c.req.param('id')))
if (event === undefined) return c.body(null, 404)
return c.body(event, 200, { 'Content-Type': 'application/json' })
}
)
// The caller's progress through one progression event — the XP, minutes and rewards the
// client draws its event track from.
//
// STUB: every counter 0, for any event id, echoing the id requested. Nothing tracks
// progress through an event yet (see `/active` above). Auth-gated because the record is
// the CALLER's and the path names no player.
.get(
'/api/progressionEvents/record/:id{[0-9]+}',
describeRoute({
tags: ['Progression'],
summary: 'The caller’s progress through a progression event (stub)',
description:
'The caller’s record for the event with this `ProgressionEventId`. Always empty ' +
'for now — 0 XP, nothing collected, `XpBoostLastPurchasedAt` null — for any id: ' +
'nothing on this server tracks progress through an event yet.',
security: AUTHED,
parameters: [idParam('id', 'The `ProgressionEventId`')],
responses: {
200: json(ProgressionEventRecordDto, 'An empty record for the event requested'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
AccountId: id,
ProgressionEventId: Number(c.req.param('id')),
Xp: 0,
GameMinutesToday: 0,
RewardsCollected: 0,
BonusRewardsCollected: 0,
XpBoostLastPurchasedAt: null,
PremiumRewardsCollected: 0,
LimitedRewardCollectionState: 0,
})
}
)
// The XP boosts the caller has bought and has running — the client multiplies the XP it
// shows by them and draws a timer for each.
//
// STUB: always the empty page. Nothing sells boosts yet, so nobody has one running. No
// auth while the answer is the same for everybody, like `xpEarnedToday` above; it will
// need the caller once it is real, since the path names no player.
.get(
'/api/purchasableXpBoosts/activations',
describeRoute({
tags: ['Progression'],
summary: 'The caller’s active XP boosts (stub)',
description:
'The XP boosts the caller has running, as `{ Results, TotalResults }`. Always the ' +
'empty page (`{ "Results": [], "TotalResults": 0 }`) — nothing on this server sells ' +
'boosts yet. Each entry, once there are any, names the purchase ' +
'(`PurchasableXpBoostLedgerEntryId`), the boost bought (`PurchasableXpBoostId`), its ' +
'`XpMultiplier`, and when it runs (`StartTime`/`EndTime`, ISO-8601 UTC).',
responses: {
200: json(XpBoostActivationsResponse, 'Always `{ "Results": [], "TotalResults": 0 }`'),
},
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
+52 -4
View File
@@ -11,7 +11,9 @@ import {
getMutualFriendIds,
getNotificationsForPlayer,
getRelationshipsForPlayer,
MessageType,
MUTUAL_FRIENDS_LIMIT,
RelationshipType,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
@@ -122,6 +124,34 @@ async function notifyBoth(
await notifyRelationship(c, otherId, change.other)
}
/**
* Store and push the MESSAGE that a friend-graph change shows the other player. The
* `RelationshipChanged` frames above only refresh relationship state; what the client
* actually surfaces — the request in their inbox with accept/decline, the "accepted your
* request" line — is a Message of the right {@link MessageType} delivered as
* `MessageReceived`, and stored so it's still there in `GET /api/messages/v2/get` for a
* player who was offline. `Data` is NULL for both — the client's Message DTO carries
* `"Data": null` on a FriendInvite (a payload-less type, unlike a DM's empty string); the
* sender id is the whole payload.
* Delivery failure is logged by `pushMessage`; the row is committed either way.
*/
async function pushFriendMessage(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
type: MessageType.FriendInvite | MessageType.FriendRequestAccepted
): Promise<void> {
await pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: null,
})
)
}
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
* private to the caller's own side of the row, so only the caller is notified. The
@@ -454,8 +484,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
'/api/relationships/v2/sendfriendrequest',
friendMutation(
'Send a friend request',
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
'and notifies nobody.'
'Offer friendship to another player. A new request also lands in the target’s inbox ' +
'as a `FriendInvite` message (type 4), pushed as `MessageReceived` — that message, ' +
'not the `RelationshipChanged` frame, is what the client shows them. Re-sending an ' +
'outstanding request is a no-op and notifies nobody.'
),
async (c) => {
const id = await authedId(c)
@@ -464,6 +496,17 @@ export const socialRoutes = new Hono<App>({ strict: false })
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await sendFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
// A NEW request is what the target sees as a FriendInvite message (the reference
// sends it on exactly this outcome). A request that crosses one the target already
// had out auto-accepts instead — so the target hears their own request was accepted,
// as if they'd been accepted the ordinary way. A no-op re-send says nothing.
if (change.changed) {
if (change.self.RelationshipType === RelationshipType.FriendRequestSent) {
await pushFriendMessage(c, id, target, MessageType.FriendInvite)
} else if (change.self.RelationshipType === RelationshipType.Friend) {
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
}
}
return c.json(change.self)
}
)
@@ -474,8 +517,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
'/api/relationships/v2/acceptfriendrequest',
friendMutation(
'Accept a friend request',
'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' +
'no-op and notifies nobody.'
'Turn a pending incoming request into a friendship. The requester is told with a ' +
'`FriendRequestAccepted` message (type 40) pushed as `MessageReceived`. Accepting ' +
'nothing pending is a no-op and notifies nobody.'
),
async (c) => {
const id = await authedId(c)
@@ -484,6 +528,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await acceptFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
// The requester is told, as a FriendRequestAccepted message from the accepter.
if (change.changed) {
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
}
return c.json(change.self)
}
)
+7 -5
View File
@@ -75,11 +75,13 @@ const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
* A username is letters and digits only (`nameRejection`), so it carries no spaces — and
* the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
* only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
* through. So the name is split at the boundaries a player types instead of a space, and
* the pieces are checked as words. A display name may carry real spaces and punctuation
* (`displayNameRejection`), which the filter already breaks words at; the split still
* applies to the runs in between.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
+2852 -164
View File
@@ -1,6 +1,6 @@
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
import {
addXp,
@@ -31,25 +31,42 @@ import {
import '../../api.app'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db'
import {
createThread,
postMessage,
SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL,
} from '../../../../chat/src/thread-db'
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
getCustomAvatarItem,
importCustomAvatarItem,
MAX_PLAYER_ITEM_PRICE,
} from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL, MAX_INVENTION_PRICE } from '../../inventions-db'
import {
banFromReport,
createReport,
getActiveBan,
getBansInForce,
getReportById,
getReportsAgainst,
getTopReported,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
searchReports,
} from '../../reports-db'
import {
CheerCategory,
@@ -60,6 +77,7 @@ import {
spendCheerCredit,
} from '../../reputation-db'
import { charadesWordsFor } from '../../routes/gameplay'
import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { SavedImage } from '@repo/domain'
@@ -77,6 +95,9 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** The PNG signature — the least an upload route accepts as an image. */
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
// `/api/rooms/v1/verifyRole` reads room roles from the shared recflare D1. Set
// up the schema (matching the rooms worker's migration) + a couple of rooms.
const TEST_ROOMS = [
@@ -98,7 +119,8 @@ const TEST_ROOMS = [
},
{
// 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.
// Moderator (20) and 44 only Host (10) — the tier just below that gate. 45 is a
// CoOwner (30), for `roomModKick`, which is gated on owners rather than moderators.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
@@ -107,6 +129,7 @@ const TEST_ROOMS = [
Roles: [
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 45, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
],
},
]
@@ -163,6 +186,8 @@ beforeAll(async () => {
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Vote-to-kick ballots (owned by the api worker) — one row per vote cast.
for (const stmt of VOTES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links (owned by the auth worker) — the sharp arm of the
// ban-evasion resolution matches on them.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -182,6 +207,11 @@ beforeAll(async () => {
// The message store (owned by the api worker) — the inbox reads it and every send
// writes to it.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Chat messages and thread membership (owned by the chat worker) — a chat report reads
// the reported player off the message, and gates on the reporter being in its thread.
for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -259,7 +289,7 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/backtrace`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ReportBudget: number; VersionRegex: string }
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
expect(body).toMatchObject({ ReportBudget: 0, ANRThresholdMs: 0, VersionRegex: '' })
})
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
@@ -274,6 +304,18 @@ describe('public endpoints', () => {
}
})
test('GET /api/versioncheck/v4 ignores the point release: every .NN of a supported build is current', async () => {
// The list holds bare dates; the client reports `YYYYMMDD.NN`, and the `.NN` is a
// same-day rebuild of the same build. All of these are the beta build.
for (const version of ['20250718.01', '20250718.2', '20250718', '20250718.99']) {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
}
// ...and a point release of a build that ISN'T supported is still not.
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101.01`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
@@ -284,6 +326,56 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
// The list is the operator's SUPPORTED_GAME_VERSIONS var when set, plus every build
// under rollout (ROLLOUT_GAME_VERSIONS) — the same lists `auth` gates tokens on, so a
// build told to update here is refused there too.
test('GET /api/versioncheck/v4 reads the lists from vars', async () => {
const status = async (version: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
return ((await res.json()) as { VersionStatus: number }).VersionStatus
}
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
try {
// Forced upgrade: the 2025 build drops out, every point release of it.
env.SUPPORTED_GAME_VERSIONS = '20230414,20260323'
expect(await status('20250718.01')).toBe(1)
expect(await status('20250718')).toBe(1)
expect(await status(GAME_VERSION)).toBe(0)
expect(await status('20260323.14')).toBe(0)
// Alphas not named in the var are gone too: the var replaces the default list.
expect(await status('20231207')).toBe(1)
// A build under rollout is current even when the var omits it: who may sign in on
// it is `auth`'s call, and the version check must not send a supporter to update.
env.SUPPORTED_GAME_VERSIONS = '20230414'
expect(await status('20260323.14')).toBe(0)
env.ROLLOUT_GAME_VERSIONS = 'off'
expect(await status('20260323.14')).toBe(1)
env.ROLLOUT_GAME_VERSIONS = '20250718'
expect(await status('20250718.01')).toBe(0)
// Entries are builds: a copied point release matches, and `off` empties the list.
env.ROLLOUT_GAME_VERSIONS = undefined
env.SUPPORTED_GAME_VERSIONS = '20250718.01'
expect(await status('20250718.2')).toBe(0)
expect(await status(GAME_VERSION)).toBe(1)
env.SUPPORTED_GAME_VERSIONS = 'off'
env.ROLLOUT_GAME_VERSIONS = 'off'
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(1)
// Blank is unset: the default list.
env.SUPPORTED_GAME_VERSIONS = ''
env.ROLLOUT_GAME_VERSIONS = ''
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(0)
} finally {
env.SUPPORTED_GAME_VERSIONS = originals.SUPPORTED_GAME_VERSIONS
env.ROLLOUT_GAME_VERSIONS = originals.ROLLOUT_GAME_VERSIONS
}
})
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
// has, so its presence identifies which body was served.
@@ -304,6 +396,9 @@ describe('public endpoints', () => {
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
// A same-day point release of the target is the target build: compared as a build,
// not as a string that happens to sort after `20230414`.
expect(await gameConfigKeys(`${GAME_VERSION}.01`)).not.toContain(KEY_2025_ONLY)
})
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
@@ -844,6 +939,28 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('the bulk profile reads answer more ids than D1 will bind at once', async () => {
// Both of these are asked about a whole friends list at once, which runs past D1's
// 100-bound-parameter cap; unchunked, the statement fails and the endpoint 500s
// rather than returning a short list.
const ids = Array.from({ length: 250 }, (_, i) => 6000 + i)
const query = ids.map((id) => `id=${id}`).join('&')
const progression = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?${query}`
)
expect(progression.status).toBe(200)
const levels = (await progression.json()) as Array<{ PlayerId: number }>
expect(levels.map((p) => p.PlayerId)).toEqual(ids)
const reputation = await exports.default.fetch(
`${ORIGIN}/api/playerReputation/v2/bulk?${query}`
)
expect(reputation.status).toBe(200)
const reps = (await reputation.json()) as Array<{ AccountId: number }>
expect(reps.map((r) => r.AccountId)).toEqual(ids)
})
test('progression reads back the XP game rewards banked, levelled up', async () => {
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
@@ -890,6 +1007,13 @@ describe('public endpoints', () => {
)
})
test('the config carries the room key limit', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
expect(res.status).toBe(200)
const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } }
expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 })
})
test('the level rewards match the published reward table', async () => {
// Rec Room's published level-reward table, spot-checked at the points where it turns:
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
@@ -973,7 +1097,8 @@ describe('public endpoints', () => {
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
await env.DB.prepare(
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
`UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true'))
WHERE custom_avatar_item_id != ?1`
)
.bind(unflagged.CustomAvatarItemId)
.run()
@@ -1123,9 +1248,12 @@ describe('public endpoints', () => {
new Date(Date.UTC(2026, 7, 1 + i))
)
made[name] = created.CustomAvatarItemId
// Nothing sets outfit_type on creation yet, so set it straight in the table.
// Every created item is a custom shirt (OutfitType 105); this test wants a spread of
// slots to filter across, so set them straight in the table.
expect(created.OutfitType).toBe(105)
await env.DB.prepare(
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
WHERE custom_avatar_item_id = ?1`
)
.bind(created.CustomAvatarItemId, outfitType)
.run()
@@ -1173,20 +1301,67 @@ describe('public endpoints', () => {
expect(await search('?skip=99&take=10')).toEqual([])
expect(await search('?take=0')).toEqual([])
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
// case; only a recognisable "false" turns the stock content off.
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
// `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the
// Coach's stock content alone (the storefront tab), `False` the players' alone (the
// user-generated-content tab), and the two never overlap. Absent is both. The client
// capitalises its booleans, so the comparison folds case.
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat'])
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([
'Trousers A',
'Shirt B',
'Shirt A',
'Hat A',
])
expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat')
expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual(
all.map((i) => i.Name)
)
// The whole query the client actually sends, unchanged — the parameters that aren't acted
// on yet must be accepted rather than 400 or throw.
// `creatorAccountId` is one author's published items — a player's creations list. It
// was once ignored, and every player's shelf showed the whole player-made catalog.
const spec2: Array<[name: string, creator: number]> = [['Hat C', 206]]
for (const [name, creator] of spec2) {
await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: creator,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 20))
)
}
expect((await search('?creatorAccountId=206')).map((i) => i.Name)).toEqual(['Hat C'])
// The query the client sends for a player's creations: theirs alone, `Hidden` still out.
expect(
(
await search(
'?creatorAccountId=205&includePurchaseInfos=True&includeCoachItems=False&skip=0' +
'&take=1000&unityAssetTarget=0&unityAssetVersion=3'
)
).map((i) => i.Name)
).toEqual(['Trousers A', 'Shirt B', 'Shirt A', 'Hat A'])
expect(await search('?creatorAccountId=1&includeCoachItems=False')).toEqual([])
expect(await search('?creatorAccountId=424242')).toEqual([])
await env.DB.prepare('DELETE FROM custom_avatar_item WHERE creator_account_id = 206').run()
// The whole storefront query the client actually sends, unchanged — the parameters that
// aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True`
// narrows it to the stock content.
const real = await search(
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
)
expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
expect(real.map((i) => i.Name)).toEqual(['Coach Hat'])
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
// the field is null on every item and the parameter changes nothing.
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
@@ -1259,17 +1434,18 @@ describe('public endpoints', () => {
// Combined with the text search, since the client sends both together.
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
// the filter working, not the search failing.
// The whole query the client's user-generated-content tab actually sends. `itemTypes=-1`
// ("every item type") and the unity asset parameters are accepted and not acted on;
// `outfitTypes=105` is the custom shirt, which is what every created item is filed under,
// so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.)
expect(
await search(
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
'&unityAssetTarget=0&unityAssetVersion=3'
)
).toEqual([])
// Same query with the outfit-type filter dropped: the rest of it does match.
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
// Same query with the outfit-type filter dropped matches the same, as it must.
expect(
await search(
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
@@ -1279,6 +1455,245 @@ describe('public endpoints', () => {
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
})
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
// The export's hashes are the PC builds'. The same stored save is served to a Quest
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
// the load stores them blank. A null hash (no second bundle) stays null.
const literal = customAvatarItemRowLiteral({
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CreatorAccountId: 9001,
CurrentSaves: [
{
BodyType: 1,
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
},
{
BodyType: 2,
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAssetHash: '',
UnityAsset2: null,
UnityAsset2Hash: null,
},
],
})
expect(JSON.parse(literal.slice(1, -1))).toEqual({
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CreatorAccountId: 1,
CurrentSaves: [
{
BodyType: 1,
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAssetHash: '',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '',
},
{
BodyType: 2,
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAssetHash: '',
UnityAsset2: null,
UnityAsset2Hash: null,
},
],
})
})
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
// A first-party item, as the official service exports it: the same record as a player's
// shirt with the base-item and filename fields NULL and a built Unity assetbundle per
// body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row
// stores it verbatim and the search serves it back as-is, saves included.
const wings = {
Accessibility: 1,
BaseAvatarItemColor: null,
BaseAvatarItemId: null,
CreatedAt: '2024-09-26T21:32:48.523Z',
CreatorAccountId: 1,
CurrentSaves: [
{
AdditionalConfiguration:
'{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}',
BodyType: 0,
CreatedAt: '2024-10-02T22:50:38.809Z',
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomAvatarItemSaveId: 1083,
Description: null,
ModifiedAt: '2024-11-27T02:34:07.523Z',
OutfitType: 100,
QAState: 2,
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=',
UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=',
UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6',
},
{
AdditionalConfiguration:
'{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}',
BodyType: 1,
CreatedAt: '2024-10-02T22:52:04.891Z',
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomAvatarItemSaveId: 1085,
Description: null,
ModifiedAt: '2024-11-27T02:34:07.523Z',
OutfitType: 100,
QAState: 2,
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle',
UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=',
UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=',
UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2',
},
],
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomBadgeMetadata: null,
Description:
'This item will have a fix on its connection point to the back on some body types in a future patch.',
DesignFilename: null,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: true,
ModifiedAt: '2025-02-19T05:19:28.328Z',
Name: 'Skeletal Wings',
OutfitType: 100,
PreviewOrientation: 0,
Price: 6000,
RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568',
RankingContext: null,
Tags: [{ TagType: 0, Value: 'export' }],
ThumbnailImageFilename: null,
}
await importCustomAvatarItem(env.DB, wings)
// A player-made shirt beside it, to prove the two kinds share one table and one shape.
const shirt = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Skeleton Tee',
description: '',
price: 0,
baseAvatarItemId: 2184,
baseAvatarItemColor: '#F55C1A',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
})
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return (await res.json()) as Array<Record<string, unknown>>
}
// The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the
// dozen slots asked for) and serves the record exactly as exported, plus the store-side
// `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced).
const store = await search(
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
)
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
const questStore = await search(
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
)
expect(questStore).toEqual([
{
...wings,
PurchaseInfo: null,
CurrentSaves: wings.CurrentSaves.map((save) => ({
...save,
UnityAsset: `quest/${save.UnityAsset}`,
UnityAsset2: `quest/${save.UnityAsset2}`,
})),
},
])
// Text search matches it by name and by description, and by its id typed whole.
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
'Skeletal Wings',
])
expect(
(await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name)
).toEqual(['Skeletal Wings'])
// "skeleton" is not a substring of "skeletal": only the shirt.
expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee'])
// The user-generated-content tab's query does not see it: wrong side of the catalog and
// wrong slot both.
expect(
(await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId)
).toEqual([shirt.CustomAvatarItemId])
// A player-made shirt still carries the full shape — empty saves, empty tags — so the
// client decodes both kinds through one parser.
const [tee] = await search('?searchQuery=skeleton')
expect(tee).toMatchObject({
BaseAvatarItemId: 2184,
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
RankedEntityId: shirt.CustomAvatarItemId,
})
// Re-importing the same id REPLACES the record (a corrected export lands) rather than
// duplicating or being ignored.
await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 })
expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000])
// The bulk lookup resolves it too, saves and all.
const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) },
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]),
})
expect(bulk.status).toBe(200)
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
expect(got.CurrentSaves).toHaveLength(2)
expect(got.Price).toBe(5000)
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
const bulkAssets = async (query: string, form: string[][] = []) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer('42')),
},
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
})
expect(res.status).toBe(200)
const [item] = (await res.json()) as Array<{
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
}>
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
}
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
expect(await bulkAssets('')).toEqual(stored)
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
})
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const base = {
@@ -1376,8 +1791,8 @@ describe('public endpoints', () => {
...metadata,
})
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(creator),
@@ -1719,12 +2134,73 @@ describe('public endpoints', () => {
expect(res.status).toBe(400)
})
test('GET /api/progressionEvents/active is an empty list (no auth)', async () => {
// The client reads an empty list as "no event running" and skips the event UI; a 404
// would stall its load instead.
test('GET /api/players/v1/progression/xpEarnedToday is a bare 0', async () => {
// A plain int on the client. It must not fall through to `/progression/:id`, which
// would read the path segment as an account id and answer a progression OBJECT.
const res = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/xpEarnedToday`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('GET /api/progressionEvents/active is a bare 18 (no auth)', async () => {
// A hardcoded stub: the id of an archived event (0 would mean "no active event").
// A 404 would stall the client's load instead.
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toBe(18)
})
test('GET /api/progressionEvents/event/:id serves the archived event verbatim', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/18`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.json()).toMatchObject({
ProgressionEventId: 18,
Name: 'Yetis Challenge',
Rewards: [],
KeepsakeRoomLists: [],
EventLeaderboardId: 18,
})
// A keepsake room's id is 64-bit: it has to reach the client as written, not as the
// nearest double (…705361 parses to …705000), so the body is checked as text.
const base = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/8`)
expect(await base.text()).toContain('"RoomId": 8364812036745705361')
// The archive has gaps, and an id it doesn't hold is a 404.
const missing = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/4`)
expect(missing.status).toBe(404)
})
test('GET /api/progressionEvents/record/:id is an empty record for the caller', async () => {
const url = `${ORIGIN}/api/progressionEvents/record/18`
expect((await exports.default.fetch(url)).status).toBe(401)
const res = await exports.default.fetch(url, { headers: await bearer('2') })
expect(res.status).toBe(200)
expect(await res.json()).toStrictEqual({
AccountId: 2,
ProgressionEventId: 18,
Xp: 0,
GameMinutesToday: 0,
RewardsCollected: 0,
BonusRewardsCollected: 0,
XpBoostLastPurchasedAt: null,
PremiumRewardsCollected: 0,
LimitedRewardCollectionState: 0,
})
// The id is echoed, whichever event is asked for.
const other = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/record/7`, {
headers: await bearer('2'),
})
expect(await other.json()).toMatchObject({ ProgressionEventId: 7 })
})
test('GET /api/purchasableXpBoosts/activations is the empty page', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/purchasableXpBoosts/activations`)
expect(res.status).toBe(200)
expect(await res.json()).toStrictEqual({ Results: [], TotalResults: 0 })
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
@@ -1776,6 +2252,35 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/inventions/v2/mine serves a v3-published invention as public', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Published the old way', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('5149') }
)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints. The
// 2025 client has no `IsPublished` to read, only `Accessibility` — 0 is Private.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5149'),
})
expect((await mine.json()) as SavedInvention[]).toMatchObject([
{ InventionId: Invention.InventionId, IsPublished: true, Accessibility: 1 },
])
// Published, so it would otherwise turn up in the feeds the later tests count.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId }),
})
})
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
const body = {
name: '071126 13:10:50',
@@ -1845,6 +2350,19 @@ describe('public endpoints', () => {
expect(mine.status).toBe(200)
const list = (await mine.json()) as SavedInvention[]
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
// The 2025 client decodes this list into its flat `RRInvention`, so every key of
// that DTO is present even on a v6 record that stores none of them — and the keys
// the older client reads are still there beside them.
expect(list.find((i) => i.InventionId === saved.InventionId)).toMatchObject({
UgcVersion: 1,
LatestVersionNumber: 1,
ForceCannotPublish: false,
IsRecRoomApproved: false,
DisplayMetadataJson: null,
Accessibility: 0,
IsPublished: false,
CurrentVersion: { VersionNumber: 1 },
})
// The saved invention is fetchable by id via the v1 lookup.
const one = await exports.default.fetch(
@@ -1964,7 +2482,7 @@ describe('public endpoints', () => {
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.UgcVersion).toBe(1)
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.
@@ -2159,6 +2677,158 @@ describe('public endpoints', () => {
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
})
// The permission picker's own call. `Permission` is the `GeneralPermission` ladder
// number — 40 is EditAndSave — and it decides what OTHER players may do with the
// invention, up to editing, re-publishing and charging for it.
test('POST /api/inventions/v2/update sets the permission from the body', 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: 'Permission Me', inventionDataFilename: 'perm.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
// The body verbatim as the client posts it.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 40 }),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
// The UPDATED invention comes back in the envelope: the client re-renders the detail
// sheet from `Value.Invention`, so a reply that omitted it would leave the old
// permission on screen.
expect(result.Value?.Invention.GeneralPermission).toBe(40)
// And it stuck.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).GeneralPermission).toBe(40)
})
// THE gate. `GeneralPermission` is what lets another player edit, re-publish or charge
// for someone's build, so a write that took the caller's word for the id would let
// anyone hand themselves rights over any invention in the game. Refused in-band, the
// way `v2/metadata` refuses — this client cannot parse a bare error body — and, the
// part that matters, the stored permission must not move.
test('POST /api/inventions/v2/update is gated to the invention’s creator', 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: 'Not Yours Either', inventionDataFilename: 'nye.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const permissionNow = async () => {
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
return ((await one.json()) as SavedInvention).GeneralPermission
}
const before = await permissionNow()
// Another player, granting themselves Unlimited (100) over someone else's build.
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
expect(await permissionNow()).toBe(before)
// An unknown invention is the same kind of answer.
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987655, Permission: 40 }),
})
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/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
expect(await permissionNow()).toBe(before)
})
// A patch, not a replace — and specifically NOT `v4/publish`'s reading of a null
// permission, which is UseOnly. Publishing something without naming a permission is a
// decision; editing it without naming one is not, and defaulting here would silently
// demote an invention every time the creator renamed it.
test('POST /api/inventions/v2/update leaves a null permission alone', 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: 'Keep My Rights', inventionDataFilename: 'keep.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// Set it to Publish (60) first, so a fallback to UseOnly (20) would be visible.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 60 }),
})
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: null, Name: 'Renamed' }),
})
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.GeneralPermission).toBe(60)
expect(value?.Invention.Name).toBe('Renamed')
})
// The same parser `v1/update`'s query param goes through, so a build that sends the
// ladder by NAME lands on the same level as one sending the number.
test('POST /api/inventions/v2/update accepts a permission named as a string', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Named Ladder', inventionDataFilename: 'named.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
for (const [sent, expected] of [
['EditAndSave', 40],
['edit_and_save', 40],
['60', 60],
] as const) {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: sent }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveV9Result).Value?.Invention.GeneralPermission).toBe(
expected
)
}
})
test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
@@ -2296,7 +2966,7 @@ describe('public endpoints', () => {
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the store, and v2/unpublish undoes it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
@@ -2316,8 +2986,8 @@ describe('public endpoints', () => {
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.
// Unlisted is published — on the creator's portfolio and reachable by id — but it is
// not in the store: search and the feeds leave it out.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
@@ -2331,6 +3001,39 @@ describe('public endpoints', () => {
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
const portfolio = async (): Promise<number[]> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5171`)
).json()) as SavedInvention[]
).map((i) => i.InventionId)
expect(await portfolio()).toEqual([inventionId])
// Unpublishing is creator only, and puts back what a save mints: not published,
// Accessibility 0 — off the portfolio, still on the creator's own shelf.
const unpublish = async (who: string): Promise<InventionSaveV9Result> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/unpublish`, {
method: 'POST',
headers: { ...(await bearer(who)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
).json()) as InventionSaveV9Result
expect((await unpublish('5199')).Success).toBe(false)
expect(await portfolio()).toEqual([inventionId])
const undone = await unpublish('5171')
expect(undone.Success).toBe(true)
expect(undone.Value?.Invention.Accessibility).toBe(0)
expect(await portfolio()).toEqual([])
const stored = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5171'),
})
).json()) as SavedInvention[]
const shelved = stored.find((i) => i.InventionId === inventionId)
expect(shelved).toMatchObject({ IsPublished: false, Accessibility: 0 })
expect(shelved?.FirstPublishedAt).not.toBeNull()
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
@@ -2365,6 +3068,20 @@ describe('public endpoints', () => {
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// One over the cap refuses the publish in-band, and the price stands.
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
})
expect(over.status).toBe(200)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await publish({ Price: 1000 })).Price).toBe(1000)
// 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()
})
@@ -2734,6 +3451,103 @@ describe('public endpoints', () => {
expect(anon.status).toBe(401)
})
test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => {
const url = `${ORIGIN}/api/chatreport/createChatReport`
const post = async (sub: string, fields: Record<string, string>) =>
exports.default.fetch(url, {
method: 'POST',
headers: await bearer(sub),
body: new URLSearchParams(fields),
})
// 5150 says something in a DM with 42; 42 reports it. The body names no player — the
// reported one is the message's sender.
const threadId = await createThread(env.DB, [42, 5150])
const message = await postMessage(env.DB, {
chatThreadId: threadId,
senderPlayerId: 5150,
contents: '{"Type":0,"Version":1,"Data":"something awful"}',
})
const id = String(message.chatMessageId)
// The body exactly as the client posts it: the category is a NAME, not a number.
const res = await post('42', {
ChatThreadId: String(threadId),
ChatMessageId: id,
ReportCategory: 'Discriminatory',
ReportDescription: 'details',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1')
.bind(message.chatMessageId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 5150, // the message's sender
report_category: 102, // `Discriminatory` → CoCDiscrimination
details: 'details',
chat_message_id: message.chatMessageId,
event_id: null, // the id columns are mutually exclusive
invention_id: null,
custom_avatar_item_id: null,
room_id: null,
banned: 0, // filed unbanned, like any report
})
// The thread in the body is not what vouches for the message: a wrong one changes
// nothing, and a category the server can't place is filed as 0 with its name kept.
const odd = await post('42', {
ChatThreadId: '999999',
ChatMessageId: id,
ReportCategory: 'SomethingNew',
ReportDescription: 'why',
})
expect(odd.status).toBe(200)
const oddRow = await env.DB.prepare(
'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC'
)
.bind(message.chatMessageId)
.first()
expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' })
// Someone outside the thread can't report a message they can't read — and is told
// exactly what they'd be told about a message that doesn't exist.
const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' })
expect(outsider.status).toBe(404)
expect(await outsider.json()).toEqual({ success: false, error: 'No such message' })
const unknown = await post('42', { ChatMessageId: '999999' })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such message' })
// A system notice has no sender to file a report against.
const notice = await postMessage(env.DB, {
chatThreadId: threadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: '{"Type":1,"Version":1,"Data":"5150"}',
})
const system = await post('42', { ChatMessageId: String(notice.chatMessageId) })
expect(system.status).toBe(400)
const noId = await post('42', { ReportDescription: 'x' })
expect(noId.status).toBe(400)
expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
const anon = await exports.default.fetch(url, {
method: 'POST',
body: new URLSearchParams({ ChatMessageId: id }),
})
expect(anon.status).toBe(401)
// Only the two accepted reports were filed.
const count = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL'
).first<{ n: number }>()
expect(count?.n).toBe(2)
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -2773,9 +3587,9 @@ describe('public endpoints', () => {
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
// A name is 3–40 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
@@ -2986,6 +3800,55 @@ describe('public endpoints', () => {
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' }))
.run()
const invention = (id: number, creator: number, name: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: creator,
Name: name,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
invention(111, 8082, 'Devin Sofa'),
invention(112, 8082, 'Devin Lamp'),
// Another creator mentioning the name is NOT this player's invention.
{ ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' },
// Unpublished and hidden ones stay private, even to a search for the creator.
{ ...invention(114, 8082, 'Devin Draft'), IsPublished: false },
{ ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (value: string): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100`
)
expect(res.status).toBe(200)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// The player's published inventions, newest first; the username is case-insensitive.
expect(await ids('@djdevin')).toEqual([112, 111])
expect(await ids('@DJDevin')).toEqual([112, 111])
// Further terms narrow within that player's inventions.
expect(await ids('@djdevin lamp')).toEqual([112])
// An unknown player is nothing — not a text search for the literal "@nobody".
expect(await ids('@nobody')).toEqual([])
expect(await ids('@')).toEqual([])
})
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
const published = (id: number, name: string, description: string, tags: string[]) =>
({
@@ -3229,6 +4092,50 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('POST /api/inventions/v2/cheer is v1/cheer in the newer client’s envelope', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8210')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Sofa', inventionDataFilename: 'cheerable2.inv' }),
})
const { InventionId } = ((await saved.json()) as InventionSaveResult).Invention
const cheer = async (body: unknown, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/inventions/v2/cheer`, {
method: 'POST',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const stored = async (): Promise<number> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=${InventionId}`)
).json()) as SavedInvention
).CheerCount
const ok = { Value: null, Success: true, Error: null, error_id: null }
const anonymous = await cheer({ InventionId, Cheer: true })
expect(anonymous.status).toBe(401)
expect(await anonymous.json()).toMatchObject({ Value: null, Success: false })
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(1)
const personal = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${InventionId}`,
{ headers: await bearer('44') }
)
expect(await personal.json()).toEqual({ IsCheering: true })
expect(await (await cheer({ InventionId, Cheer: false }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(0)
// Refusals stay in the envelope, at 200.
const unknown = await cheer({ InventionId: 999999, Cheer: true }, '44')
expect(unknown.status).toBe(200)
expect(await unknown.json()).toMatchObject({ Success: false, Error: 'No such invention' })
expect(await (await cheer({ InventionId }, '44')).json()).toMatchObject({ Success: false })
})
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -3545,6 +4452,21 @@ describe('public endpoints', () => {
expect(await search()).toEqual([Invention.InventionId])
// A price over the cap refuses the whole publish, leaving the invention as it was.
const over = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`,
{ headers: await bearer('2121') }
)
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
expect(
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(Invention.InventionId)
.first<{ price: number }>())!.price
).toBe(250)
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -3597,26 +4519,142 @@ describe('public endpoints', () => {
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
).toBe(403)
// Capped at MAX_INVENTION_PRICE, inclusive: one over is refused and the price stands.
expect(MAX_INVENTION_PRICE).toBe(1000)
const over = await updateprice({ InventionId: Invention.InventionId, Price: 1001 })
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
const atCap = await updateprice({ InventionId: Invention.InventionId, Price: 1000 })
expect(atCap.status).toBe(200)
expect(((await atCap.json()) as InventionSaveResult).Invention.Price).toBe(1000)
})
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
// A stub: the client renders an empty array as "this creator has published nothing",
// where a 404 would read as a row that failed to load.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The cap arrived after creators had priced inventions. Migration 0033 lowers every row
// over it to exactly the cap, published or not, and leaves the rest alone. Tests build the
// schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE is run by hand.
test('migration 0033 lowers existing invention prices to the cap', async () => {
const make = async (name: string, price: number): Promise<number> => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('1213')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
const id = ((await save.json()) as InventionSaveResult).Invention.InventionId
// Written straight into the row: no endpoint will store this any more.
await env.DB.prepare(
`UPDATE invention SET data = json_set(data, '$.Price', ?2) WHERE id = ?1`
)
.bind(id, price)
.run()
return id
}
const dear = await make('Dear Lamp', 25000)
const fair = await make('Fair Lamp', 1000)
const cheap = await make('Cheap Lamp', 50)
// The params are accepted and ignored, including a repeated `id` and none at all.
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
).json()
).toEqual([])
expect(
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
).toEqual([])
const statements = inventionPriceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: number): Promise<number> =>
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(id)
.first<{ price: number }>())!.price
expect(await price(dear)).toBe(1000)
expect(await price(fair)).toBe(1000)
expect(await price(cheap)).toBe(50)
})
test('GET /api/inventions/v1/fromcreators lists a creator’s public inventions', async () => {
const json = async (creator: string, path: string, body: unknown): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(creator)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const save = async (creator: string, name: string): Promise<number> => {
const res = await json(creator, '/api/inventions/v9/save', {
name,
inventionDataFilename: 'portfolio.inv',
ugcVersion: 1,
})
const id = ((await res.json()) as InventionSaveV9Result).Value?.Invention.InventionId
if (id === undefined) throw new Error('save failed')
return id
}
const publish = (creator: string, id: number, accessibility: number): Promise<Response> =>
json(creator, '/api/inventions/v4/publish', {
InventionId: id,
Permission: 20,
Accessibility: accessibility,
})
const draft = await save('5301', 'Portfolio Draft')
const listed = await save('5301', 'Portfolio Public')
const unlisted = await save('5301', 'Portfolio Unlisted')
const legacy = await save('5301', 'Portfolio Legacy')
const other = await save('5302', 'Other Creator')
await publish('5301', listed, 1)
await publish('5301', unlisted, 2)
await publish('5302', other, 1)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints.
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${legacy}`, {
headers: await bearer('5301'),
})
const shelf = async (query: string): Promise<SavedInvention[]> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?${query}`)
).json()) as SavedInvention[]
// Published is what lists an invention here, whatever its accessibility: the draft
// is not on the shelf, the Unlisted one is — and the `v3/publish` one, which stores
// the 0 a save mints, is served as Public.
const mine = await shelf('id=5301&skip=0&take=100')
expect(mine.map((i) => i.InventionId)).toEqual([legacy, unlisted, listed])
expect(mine.map((i) => i.Accessibility)).toEqual([1, 2, 1])
// The creator gets the same shelf as anyone: their drafts are `v2/mine`'s alone.
const own = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5301`, {
headers: await bearer('5301'),
})
).json()) as SavedInvention[]
expect(own.map((i) => i.InventionId)).not.toContain(draft)
// `id` repeats, newest first across creators, and skip/take page it.
expect((await shelf('id=5301&id=5302')).map((i) => i.InventionId)).toEqual([
other,
legacy,
unlisted,
listed,
])
expect((await shelf('id=5301&id=5302&skip=1&take=1')).map((i) => i.InventionId)).toEqual([
legacy,
])
// No creator, or one with nothing public, is an empty shelf rather than a 404.
expect(await shelf('')).toEqual([])
expect(await shelf('id=999999')).toEqual([])
// Published, so they would otherwise turn up in the feeds the later tests count.
for (const [creator, id] of [
['5301', listed],
['5301', unlisted],
['5301', legacy],
['5302', other],
] as const) {
await json(creator, '/api/inventions/v2/delete', { InventionId: id })
}
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
@@ -3771,6 +4809,59 @@ describe('public endpoints', () => {
})
})
describe('POST /api/sanitize/v2/isAllowed/bulk', () => {
const bulk = async (body: URLSearchParams | FormData, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, {
method: 'POST',
headers: authed ? await bearer('42') : {},
body,
})
test('401s without a token', async () => {
expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401)
})
test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => {
const body = new URLSearchParams()
for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t'])
body.append('Values', text)
const res = await bulk(body)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false, true, true, true, false] },
})
})
test('accepts a single multipart value and an empty batch', async () => {
const body = new FormData()
body.append('Values', 'a$$hole')
expect(await (await bulk(body)).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [false] },
})
expect(await (await bulk(new URLSearchParams())).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [] },
})
})
test('rejects file values rather than marking them clean', async () => {
const body = new FormData()
body.append('Values', new Blob(['hello']), 'text.txt')
const res = await bulk(body)
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
Success: false,
Error: 'Values must be strings.',
Value: null,
})
})
})
describe('POST /api/sanitize/v1/isPure', () => {
const isPure = async (Value?: string, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
@@ -3878,11 +4969,8 @@ describe('custom avatar items', () => {
Accessibility: 0,
})
)
form.set(
'thumbnailImage',
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
)
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
@@ -3911,11 +4999,16 @@ describe('custom avatar items', () => {
BaseAvatarItemColor: '#F55C1A',
PreviewOrientation: 0,
RankingContext: null,
OutfitType: 0,
// Filed as a custom shirt, the slot the store's user-generated-content tab searches.
OutfitType: 105,
// The full shape a first-party item carries, empty: no built saves, no tags.
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
PurchaseInfo: null,
})
const itemId = body.Value.CustomAvatarItemId as string
expect(body.Value.RankedEntityId).toBe(itemId)
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
const date = (body.Value.CreatedAt as string).slice(0, 10)
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
@@ -3924,17 +5017,15 @@ describe('custom avatar items', () => {
// Both uploads landed in the image bucket under those keys.
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([1, 2, 3])
)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([4, 5, 6])
)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
// The row is the record as JSON, with the queried fields generated off it.
const row = await env.DB.prepare(
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
`SELECT json_extract(data, '$.Name') AS name, creator_account_id
FROM custom_avatar_item WHERE custom_avatar_item_id = ?1`
)
.bind(body.Value.CustomAvatarItemId)
.first()
@@ -4007,6 +5098,150 @@ describe('custom avatar items', () => {
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
// A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The
// client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a
// player has enforce it — refusing, not clamping, so the creator sees what was set.
test('the price is capped at 1000 tokens on create and edit', async () => {
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
const create = async (Price: unknown) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
)
form.set('thumbnailImage', new File([PNG_BYTES], 't.png', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const over = await create(1001)
expect(over.status).toBe(400)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await create(-1)).status).toBe(400)
expect((await create(10.5)).status).toBe(400)
// Nothing was written for the refused ones.
expect(
await env.DB.prepare(
`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`
)
.first<{ n: number }>()
.then((r) => r!.n)
).toBe(0)
// The cap itself is allowed, inclusive; a missing price is still 0.
const atCap = await create(1000)
expect(atCap.status).toBe(200)
const { Value } = (await atCap.json()) as {
Value: { CustomAvatarItemId: string; Price: number }
}
expect(Value.Price).toBe(1000)
expect(
((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price
).toBe(0)
// The edit is bound the same way, and a refused edit leaves the row as it was.
const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
const edit = async (Price: number) =>
exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
})
const tooMuch = await edit(1001)
expect(tooMuch.status).toBe(400)
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' })
expect((await edit(-1)).status).toBe(400)
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
const lowered = await edit(999)
expect(lowered.status).toBe(200)
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999)
})
// The cap arrived after players had already priced shirts. Migration 0032 lowers every
// PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the
// storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`.
// Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE
// is run here by hand.
test('migration 0032 lowers existing player-made prices to the cap', async () => {
const shirt = (name: string, price: number) =>
createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const dear = await shirt('Dear', 5000)
const fair = await shirt('Fair', 1000)
const cheap = await shirt('Cheap', 50)
// A first-party item over the cap: no base item, Coach-authored.
const firstPartyId = crypto.randomUUID()
await importCustomAvatarItem(env.DB, {
CustomAvatarItemId: firstPartyId,
RankedEntityId: firstPartyId,
CreatorAccountId: 1,
Name: 'Studio Wings',
Description: '',
Price: 6000,
Accessibility: 1,
OutfitType: 100,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: true,
PreviewOrientation: 0,
RankingContext: null,
CreatedAt: '2024-09-26T21:32:48.523Z',
ModifiedAt: '2025-02-19T05:19:28.328Z',
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
})
const statements = priceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))!
expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000)
// The lowered row is marked modified, as an edit would mark it; the untouched ones aren't.
expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt)
expect(await price(fair.CustomAvatarItemId)).toMatchObject({
Price: 1000,
ModifiedAt: fair.ModifiedAt,
})
expect(await price(cheap.CustomAvatarItemId)).toMatchObject({
Price: 50,
ModifiedAt: cheap.ModifiedAt,
})
expect((await price(firstPartyId)).Price).toBe(6000)
})
test('DELETE removes the creator’s item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket.
const form = new FormData()
@@ -4014,8 +5249,8 @@ describe('custom avatar items', () => {
'metadata',
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -4109,7 +5344,7 @@ describe('custom avatar items', () => {
// Neither rejected request may create metadata or leave one of its two objects behind.
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
@@ -4120,6 +5355,48 @@ describe('custom avatar items', () => {
}
})
// Both files are identified by their bytes — the client posts them as `.bin` under
// `image/png` — and the `img` worker serves each under its stored type, so a non-image
// is refused before either file or the row is written.
test('POST refuses a file whose bytes are not an image, writing nothing', async () => {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: string | Uint8Array, design: string | Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'not-image', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'f.bin', { type: 'image/png' }))
form.set('design', new File([design], 'f.bin', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const html = '<script>alert(1)</script>'
const badThumbnail = await upload(html, PNG_BYTES)
expect(badThumbnail.status).toBe(400)
expect(await badThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage is not a PNG or JPEG image',
})
const badDesign = await upload(PNG_BYTES, html)
expect(badDesign.status).toBe(400)
expect(await badDesign.json()).toMatchObject({
Success: false,
Error: 'design is not a PNG or JPEG image',
})
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'not-image'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(objectsBefore)
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -4190,6 +5467,24 @@ describe('instant kick', () => {
.bind(accountId)
.first<{ n: number }>())!.n === 1
// A kick no longer deletes presence — it moves the player into their own dorm — so
// "kicked" is "no longer standing in that session", not "gone".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** The instance a player's presence says they are standing in. */
const presenceInstance = async (accountId: number) =>
JSON.parse(
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>())!.data
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
const isFull = async (roomInstanceId: number) =>
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
.bind(roomInstanceId)
@@ -4221,9 +5516,19 @@ describe('instant kick', () => {
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)
// Out of the session — and into their own DORM, rather than having their presence
// deleted: with nothing to read the client lands not knowing where it is and loads the
// dorm a second time. 206 is untouched.
expect(await inSession(205, SESSION)).toBe(false)
expect(await isPresent(205)).toBe(true)
const dorm = await presenceInstance(205)
// The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player
// is not joining a session, and minting one per kick would put live dorm sessions on the
// books that nothing ever joins.
expect(dorm.roomInstanceId).toBe(-2)
expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom
expect(dorm.isPrivate).toBe(true)
expect(await inSession(206, SESSION)).toBe(true)
// The instance lost a player, so it is no longer full.
expect(await isFull(SESSION)).toBe(false)
@@ -4273,7 +5578,7 @@ describe('instant kick', () => {
// 43 holds Moderator (20) on the room.
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
expect(await isPresent(209)).toBe(false)
expect(await inSession(209, SESSION)).toBe(false)
await standIn(209, SESSION)
// 44 is only a Host (10), and 99 holds nothing at all.
@@ -4282,7 +5587,7 @@ describe('instant kick', () => {
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await isPresent(209)).toBe(true)
expect(await inSession(209, SESSION)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
@@ -4302,8 +5607,8 @@ describe('instant kick', () => {
// 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 inSession(42, SESSION)).toBe(true)
expect(await inSession(43, SESSION)).toBe(true)
expect(await frames()).toEqual([])
})
@@ -4339,11 +5644,250 @@ describe('instant kick', () => {
})
})
describe('room mod kick', () => {
// A session of KickRoom (room 4, owned by 42, co-owned by 45), a second session of the
// same room, and a session of RecCenter (room 2, owned by 1) — somebody else's room.
const SESSION = 1074859
const SAME_ROOM_SESSION = 1074860
const OTHER_ROOM_SESSION = 1074861
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const seedInstance = async (
roomInstanceId: number,
roomId: number,
maxCapacity = 0,
isFull = false
) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId,
subRoomId: roomId,
maxCapacity,
isFull,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number, roomId = 4) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId },
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// A kick no longer deletes presence — it moves the player into their own dorm — so
// "kicked" is "no longer standing in that session", not "gone".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** The instance a player's presence says they are standing in. */
const presenceInstance = async (accountId: number) =>
JSON.parse(
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>())!.data
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
// The client's form body.
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: {
...(await bearer(sub, roles)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
data: Record<string, unknown>
}>
test('the room’s owner kicks a player out of the session they are in, with a reason', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full one-seat instance that 205 is filling.
await seedInstance(SESSION, 4, 1, true)
await standIn(205, SESSION)
// The client's body, verbatim.
const res = await modKick({ PlayerId: '205', GameSessionId: String(SESSION), Reason: 'test' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Out of the session and into their own dorm — not deleted.
expect(await inSession(205, SESSION)).toBe(false)
const kickedTo = await presenceInstance(205)
expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel
expect(kickedTo.roomInstanceType).toBe(2) // Dormroom
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
.bind(SESSION)
.first<{ f: number }>()
expect(full!.f).toBe(0)
expect(await frames()).toEqual([
expect.objectContaining({
playerIds: [205],
ephemeral: true,
data: expect.objectContaining({
GameSessionId: SESSION,
IsHostKick: true,
IsBan: false,
PlayerIdReporter: 42,
Message: 'You have been kicked from KickRoom. Reason: test',
}),
}),
])
})
// The whole reason for the presence check: authority over one room must never reach a
// session somewhere else.
test('a player who is not standing in that exact session is not kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION, 4)
await seedInstance(SAME_ROOM_SESSION, 4)
await seedInstance(OTHER_ROOM_SESSION, 2)
// In another instance of the SAME room, in a different room entirely, and offline.
await standIn(206, SAME_ROOM_SESSION)
await standIn(207, OTHER_ROOM_SESSION, 2)
for (const playerId of ['206', '207', '208']) {
const res = await modKick({ PlayerId: playerId, GameSessionId: String(SESSION) }, '42')
expect(res.status, playerId).toBe(200)
expect(await res.json(), playerId).toEqual({
success: false,
error: 'That player is not in this game session!',
})
}
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
const cross = await modKick(
{ PlayerId: '207', GameSessionId: String(OTHER_ROOM_SESSION) },
'42'
)
expect(cross.status).toBe(403)
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
expect(await frames()).toEqual([])
})
test('a co-owner or a staff role may kick; a room moderator, a host and a stranger may not', async () => {
await seedInstance(SESSION, 4)
// 45 co-owns the room.
await standIn(209, SESSION)
expect(
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: true, error: '' })
expect(await inSession(209, SESSION)).toBe(false)
// 999 has no role on the room but carries the staff `moderator` role — and is not the
// host, so the frame says so.
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(209, SESSION)
const staff = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '999', [
'gameClient',
'moderator',
])
expect(await staff.json()).toEqual({ success: true, error: '' })
expect(await inSession(209, SESSION)).toBe(false)
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
// 99 holds nothing.
await standIn(209, SESSION)
for (const sub of ['43', '44', '99']) {
const res = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await inSession(209, SESSION)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `PlayerId=209&GameSessionId=${SESSION}`,
})
expect(anon.status).toBe(401)
})
test('nobody kicks themselves or an owner of the room', async () => {
await seedInstance(SESSION, 4)
await standIn(42, SESSION)
await standIn(45, SESSION)
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '42')).json()
).toEqual({ success: false, error: 'You cannot kick yourself!' })
// A co-owner cannot throw out the owner, and staff cannot throw out a co-owner.
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(
await (
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(await inSession(42, SESSION)).toBe(true)
expect(await inSession(45, SESSION)).toBe(true)
})
test('an unknown session 404s, and the body must name a session and a player', async () => {
const unknown = await modKick({ PlayerId: '205', GameSessionId: '999999' })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [fields, error] of [
[{ PlayerId: '205' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
[{ GameSessionId: String(SESSION) }, 'PlayerId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await modKick(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
})
})
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.
// other. The gate is presence alone; `room_instance` is read only to name the room in a
// carried vote's kick frame.
const SESSION = 1014079
const OTHER_SESSION = 1014080
// The tally tests get sessions of their own: ballots are appended and never pruned, so
// votes cast by the tests above would otherwise be counted into theirs.
const TALLY_SESSION = 1014081
const REVOTE_SESSION = 1014082
const TIE_SESSION = 1014083
// Every test calls votes as 42, and calling is throttled per caller — with the ballots of
// the test before still there, each call after the first would be refused with a 429, or
// land as an answer to a vote still open from earlier.
beforeEach(async () => {
await env.DB.prepare('DELETE FROM room_vote').run()
})
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
@@ -4363,6 +5907,40 @@ describe('vote to kick', () => {
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare(
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1"
)
.bind(accountId)
.first<{ n: number }>())!.n === 1
// A carried vote moves the player into their own dorm rather than deleting their presence,
// so "kicked" is "no longer standing in that session".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE json_extract(data, '$.accountId') = ?1
AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** Every ballot cast against one player in one session, oldest first. */
const ballots = async (gameSessionId: number, playerId: number) =>
(
await env.DB.prepare(
'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id'
)
.bind(gameSessionId, playerId)
.all<{
voter_id: number
response: number
player_id: number
voted_at: string
init: number
}>()
).results
// 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`, {
@@ -4400,17 +5978,20 @@ describe('vote to kick', () => {
// 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.
//
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
// raises its prompt about whoever that names, so the caller there asks the room to kick
// the wrong player. `Data` is the REASON as plain text — it is what the prompt shows,
// and it used to be a JSON blob of the ids, which players saw printed on the prompt. It
// is still a string on the wire: an object fails the client's decoder
// (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it.
const message = {
ephemeral: true,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 42,
FromPlayerId: 205,
Type: MessageType.VoteToKick,
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
Data: 'Inactive in games (AFK)',
},
}
const sent = await frames()
@@ -4427,6 +6008,109 @@ describe('vote to kick', () => {
})
})
test('a vote with no reason still goes out, with an empty Data', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const { Reason: _reason, ...noReason } = FIELDS
expect((await vote(noReason)).status).toBe(200)
const sent = await frames()
expect(sent).toHaveLength(2)
expect(sent.every((f) => f.data.Data === '')).toBe(true)
// Still about the player voted on, reason or no reason.
expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true)
})
// A vote is recorded whether or not it carries — the row is the record of who asked.
test('the caller’s ballot is recorded', async () => {
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const before = await ballots(SESSION, 205)
expect((await vote(FIELDS)).status).toBe(200)
const after = await ballots(SESSION, 205)
expect(after).toHaveLength(before.length + 1)
expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 })
expect(typeof after.at(-1)!.voted_at).toBe('string')
// `Response=False` is recorded as a no rather than dropped.
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 })
})
// The point of the tally: enough of the room says yes and the player goes, without any
// moderator being involved.
test('a majority of the room carries the vote and kicks the player', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// Three in the session: 42 calls it on 210, 211 is the other voter.
for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION)
const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) }
// One yes of three is not a majority — the room is asked instead.
expect((await vote(fields)).status).toBe(200)
expect(await inSession(210, TALLY_SESSION)).toBe(true)
expect((await frames()).every((f) => f.notificationType === 2)).toBe(true)
// The second yes carries it: 2 of 3 is strictly more than half.
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await vote(fields, '211')).status).toBe(200)
// Kicked exactly as a staff kick does it — out of the session and into their own dorm,
// their presence rewritten rather than deleted...
expect(await inSession(210, TALLY_SESSION)).toBe(false)
expect(await isPresent(210)).toBe(true)
expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel
// ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered.
const sent = await frames()
expect(sent).toHaveLength(1)
expect(sent[0]).toMatchObject({
playerIds: [210],
ephemeral: true,
notificationType: 22, // NotificationType.ModerationKick
data: {
ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them
IsHostKick: false,
IsBan: false, // nothing stops them coming back
Duration: 0,
GameSessionId: TALLY_SESSION,
PlayerIdReporter: 211, // whose ballot carried it
VoteKickReason: 'Inactive in games (AFK)',
},
})
})
// Otherwise one player could carry a vote alone by posting it repeatedly.
test('re-posting a vote cannot carry it, and a voter may change their mind', async () => {
for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION)
const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) }
// Three yes votes from one voter is still one voter.
for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200)
expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3)
expect(await inSession(212, REVOTE_SESSION)).toBe(true)
// 213 says yes — 2 of 3 — and it carries.
expect((await vote(fields, '213')).status).toBe(200)
expect(await inSession(212, REVOTE_SESSION)).toBe(false)
})
test('a tie is not a majority', async () => {
// Four in the session, so two yes votes are exactly half.
for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION)
const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) }
expect((await vote(fields)).status).toBe(200)
expect((await vote(fields, '215')).status).toBe(200)
expect(await inSession(214, TIE_SESSION)).toBe(true)
// The third yes is more than half, and carries.
expect((await vote(fields, '216')).status).toBe(200)
expect(await inSession(214, TIE_SESSION)).toBe(false)
})
test('both players have to be standing in the session', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, OTHER_SESSION)
@@ -4494,6 +6178,80 @@ describe('vote to kick', () => {
// 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])
})
// The client posts the call and every answer alike; only the call raises the prompt.
test('the first ballot calls the vote, and answers to it put nothing to the room', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
expect((await vote(FIELDS)).status).toBe(200)
expect(await frames()).toHaveLength(4)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
expect((await vote(FIELDS, '207')).status).toBe(200)
expect(await frames()).toEqual([])
expect((await ballots(SESSION, 205)).map((b) => [b.voter_id, b.init])).toEqual([
[42, 1],
[206, 0],
[207, 0],
])
})
test('a player may not call vote after vote', async () => {
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
expect((await vote(FIELDS)).status).toBe(200)
// Another vote, on another player — still a CALL, so still refused.
const again = await vote({ ...FIELDS, PlayerId: '206' })
expect(again.status).toBe(429)
expect(await again.json()).toEqual({
success: false,
error: 'You are calling votes too quickly!',
})
// Answering someone else's vote is never throttled...
expect((await vote({ ...FIELDS, PlayerId: '206' }, '207')).status).toBe(200)
expect((await vote({ ...FIELDS, PlayerId: '206' })).status).toBe(200)
// ...and neither is re-voting in their own.
expect((await vote(FIELDS)).status).toBe(200)
// Once the cooldown has passed, they may call again.
await env.DB.prepare(
"UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z' WHERE voter_id = 42 AND init = 1"
).run()
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(200)
})
test('a "no" cannot call a vote', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const res = await vote({ ...FIELDS, Response: 'False' })
expect(res.status).toBe(409)
expect(await res.json()).toEqual({ success: false, error: 'There is no vote in progress!' })
expect(await ballots(SESSION, 205)).toEqual([])
expect(await frames()).toEqual([])
})
// Otherwise yes ballots from a vote that failed would carry the next one on the same player.
test('an expired vote’s ballots do not count toward the next', async () => {
for (const id of [42, 217, 218, 219]) await standIn(id, TALLY_SESSION)
const fields = { ...FIELDS, PlayerId: '217', GameSessionId: String(TALLY_SESSION) }
// 42 calls it: one yes of four. Then the vote expires.
expect((await vote(fields)).status).toBe(200)
await env.DB.prepare("UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z'").run()
// 218 calls a fresh one — counting 42's old yes, that would be two of four... and 219's
// yes would make three and carry it. Without it, 219 makes two of four: a tie.
expect((await vote(fields, '218')).status).toBe(200)
expect((await vote(fields, '219')).status).toBe(200)
expect(await inSession(217, TALLY_SESSION)).toBe(true)
expect((await ballots(TALLY_SESSION, 217)).map((b) => b.init)).toEqual([1, 1, 0])
})
})
describe('player reports', () => {
@@ -4655,6 +6413,235 @@ describe('player reports', () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not
// bookkeeping — it is what the client's block screen counts the ban from (see
// `banBlockDetails`), which is why a lift has to clear it along with the flag.
test('banFromReport records the acting moderator and the instant it landed', async () => {
await submit({ PlayerIdReported: '216' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 216)
// An unactioned report carries neither.
expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null })
const before = Date.now()
const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 })
expect(banned!.banned_by_player_id).toBe(9001)
expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before)
// Stamped by the write, so it is the ban's own time rather than the report's.
expect(banned!.banned_at).not.toBe(row!.created_at)
// A lift clears all four together. Leaving `banned_at` behind on an unbanned row
// would read as a ban to anything checking the timestamp.
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({
banned: 0,
ban_expires: null,
banned_by_player_id: null,
banned_at: null,
})
})
// The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint
// on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`),
// which imports them, because the panel is a recflare addition with no counterpart in
// the real service while the SQL belongs with the table. Tested here, where the table
// and its migrations live.
describe('moderation reads', () => {
test('searchReports filters, orders newest-first and counts the full match', async () => {
const target = 3300
await createReport(env.DB, {
reporterPlayerId: 3301,
reportedPlayerId: target,
reportCategory: 101,
details: 'one',
})
await createReport(env.DB, {
reporterPlayerId: 3302,
reportedPlayerId: target,
reportCategory: 102,
details: 'two',
})
await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 })
const byPlayer = await searchReports(env.DB, { reportedPlayerId: target })
expect(byPlayer.total).toBe(2)
// `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological
// and is the primary key, where `created_at` is unindexed.
expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one'])
expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1)
expect(
(await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total
).toBe(1)
// `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so
// a pager can say "3–4 of 9" without asking twice.
const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 })
expect(page.reports).toHaveLength(1)
expect(page.total).toBe(2)
})
test('searchReports filters on the ban FLAG, expired bans included', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3311,
reportedPlayerId: 3310,
})
// Banned, and the ban has since run out — so it is not in force, but it IS a
// report that was actioned, which is what a moderator reviewing their own work
// is looking for.
await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0)
// An omitted filter is BOTH, not false.
expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1)
})
test('searchReports windows on created_at, with an exclusive upper bound', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3321,
reportedPlayerId: 3320,
})
const filed = '2024-06-15T12:00:00.000Z'
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(report.id, filed)
.run()
const inWindow = await searchReports(env.DB, {
reportedPlayerId: 3320,
from: '2024-06-01T00:00:00.000Z',
to: '2024-07-01T00:00:00.000Z',
})
expect(inWindow.total).toBe(1)
// `from` is inclusive, `to` exclusive — which is what makes day-boundary windows
// composable rather than double-counting the row on the seam.
expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0)
})
test('getTopReported ranks distinct reporters ahead of raw count', async () => {
// Four reports from one person, against one player…
for (let i = 0; i < 4; i++) {
await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 })
}
// …and three reports from three different people, against another.
for (const reporter of [3341, 3342, 3343]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 })
}
const rows = await getTopReported(env.DB, { minReports: 3 })
const feud = rows.find((r) => r.playerId === 3330)
const real = rows.find((r) => r.playerId === 3340)
expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 })
expect(real).toMatchObject({ reports: 3, distinctReporters: 3 })
// Fewer reports, but from more people — so ahead. One player filing twenty
// reports about someone they are feuding with must not top the queue.
expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!))
})
test('getTopReported windows, thresholds, and flags a ban in force', async () => {
for (const reporter of [3351, 3352, 3353]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 })
}
// Backdate them all a year, so only an all-time read sees them. The default
// window is 30 days precisely so the queue is who is a problem NOW — an account
// that collected reports last year and stopped would otherwise sit at the top
// forever.
await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350')
.bind(new Date(Date.now() - 365 * 86_400_000).toISOString())
.run()
expect(
(await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId)
).not.toContain(3350)
const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(allTime.map((r) => r.playerId)).toContain(3350)
expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false)
// A ban in force is decided in the same statement, so the queue can skip the
// ones already dealt with.
const [row] = await getReportsAgainst(env.DB, 3350)
await banFromReport(env.DB, row!.id)
const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
})
// The standing-bans list exists to catch MISTAKES, so it is ordered by when the ban
// landed: the one most likely to be wrong is the one just handed down. Ordering by
// severity instead would bury a fresh one-day ban under every permanent ban ever
// issued.
test('getBansInForce puts the most recently handed-down ban first', async () => {
const first = await createReport(env.DB, { reporterPlayerId: 3371, reportedPlayerId: 3370 })
const second = await createReport(env.DB, { reporterPlayerId: 3373, reportedPlayerId: 3372 })
const third = await createReport(env.DB, { reporterPlayerId: 3375, reportedPlayerId: 3374 })
// Handed down oldest-first, and deliberately mixed in severity: the permanent one
// is banned FIRST, so an order by "longest lasting" would float it to the top.
await banFromReport(env.DB, first.id)
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(first.id, '2024-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, second.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(second.id, '2024-06-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, third.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(third.id, '2025-01-01T00:00:00.000Z')
.run()
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [first.id, second.id, third.id].includes(id))
expect(ordered).toEqual([third.id, second.id, first.id])
})
// A ban set before 0020 added `banned_at` has only its report's date to sort by —
// the same fallback the block screen uses. Without the coalesce every such row would
// sort as NULL: all at one end, in no order at all.
test('getBansInForce falls back to created_at for a ban with no banned_at', async () => {
const old = await createReport(env.DB, { reporterPlayerId: 3381, reportedPlayerId: 3380 })
const recent = await createReport(env.DB, { reporterPlayerId: 3383, reportedPlayerId: 3382 })
// The pre-migration shape: banned, with no record of when.
await env.DB.prepare(
'UPDATE report SET banned = 1, banned_at = NULL, created_at = ?2 WHERE id = ?1'
)
.bind(old.id, '2023-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, recent.id)
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [old.id, recent.id].includes(id))
// The undated one sorts by its report's date, so it lands behind today's ban
// rather than at an arbitrary end of the list.
expect(ordered).toEqual([recent.id, old.id])
})
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
const permanent = await createReport(env.DB, {
reporterPlayerId: 3361,
reportedPlayerId: 3360,
})
const expired = await createReport(env.DB, {
reporterPlayerId: 3363,
reportedPlayerId: 3362,
})
await banFromReport(env.DB, permanent.id, { bannedBy: 9002 })
await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' })
const ids = (await getBansInForce(env.DB)).map((b) => b.id)
expect(ids).toContain(permanent.id)
// A ban that has served its time is not in force, though the row stays as the
// record that it happened — the same rule `getActiveBan` applies.
expect(ids).not.toContain(expired.id)
const one = await getReportById(env.DB, permanent.id)
expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 })
expect(await getReportById(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', () => {
@@ -4706,10 +6693,9 @@ describe('player reports', () => {
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.
// `Duration` is the seconds LEFT — the client counts it down from receipt. A permanent
// ban runs for the largest span the int holds, and `TimeoutStartedAt` stays null: the
// client does not pair it with `Duration` for a ban.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
@@ -4727,26 +6713,78 @@ describe('player reports', () => {
// 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 report’s created_at plus the span to expiry', async () => {
// A fresh timed ban: the seconds left are (within the write's latency) the whole ban.
test('describes a timed ban as the seconds left as of the request', 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)
const banExpires = new Date(Date.now() + 3600 * 1000)
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
expect(await details('GET', '222')).toEqual({
...NOT_BLOCKED,
// `Duration` is asserted as a range below, so it is left out of the object match
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '222')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 103,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
TimeoutStartedAt: null,
})
// Within a second of the hour: however long the write took has already elapsed.
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// The case the bug lived in: a player signing in AFTER the ban was handed down. The
// client counts `Duration` down from the moment it receives it, so it must be what is
// left NOW — not the ban's full span from `banned_at`, which read as the whole ban
// still to run at every sign-in (and only looked right in the mid-session kick frame,
// sent at the instant the span and the remainder are the same).
test('counts down: a ban handed down a day ago has a day less to run', async () => {
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 224)
// A seven-day ban handed down yesterday: six days left.
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 6 * 86_400_000).toISOString(),
})
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(row!.id, new Date(Date.now() - 86_400_000).toISOString())
.run()
const body = (await details('GET', '224')) as Record<string, unknown>
expect(body.IsBan).toBe(true)
expect(body.Duration).toBeGreaterThan(6 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(6 * 86_400)
// The start is not on the wire at all: null, not `banned_at`.
expect(body.TimeoutStartedAt).toBeNull()
expect(banned!.banned_at).not.toBeNull()
})
// A row banned BEFORE 0020 added `banned_at` reads the same as any other: the
// remainder needs only `ban_expires`.
test('reads a ban with no recorded banned_at the same way', async () => {
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 225)
await env.DB.prepare(
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
)
.bind(row!.id, new Date(Date.now() + 3600 * 1000).toISOString())
.run()
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '225')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 101,
IsBan: true,
Message: 'Rule violation',
})
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// A ban that has served its time is not a block, even though the row still says
@@ -4884,7 +6922,7 @@ describe('rooms', () => {
describe('images', () => {
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
const bytes = new Uint8Array([...PNG_BYTES, 1, 2, 3, 4])
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
@@ -4909,14 +6947,50 @@ describe('images', () => {
// A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number }
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42)
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
expect(typeof meta.SavedImageId).toBe('number')
// `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
expect(typeof meta.Id).toBe('number')
expect(meta.CheerCount).toBe(0)
})
// The part's filename and content-type are the uploader's claims, and the `img`
// worker serves the object under whatever type is stored — so an HTML body under an
// image name would be a page on the image origin. The bytes decide, and only images
// get in.
test('POST /api/images/v4/uploadsaved refuses a file whose bytes are not an image', async () => {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
fd.append('image', new File(['<script>alert(1)</script>'], 'photo.jpg', { type: 'image/jpeg' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'image is not a PNG or JPEG image' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
})
test('POST /api/images/v4/uploadsaved stores the type and extension the bytes say, not the claim', async () => {
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
// A real PNG posted as a JPEG under an HTML name.
fd.append('image', new File([PNG_BYTES], 'photo.html', { type: 'text/html' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(200)
const { ImageName } = (await res.json()) as { ImageName: string }
expect(ImageName).toMatch(/\.png$/)
const stored = await env.IMAGES.get(ImageName)
expect(stored?.httpMetadata?.contentType).toBe('image/png')
})
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -4960,6 +7034,37 @@ describe('images', () => {
})
})
// The feed is anonymous, so a private photo (Accessibility 0) must never reach it —
// there is no viewer it could be unlocked for. It once accepted 0 alongside 1.
test('GET /api/images/v1/slideshow leaves private photos out', async () => {
const pub = await createImage(env.DB, {
imageName: 'slidepublic.jpg',
playerId: 42,
accessibility: 1,
})
const priv = await createImage(env.DB, {
imageName: 'slideprivate.jpg',
playerId: 42,
accessibility: 0,
})
// A non-photo (room thumbnail) that happens to be public stays out too.
const thumb = await createImage(env.DB, {
imageName: 'slidethumb.jpg',
playerId: 42,
type: 3,
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow?take=100`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as { Images: Array<{ SavedImageId: number }> }).Images.map(
(i) => i.SavedImageId
)
expect(ids).toContain(pub.Id)
expect(ids).not.toContain(priv.Id)
expect(ids).not.toContain(thumb.Id)
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
@@ -5001,20 +7106,37 @@ describe('images', () => {
// Request order, not id order — the client lines the answers up with what it asked for.
const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id])
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the
// SavedImageId/SavedImageType projection the player photo lists serve.
expect(both.map((i) => i.SavedImageId)).toEqual([two.Id, one.Id])
// The ImagesPlayer projection plus ClubId — not the raw SavedImage (`Id`/`Type`,
// `TaggedPlayerIds`).
expect(Object.keys(both[0]).sort()).toEqual(
[
'SavedImageId',
'ImageName',
'PlayerId',
'RoomId',
'PlayerEventId',
'ClubId',
'Description',
'Accessibility',
'AccessibilityLocked',
'SavedImageType',
'CreatedAt',
'CheerCount',
'CommentCount',
].sort()
)
expect(both[0]).toMatchObject({
Id: two.Id,
Type: two.Type,
SavedImageId: two.Id,
SavedImageType: two.Type,
ImageName: 'bulktwo.jpg',
PlayerId: 7102,
TaggedPlayerIds: [],
ClubId: null,
})
// An unknown id and a non-public one are absent rather than errors or holes, so the
// answer can be shorter than the request.
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
])
@@ -5023,7 +7145,7 @@ describe('images', () => {
// More ids than D1 will bind in one query (100) — the lookup has to split.
const many = [...Array.from({ length: 130 }, (_, i) => 800000 + i), one.Id, two.Id]
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
two.Id,
])
@@ -5121,6 +7243,13 @@ describe('images', () => {
// The setting is per-player.
expect(await (await read('710')).text()).toBe('2')
// Re-posting the stored value writes nothing to KV: the raw value is seeded with
// whitespace JSON.stringify never produces, and it survives the PUT untouched.
const padded = '{ "Recroom.OOBE": "77", "playerPhotoTaggingSetting": "1" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:712', padded)
expect(await (await write('712', { Setting: 1 })).text()).toBe('1')
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:712', 'text')).toBe(padded)
// A body with no readable Setting leaves the stored value alone rather than writing 0
// — and answers what the player still has.
expect(await (await write('710', { Nothing: true })).text()).toBe('2')
@@ -5220,7 +7349,7 @@ describe('images', () => {
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
})
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => {
test('GET /api/images/v6 serves the raw SavedImage by name', async () => {
const img = await createImage(env.DB, {
imageName: 'v6shape.jpg',
playerId: 7301,
@@ -5228,24 +7357,98 @@ describe('images', () => {
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
expect(res.status).toBe(200)
// The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the
// same record `v6/:id` serves.
expect(await res.json()).toEqual({
SavedImageId: img.Id,
Id: img.Id,
ImageName: 'v6shape.jpg',
Description: null,
PlayerId: 7301,
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
RoomId: 0,
PlayerEventId: 0,
ClubId: 0,
Description: '',
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
SavedImageType: 1,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
})
test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => {
const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 })
const report = async (id: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, {
method: 'POST',
headers: sub ? await bearer(sub) : undefined,
})
expect((await report(img.Id)).status).toBe(401)
const unknown = await report(99999999, '7311')
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such image' })
const res = await report(img.Id, '7311')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1')
.bind(img.Id)
.all()
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
reporter_player_id: 7311,
reported_player_id: 7310,
image_id: img.Id,
report_category: 0,
details: null,
event_id: null,
invention_id: null,
custom_avatar_item_id: null,
chat_message_id: null,
})
})
test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => {
const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 })
const get = async (id: number, sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/images/v6/${id}`,
sub ? { headers: await bearer(sub) } : undefined
)
// The 13-key record, nulls left null.
const res = await get(img.Id)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Id: img.Id,
ImageName: 'v6byid.jpg',
Description: null,
PlayerId: 7302,
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
expect((await get(99999999)).status).toBe(404)
// Made private, it is its owner's alone — ids are sequential.
await env.DB.prepare(
"UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1"
)
.bind(img.Id)
.run()
expect((await get(img.Id)).status).toBe(404)
expect((await get(img.Id, '7303')).status).toBe(404)
expect((await get(img.Id, '7302')).status).toBe(200)
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect(
@@ -5266,7 +7469,7 @@ describe('images', () => {
accessibility: 2,
})
)
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
fd.append('image', new File([PNG_BYTES], 'pic.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
@@ -5277,23 +7480,20 @@ describe('images', () => {
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as {
SavedImageType: number
Type: number
RoomId: number
Accessibility: number
PlayerEventId: number
ClubId: number
Description: string
PlayerEventId: number | null
TaggedPlayerIds: number[]
}
expect(meta.SavedImageType).toBe(1)
expect(meta.Type).toBe(1)
expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2)
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
// null, and the club (which nothing here sets) reads 0 too.
expect(meta).not.toHaveProperty('TaggedPlayerIds')
expect(meta.PlayerEventId).toBe(0)
expect(meta.ClubId).toBe(0)
expect(meta.Description).toBe('')
// v6 serves the stored record as it is: the tagged players ride along, a "none"
// event is null rather than 0, and there is no `ClubId`.
expect(meta.TaggedPlayerIds).toEqual([5, 6])
expect(meta.PlayerEventId).toBeNull()
expect(meta).not.toHaveProperty('ClubId')
// The tagged players and the null event id, as actually stored.
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
@@ -5304,7 +7504,7 @@ describe('images', () => {
PlayerEventId: number | null
}
expect(stored.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null, and serialized back out as 0.
// playerEventId 0 means "none" → stored as null.
expect(stored.PlayerEventId).toBeNull()
})
@@ -5334,7 +7534,7 @@ describe('images', () => {
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
test('POST /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -5362,7 +7562,7 @@ describe('images', () => {
const del = (headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ImageName }),
})
@@ -5374,7 +7574,7 @@ describe('images', () => {
// Unknown image → 404.
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
})
@@ -5390,6 +7590,113 @@ describe('images', () => {
expect(cheers!.n).toBe(0)
})
/** Seed one image row owned by the default bearer account (42). */
const seedOwnedImage = async (
Id: number,
ImageName: string,
extra: Record<string, unknown> = {}
) =>
env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 42,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
...extra,
})
)
.run()
const postImageJson = (path: string, headers: Record<string, string>, body: unknown) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
test('POST /api/images/v2/modifyaccessibility flips the owner’s image and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/flip-me.jpg'
await seedOwnedImage(8101, ImageName)
const path = '/api/images/v2/modifyaccessibility'
// No token → 401; a different account → 403; the image is untouched.
expect((await postImageJson(path, {}, { ImageName, Accessibility: 0 })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Accessibility: 0 })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// Unknown image → 404; a bad accessibility or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Accessibility: 0 }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 7 })).status).toBe(400)
expect((await postImageJson(path, owner, { Accessibility: 0 })).status).toBe(400)
// Owner → 200 and the record is now private; back to 1 makes it public again.
const res = await postImageJson(path, owner, { ImageName, Accessibility: 0 })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(0)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 1 })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// A locked image refuses even its owner.
const locked = 'sharecamera/2026-09-13/locked.jpg'
await seedOwnedImage(8102, locked, { AccessibilityLocked: true, Accessibility: 0 })
expect((await postImageJson(path, owner, { ImageName: locked, Accessibility: 1 })).status).toBe(
403
)
expect((await getImageByName(env.DB, locked))!.Accessibility).toBe(0)
})
test('POST /api/images/v1/modifydescription sets the owner’s caption and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/caption-me.jpg'
await seedOwnedImage(8103, ImageName)
const path = '/api/images/v1/modifydescription'
// No token → 401; a different account → 403; the caption is untouched.
expect((await postImageJson(path, {}, { ImageName, Description: 'x' })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Description: 'x' })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
// Unknown image → 404; a non-string description or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Description: 'x' }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Description: 5 })).status).toBe(400)
expect((await postImageJson(path, owner, { Description: 'x' })).status).toBe(400)
// Owner → 200 and the caption is stored; the rest of the record is untouched.
const res = await postImageJson(path, owner, { ImageName, Description: 'tet' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
const after = await getImageByName(env.DB, ImageName)
expect(after!.Description).toBe('tet')
expect(after!.Accessibility).toBe(1)
expect(after!.PlayerId).toBe(42)
// An empty description clears the caption back to null.
expect((await postImageJson(path, owner, { ImageName, Description: '' })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
})
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
const fd = new FormData()
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
@@ -5520,6 +7827,10 @@ describe('images', () => {
}),
// Unrelated to 700 → in neither.
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
// 700's outfit thumbnail: an image under their id, but not a photo → in neither,
// not even in their own view (207 is private, as the client stores such things).
seed({ Id: 200, PlayerId: 700, Type: 2, CreatedAt: '2026-06-01T00:00:00.000Z' }),
seed({ Id: 207, PlayerId: 700, Type: 3, Accessibility: 0 }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
@@ -5571,6 +7882,44 @@ describe('images', () => {
expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
).toEqual([])
// The owner's view: 700 asking for their own list gets the private 203 too, on v4,
// v5 and the feed alike. 203 has the seed's default CreatedAt (January), so it sorts
// last — the private photo takes its place in the order, it isn't pinned anywhere.
const own = await bearer('700')
for (const path of ['v4/player/700', 'v5/player/700?sort=0']) {
const list = (await (
await exports.default.fetch(`${ORIGIN}/api/images/${path}`, { headers: own })
).json()) as Array<ImagesPlayer & { Accessibility: number }>
expect(list.map((i) => i.SavedImageId)).toEqual([202, 201, 203])
expect(list[2]?.Accessibility).toBe(0)
}
const ownFeed = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
).json()) as ImagesPlayer[]
expect(ownFeed.map((i) => i.SavedImageId)).toEqual([204, 202, 201, 203])
// Being tagged in someone else's private photo does NOT surface it in the feed —
// the taker decides its privacy, not the tag.
await seed({ Id: 206, PlayerId: 999, TaggedPlayerIds: [700], Accessibility: 0 }).run()
const ownFeedAgain = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
).json()) as ImagesPlayer[]
expect(ownFeedAgain.map((i) => i.SavedImageId)).not.toContain(206)
// Anyone else's token — a friend, staff — is the public list, same as no token.
const other = await bearer('999', ['developer', 'moderator'])
const asOther = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { headers: other })
).json()) as ImagesPlayer[]
expect(asOther.map((i) => i.SavedImageId)).toEqual([202, 201])
// A garbage token isn't a 401 on a public list; it reads as nobody.
const junk = await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, {
headers: { Authorization: 'Bearer not.a.jwt' },
})
expect(junk.status).toBe(200)
expect(((await junk.json()) as ImagesPlayer[]).map((i) => i.SavedImageId)).toEqual([202, 201])
})
})
@@ -5834,8 +8183,11 @@ describe('relationships', () => {
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
// Both sides hear about it, each seeing the other player and their own side's
// type: the sender Sent (1), the recipient Received (2).
expect(await sentNotifications()).toEqual([
// type: the sender Sent (1), the recipient Received (2). The target ALSO gets the
// request itself as a FriendInvite (4) message — that is what the client shows.
const sent = await sentNotifications()
expect(sent).toHaveLength(3)
expect(sent.slice(0, 2)).toEqual([
{
playerId: 770,
notificationType: 1,
@@ -5847,6 +8199,34 @@ describe('relationships', () => {
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
expect(sent[2]?.playerId).toBe(771)
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null })
// The message is stored under the same id, so an offline target finds it on login.
const frameId = (sent[2]?.data as unknown as { Id: number }).Id
expect(frameId).toBeGreaterThan(0)
const inbox = (await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') })
).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }>
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 })
})
test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
await resetNotifications()
// Re-sending an outstanding request is a no-op: nothing is pushed at all.
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
expect(await sentNotifications()).toEqual([])
// The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester
// (772) is told their request was accepted rather than asked anything.
await mutate('/api/relationships/v2/sendfriendrequest', '773', 772)
const sent = await sentNotifications()
expect(sent).toHaveLength(3)
expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true)
expect(sent[2]?.playerId).toBe(772)
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 })
})
test('accepting notifies both players as Friend', async () => {
@@ -5854,8 +8234,13 @@ describe('relationships', () => {
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
const all = await sentNotifications()
expect(all).toHaveLength(3)
// The requester also gets the acceptance as a FriendRequestAccepted (40) message.
expect(all[2]?.playerId).toBe(780)
expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null })
const sent = all.slice(0, 2)
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
expect(sent).toEqual(
expect.arrayContaining([
@@ -5902,8 +8287,10 @@ describe('relationships', () => {
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
expect(sent).toHaveLength(3)
for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3)
// ...and 820, whose request this crossed, is told it was accepted.
expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } })
})
})
@@ -6594,10 +8981,10 @@ describe('player events', () => {
expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
// is names to every build.
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
// caller's build shape — this token names no build, so the 2023 bare names (a newer
// build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every
// build.
expect(body.PlayerEvent.Tags).toEqual(['music'])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State')
@@ -6606,7 +8993,7 @@ describe('player events', () => {
test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => {
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
// shapes: the 2023 build parses bare names, anything newer `{ Tag, Type }` pairs.
// Serving either to the wrong build empties the event's chips instead of erroring.
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
// four events below would join any set they share a tag with.
@@ -6626,16 +9013,18 @@ describe('player events', () => {
return (await res.json()) as PlayerEventResult
}
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
// strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
// Newer than 20230414 gets the pairs; that build, an older one, and a token naming
// no build at all are all the 2023 client and get names. Builds are date-stamped, so
// they compare as strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES)
const legacy = await created()
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
// Only the inline field moves: TagModifyResult carries names to both builds.
expect(legacy.PlayerEvent.Tags).toEqual(NAMES)
// Only the inline field moves: TagModifyResult carries names to both builds — it
// really is a List<string> on the client, so the pairs must never land there.
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
expect((await created('20250718.01')).TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
// the same shape, so a client that made an event and one opening it cold agree.
@@ -6648,8 +9037,8 @@ describe('player events', () => {
})
).json()) as PlayerEventResult
).PlayerEvent.Tags
expect(await read('20250718.01')).toEqual(NAMES)
expect(await read()).toEqual(PAIRS)
expect(await read('20250718.01')).toEqual(PAIRS)
expect(await read()).toEqual(NAMES)
})
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
@@ -6867,6 +9256,69 @@ describe('player events', () => {
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1/search?sort=Attendance ranks by Going replies', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
// Three events sharing a searchable word, created soonest-first so the default
// order is the OPPOSITE of the attendance order — the sort has to do something.
const quiet = await create({ RoomId: 3, Name: 'Sortable Quiet', StartTime: at(2 * HOUR) })
const busy = await create({ RoomId: 3, Name: 'Sortable Busy', StartTime: at(3 * HOUR) })
const packed = await create({ RoomId: 3, Name: 'Sortable Packed', StartTime: at(4 * HOUR) })
// Every creator is Going from create (1 each). `busy` gains one more Going; `packed`
// gains two. Interested and Can't-go on `quiet` don't move it: only a Yes counts.
for (const [id, type, sub] of [
[busy.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '44'],
[quiet.PlayerEventId, 1, '43'],
[quiet.PlayerEventId, 1, '44'],
[quiet.PlayerEventId, 2, '45'],
] as const) {
expect((await respond(id, type, sub)).status).toBe(200)
}
// Default and `StartTime`: soonest first.
const soonest = [quiet.PlayerEventId, busy.PlayerEventId, packed.PlayerEventId]
expect((await search('?query=sortable')).map((e) => e.PlayerEventId)).toEqual(soonest)
expect((await search('?query=sortable&sort=StartTime')).map((e) => e.PlayerEventId)).toEqual(
soonest
)
// `Attendance`: most Going first (3, 2, 1), case-insensitively.
const byAttendance = await search('?query=sortable&sort=Attendance')
expect(byAttendance.map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
])
expect(byAttendance.map((e) => e.AttendeeCount)).toEqual([3, 2, 1])
expect((await search('?query=sortable&sort=attendance')).map((e) => e.PlayerEventId)).toEqual(
byAttendance.map((e) => e.PlayerEventId)
)
// A tie in attendance falls back to soonest first: another 1-Going event, later
// than `quiet`, lands after it.
const late = await create({ RoomId: 3, Name: 'Sortable Late', StartTime: at(5 * HOUR) })
expect((await search('?query=sortable&sort=Attendance')).map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
late.PlayerEventId,
])
// Paging walks the sorted order, and an unknown sort is the default, not a 400.
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([byAttendance[1]])
const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity')
expect(unknown.status).toBe(200)
expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([
...soonest,
late.PlayerEventId,
])
})
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
const res = await get('/api/playerevents/v1')
expect(res.status).toBe(200)
@@ -7087,6 +9539,77 @@ describe('player events', () => {
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => {
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
interface Profile {
Created: PlayerEvent[]
Responses: Array<{
PlayerEvent: Record<string, unknown>
PlayerEventResponse: Record<string, unknown>
}>
}
const profile = async (playerId: number | string): Promise<Profile> => {
// No token: the read is public.
const res = await get(`/api/playerevents/v1/all/${playerId}`)
expect(res.status).toBe(200)
return (await res.json()) as Profile
}
// 610 hosts a public event, a private one and a finished one; 611 is Going to the
// first two, Interested in a third public one, and went to the finished one.
const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610')
const closed = await create(
{ RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 },
'610'
)
const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610')
const over = await create(
{ RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) },
'610'
)
await respond(open.PlayerEventId, 0, '611')
await respond(closed.PlayerEventId, 0, '611')
await respond(maybe.PlayerEventId, 1, '611')
await respond(over.PlayerEventId, 0, '611')
// The attendee: exactly the public event they are Going to, as the pair.
const guest = await profile(611)
expect(guest.Created).toEqual([])
expect(guest.Responses).toHaveLength(1)
const [entry] = guest.Responses
expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse'])
expect(entry.PlayerEventResponse).toEqual({
PlayerEventResponseId: expect.any(Number),
PlayerEventId: open.PlayerEventId,
PlayerId: 611,
CreatedAt: expect.any(String),
Type: 0,
})
// The 17-key base event: no `State`, `ImageName` a string, the broadcast key present.
expect(Object.keys(entry.PlayerEvent)).toHaveLength(17)
expect(entry.PlayerEvent).toMatchObject({
PlayerEventId: open.PlayerEventId,
Name: 'Profile Open',
AttendeeCount: 2,
Accessibility: 1,
ImageName: '',
BroadcastingRoomInstanceId: null,
})
expect(entry.PlayerEvent).not.toHaveProperty('State')
// The host: their public unfinished events, soonest first, under both keys.
const host = await profile(610)
const ids = [maybe.PlayerEventId, open.PlayerEventId]
expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids)
expect(Object.keys(host.Created[0])).toHaveLength(17)
expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids)
// Nobody's events, and a non-numeric id doesn't match.
expect(await profile(999999)).toEqual({ Created: [], Responses: [] })
expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404)
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
@@ -7242,7 +9765,7 @@ describe('player events', () => {
).toBe(401)
})
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
test('POST /api/playerevents/v1/bulkInvite adds invitees as Pending without overwriting answers', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
@@ -7258,8 +9781,9 @@ describe('player events', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// The creator plus the two newly invited — 43 keeps their decline, so isn't counted.
expect(body.PlayerEvent.AttendeeCount).toBe(3)
// Only the creator: an invite is Pending, not Going, so the two newly invited don't
// count until they answer — and 43 keeps their decline.
expect(body.PlayerEvent.AttendeeCount).toBe(1)
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
PlayerId: number
@@ -7268,10 +9792,10 @@ describe('player events', () => {
expect(
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
).toEqual([
[2, 0],
[2, 3],
[42, 0],
[43, 2],
[187, 0],
[187, 3],
])
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
@@ -7280,14 +9804,23 @@ describe('player events', () => {
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
'42'
)
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
// An empty list is a no-op that still answers the event.
const none = await post('/api/playerevents/v1/bulkInvite', {
PlayerEventId: id,
InvitedPlayerIds: [],
})
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
// Accepting the invite is an ordinary respond: Pending becomes Going and counts.
const accepted = await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
expect(((await accepted.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(2)
// Pending is the server's mark, not an answer a player can give.
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 3 }, '187')).status
).toBe(400)
})
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
@@ -7305,17 +9838,57 @@ describe('player events', () => {
data: Record<string, Record<string, unknown>>
}>
// One frame, to the one player who gained a row. 43 kept their answer, so nothing
// ONE frame, to the one player who gained a row: the invitation Message. No
// PlayerEventResponseChanged — a Pending row is not a response; that frame is the
// responder's, sent by `respond` once they answer. 43 kept their answer, so nothing
// changed for them and nothing is pushed.
expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(2) // MessageReceived
// A PlayerEventInvitation (81) Message from the inviter, naming the event on
// PlayerEventId and as a string in Data.
expect(sent[0]!.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 81,
Data: String(id),
PlayerEventId: id,
})
// Stored under the same id, so an offline invitee finds it on login.
const frameId = (sent[0]!.data as unknown as { Id: number }).Id
expect(frameId).toBeGreaterThan(0)
const inbox = (await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('2') })
).json()) as Array<{ Id: number; Type: number; PlayerEventId: number | null }>
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ Type: 81, PlayerEventId: id })
})
test('POST /api/playerevents/v1/respond pushes PlayerEventResponseChanged to the responder', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'RSVP Frames', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2] })
// The invitee accepts: THIS is where their response frame comes from — the frame
// that populates the event on their client — carrying their row as it now stands.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, Record<string, unknown>>
}>
expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged
// BOTH nested objects are present — the client dereferences them without a null
// guard, so a missing one is a NullReferenceException rather than a blank field.
// The event carries the count as recomputed AFTER the answer.
expect(sent[0]!.data.PlayerEvent).toMatchObject({
playerEventId: id,
name: 'Invite Frames',
name: 'RSVP Frames',
attendeeCount: 2,
})
expect(sent[0]!.data.PlayerEventResponse).toEqual({
@@ -7325,6 +9898,106 @@ describe('player events', () => {
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
Type: 0,
})
// Changing the answer pushes again, with the same row id and the new Type — the
// upsert updates in place, so the frame's id matches what the RSVP list serves.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '2')
const again = (await (await hub.fetch('http://do/all')).json()) as typeof sent
expect(again).toHaveLength(1)
expect(again[0]!.data.PlayerEventResponse).toMatchObject({
PlayerEventResponseId: sent[0]!.data.PlayerEventResponse!.PlayerEventResponseId,
Type: 2,
})
expect(again[0]!.data.PlayerEvent).toMatchObject({ attendeeCount: 1 })
})
test('a deleted event’s id is never reused by a later create', async () => {
// Ids used to be MAX(id) + 1 over the table, so deleting the newest event handed its
// id to the next one created — which then inherited everything still filed under
// that id outside the event's own rows (an invitation message, the client's cache).
const first = await create({ RoomId: 3, Name: 'First', StartTime: at(HOUR) })
expect(
(await post(`/api/playerevents/v2/delete/${first.PlayerEventId}`, undefined)).status
).toBe(200)
const second = await create({ RoomId: 3, Name: 'Second', StartTime: at(HOUR) })
expect(second.PlayerEventId).toBeGreaterThan(first.PlayerEventId)
// And the replacement starts with its own guest list: just its creator.
const responses = (await (
await get(`/api/playerevents/v1/${second.PlayerEventId}/responses`)
).json()) as Array<{ PlayerId: number }>
expect(responses.map((r) => r.PlayerId)).toEqual([42])
})
test('editing an event pushes PlayerEventUpdated to everyone Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Before', StartTime: at(HOUR) })
const id = event.PlayerEventId
// 2 is Going, 43 is Interested, 44 declined, 45 is invited and still Pending.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '44')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
const frames = async () =>
(await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The whole-event update: the creator (Going from create) and 2 hear about it, with
// the event as it now stands in the camelCase projection PlayerEventCreated uses.
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/${id}`, { Name: 'After' })
expect(res.status).toBe(200)
const sent = await frames()
expect(sent.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81], // PlayerEventUpdated
[42, 81],
])
for (const frame of sent) {
expect(frame.data).toMatchObject({ playerEventId: id, name: 'After', attendeeCount: 2 })
}
// A single-field edit pushes the same frame to the same audience.
await hub.fetch('http://do/all', { method: 'DELETE' })
const renamed = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${id}/name`, {
method: 'PUT',
headers: { ...(await bearer('42')), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'Renamed' }),
})
expect(renamed.status).toBe(200)
const again = await frames()
expect(again.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81],
[42, 81],
])
expect(again[0]!.data).toMatchObject({ name: 'Renamed' })
})
test('deleting an event pushes PlayerEventDeleted to everyone who was Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Doomed', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/delete/${id}`, undefined)
expect(res.status).toBe(200)
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The audience is read before the RSVP rows are deleted with the event; the frame
// is just the id, there being no event left to carry.
expect(sent).toEqual([
{ playerId: 2, notificationType: 82, data: { PlayerEventId: id } }, // PlayerEventDeleted
{ playerId: 42, notificationType: 82, data: { PlayerEventId: id } },
])
})
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
@@ -7591,11 +10264,9 @@ describe('player events', () => {
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
// Names, since this token names no build (a newer build would get the pairs).
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual([
{ Tag: 'class', Type: 0 },
{ Tag: 'tag1', Type: 0 },
])
expect(tagged.Tags).toEqual(['class', 'tag1'])
// The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
@@ -7651,6 +10322,8 @@ describe('player events', () => {
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
})
// One create, fourteen gated calls, and a read. The default 5s budget is what
// this crossed on CI; the checks themselves are the same creator gate as above.
test('the single-field edits are creator-only, like the whole-event update', async () => {
const event = await create({ RoomId: 5, Name: 'Guarded' })
const id = event.PlayerEventId
@@ -7671,7 +10344,7 @@ describe('player events', () => {
// Nothing moved.
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
})
}, 20_000)
})
describe('openapi', () => {
@@ -7699,7 +10372,6 @@ describe('openapi', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /api/customAvatarItems/v1/{id}',
'DELETE /api/images/v1/deletesaved',
'DELETE /api/playerevents/v2/delete/{eventId}',
'GET /api/CircuitChipLists/{list}',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
@@ -7731,6 +10403,7 @@ describe('openapi', () => {
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/images/v6/{id}',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
@@ -7756,6 +10429,7 @@ describe('openapi', () => {
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/all/{playerId}',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
@@ -7767,9 +10441,13 @@ describe('openapi', () => {
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/playerevents/v2/{eventId}',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/xpEarnedToday',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/progressionEvents/active',
'GET /api/progressionEvents/event/{id}',
'GET /api/progressionEvents/record/{id}',
'GET /api/purchasableXpBoosts/activations',
'GET /api/quickPlay/v1/getandclear',
'GET /api/referee/files',
'GET /api/relationships/mutualfriends',
@@ -7800,16 +10478,22 @@ describe('openapi', () => {
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v1/roomModKick',
'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate',
'POST /api/chatreport/createChatReport',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/customAvatarItems/v1/{id}/report',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v1/deletesaved',
'POST /api/images/v1/modifydescription',
'POST /api/images/v1/{id}/report',
'POST /api/images/v2/modifyaccessibility',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
@@ -7817,7 +10501,10 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/cheer',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/unpublish',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
@@ -7850,6 +10537,7 @@ describe('openapi', () => {
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/sanitize/v2/isAllowed/bulk',
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
@@ -0,0 +1,338 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/api/customAvatarItems'
async function headers(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...UNITY_ASSET_SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
])
await env.DB.prepare(sql).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
})
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
const foreign = new FormData()
foreign.set(
'metadata',
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
)
expect(
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
.status
).toBe(403)
const { value: item } = await (
await create(42, ['betastudio'], 1000)
).json<{ value: { CustomAvatarItemId: string } }>()
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
const main = 'b'.repeat(40)
const bytes = new TextEncoder().encode(
JSON.stringify({
Version: 3,
MainAssetProjectRelativePath: 'Assets/Badge.png',
Files: [{ n: main, p: 'Assets/Badge.png' }],
})
)
const digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('unityasset/' + main, png)
await env.CDN_ASSETS.put('image/badge.png', png, {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
NewUnityAssetFile: {
Filename: 'badge-source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 4,
ThumbnailFileName: 'badge.png',
AdditionalConfiguration: JSON.stringify({
v: 1,
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
}),
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(save.status).toBe(200)
const { value: saveId } = await save.json<{ value: number }>()
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
headers: await headers(),
})
expect(await detail.json()).toMatchObject({
ThumbnailImageFilename: 'badge.png',
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(200)
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({ Accessibility: 1 }),
})
expect(published.status).toBe(200)
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
})
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
)
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
}
it('accepts the SDK create request with nullable metadata fields', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'SDK item',
OutfitType: 101,
BaseAvatarItemColor: '#101',
BadgeRoomId: null,
PreviewOrientation: null,
Description: null,
Tags: null,
Price: null,
Accessibility: null,
})
)
const response = await SELF.fetch(base + '/v1', {
method: 'POST',
headers: await headers(),
body: form,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
})
})
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
expect((await create(43)).status).toBe(403)
expect((await create(42, [])).status).toBe(403)
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
const response = await create()
expect(response.status).toBe(200)
const { value: item } = await response.json<{
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
}>()
expect(item.OutfitType).toBe(0)
expect(item.CurrentSaves).toEqual([])
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
expect(
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
).toBe(200)
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
expect(await page.json()).toMatchObject({
TotalResults: 1,
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
})
})
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const bytes = new TextEncoder().encode('{"Version":3}'),
digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const body = {
NewUnityAssetFile: {
Filename: '2026-10-18/source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 1,
ThumbnailFileName: 'thumb.png',
AdditionalConfiguration: '{}',
UpdateCurrentSaveIfPrivate: true,
}
const post = async (value: unknown, id = 42) =>
SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
expect((await post(body, 43)).status).toBe(403)
expect(
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
.status
).toBe(400)
const response = await post(body)
expect(response.status).toBe(200)
const { value: saveId } = await response.json<{ value: number }>()
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
const history = await SELF.fetch(url, { headers: await headers() })
expect(await history.json()).toMatchObject({
success: true,
value: {
TotalResults: 1,
Results: [
{
CustomAvatarItemSaveId: saveId,
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
UnityAsset: null,
},
],
},
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(400)
expect(
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
.status
).toBe(400)
})
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const edit = async (body: unknown, id = 42) =>
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
expect((await edit({ Accessibility: 1 })).status).toBe(400)
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
const response = await edit({
Name: ' Updated hat ',
Description: 'Edited in Studio',
Tags: [{ TagType: 1, Value: 'hat' }],
CreatorAccountId: 43,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: {
Name: 'Updated hat',
Description: 'Edited in Studio',
CreatorAccountId: 42,
Accessibility: 0,
Tags: [{ TagType: 1, Value: 'hat' }],
},
})
})
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const asset = crypto.randomUUID()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
)
.bind(item.CustomAvatarItemId, asset)
.run()
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
)
.bind(asset)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = async (bodyType: number, config: string) => {
const response = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
ExistingUnityAssetId: asset,
BodyType: bodyType,
AdditionalConfiguration: config,
ThumbnailFileName: 'thumb.png',
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(response.status).toBe(200)
const { value: id } = await response.json<{ value: number }>()
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
}
const metadata = await save(1, '{}')
expect(metadata.unity_asset_id).toBe(asset)
const otherBody = await save(2, '{}'),
otherConfig = await save(1, '{"variant":2}')
for (const revision of [otherBody, otherConfig]) {
expect(revision.unity_asset_id).not.toBe(asset)
expect(revision.source_filename).toBe('same-source')
expect(revision.source_hash).toBe('same-hash')
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
}
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
)
.bind(otherBody.unity_asset_id)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
const stored = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(item.CustomAvatarItemId)
.first<{ data: string }>())!.data
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
'original.bundle'
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
'other-body.bundle'
)
})
@@ -0,0 +1,52 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { generateStudioBuildValidationToken, validateAndGetAccountId } from '@repo/jwt'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
})
it('allows build validation credentials to check text without granting an account identity', async () => {
const token = await generateStudioBuildValidationToken('123456', 'test-key')
const headers = { Authorization: 'Bearer ' + token }
expect(
await validateAndGetAccountId(new Request('https://example.com', { headers }), 'test-key')
).toBeNull()
const response = await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers,
body: new URLSearchParams('Values=hello&Values=fuck'),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false] },
})
expect(
(
await SELF.fetch('https://example.com/api/PlayerCheer/v1/create', {
method: 'POST',
headers,
body: new URLSearchParams(),
})
).status
).toBe(401)
})
it('rejects an invalid build validation signature', async () => {
const token = await generateStudioBuildValidationToken('123456', 'wrong-key')
expect(
(
await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: new URLSearchParams('Values=hello'),
})
).status
).toBe(401)
})
+178
View File
@@ -0,0 +1,178 @@
/**
* Vote-to-kick ballots on the shared `recflare` D1 database.
*
* One row per vote cast: who was voted on, in which game session, by whom, and which way.
* Append-only like the `report` and `warning` tables (see reports-db.ts), so a vote is a
* record of what happened rather than a tally that gets rewritten — the tally is computed
* from the rows.
*
* The rows outlive the vote, but not by much: the `rooms` worker's cron deletes ballots
* older than 5 minutes (its `ROOM_VOTE_RETENTION_MS`), which is the longest anything here
* looks back — `VOTE_CALL_COOLDOWN_MS`. Nothing deletes them when a session ends, and
* nothing needs to: a ballot is keyed to its `game_session_id`, and instance ids are not
* reused, so an old session's votes can never be counted into a new one's. Raising the
* cooldown means raising that retention too.
*
* The `api` worker owns this schema/migration (migrations/0023_room_vote.sql and
* 0026_room_vote_init.sql, applied under
* its own `migrations_table` so it doesn't clash with the other workers' migrations that
* share the database).
*/
/** Schema DDL (mirror of migrations/0023_room_vote.sql + 0026_room_vote_init.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
response INTEGER NOT NULL,
voter_id INTEGER NOT NULL,
voted_at TEXT NOT NULL,
init INTEGER NOT NULL DEFAULT 0
)`,
// The tally's own query: every ballot cast against one player in one session.
`CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id)`,
// The caller throttle's query: the last vote one player CALLED.
`CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1`,
]
/**
* How long a vote stays open once it is called. Every ballot posted inside it is an answer to
* THAT vote; the first one posted after it is a new vote, with a prompt of its own.
*/
export const VOTE_WINDOW_MS = 60_000
/** How long a player must wait after calling a vote before they may call another, anywhere. */
export const VOTE_CALL_COOLDOWN_MS = 5 * 60_000
/**
* One cast ballot. `response` is 1 for yes (kick them) and 0 for no. `init` is 1 on the ballot
* that CALLED the vote — the first cast against this player in this session once no vote was
* open — and 0 on every answer to it. The client posts both to the same endpoint and nothing
* in the body tells them apart, so the row is where a vote's start is recorded.
*/
export interface RoomVoteRow {
id: number
game_session_id: number
player_id: number
response: number
voter_id: number
voted_at: string
init: number
}
/** A ballot as cast — the timestamp is the table's. */
export interface NewRoomVote {
gameSessionId: number
/** The player being voted on. */
playerId: number
voterId: number
/** True to kick them. */
response: boolean
/** True when this ballot calls the vote rather than answering one. */
init: boolean
}
/** Record one cast ballot, returning the stored row. */
export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise<RoomVoteRow> {
const row = await db
.prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.gameSessionId,
input.playerId,
input.response ? 1 : 0,
input.voterId,
new Date().toISOString(),
input.init ? 1 : 0
)
.first<RoomVoteRow>()
// RETURNING always yields the inserted row.
return row!
}
/**
* The ballot that called the vote currently open on this player in this session — the latest
* `init` row, if it was cast inside `VOTE_WINDOW_MS` — or null when no vote is open.
*/
export async function getOpenVote(
db: D1Database,
gameSessionId: number,
playerId: number,
now = Date.now()
): Promise<RoomVoteRow | null> {
return db
.prepare(
`SELECT * FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2 AND init = 1 AND voted_at > ?3
ORDER BY id DESC LIMIT 1`
)
.bind(gameSessionId, playerId, new Date(now - VOTE_WINDOW_MS).toISOString())
.first<RoomVoteRow>()
}
/**
* Whether `voterId` called a vote — any vote, in any session — within `VOTE_CALL_COOLDOWN_MS`.
* Only CALLING is throttled: answering a vote someone else called is never refused.
*/
export async function isOnVoteCooldown(
db: D1Database,
voterId: number,
now = Date.now()
): Promise<boolean> {
const row = await db
.prepare(`SELECT 1 FROM room_vote WHERE voter_id = ?1 AND init = 1 AND voted_at > ?2 LIMIT 1`)
.bind(voterId, new Date(now - VOTE_CALL_COOLDOWN_MS).toISOString())
.first()
return row !== null
}
/**
* How many DISTINCT players have voted to kick this player out of this session.
*
* Distinct voters, not rows, and only each voter's LATEST ballot: the table is append-only,
* so a player who votes twice leaves two rows, and counting rows would let one person carry
* a vote on their own by posting it repeatedly. Counting their latest also lets someone
* change their mind — a yes followed by a no is a no.
*
* `MAX(id)` picks the latest rather than `MAX(voted_at)`: two ballots cast in the same
* millisecond carry the same timestamp, and the id is the order they were actually recorded
* in.
*
* Only ballots from `sinceId` on count — the id of the `init` row that called THIS vote. A
* vote that expired without carrying leaves its yes ballots behind, and without the bound
* they would carry the next vote called on the same player.
*/
export async function countKickVotes(
db: D1Database,
gameSessionId: number,
playerId: number,
sinceId: number
): Promise<number> {
const row = await db
.prepare(
`SELECT COUNT(*) AS yes FROM (
SELECT response FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2
AND id IN (
SELECT MAX(id) FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2 AND id >= ?3
GROUP BY voter_id
)
) WHERE response = 1`
)
.bind(gameSessionId, playerId, sinceId)
.first<{ yes: number }>()
return row?.yes ?? 0
}
/**
* Whether `yesVotes` carries a vote in a session holding `playerCount` players: STRICTLY more
* than half. A tie is not a majority, and a session whose presence count has somehow reached
* zero can never carry one — `0 > 0` is false — rather than every vote passing unopposed.
*/
export const isKickMajority = (yesVotes: number, playerCount: number): boolean =>
playerCount > 0 && yesVotes > playerCount / 2
+3
View File
@@ -382,5 +382,8 @@
"CloudRegion": "us",
"CrcCheckEnabled": false,
"EnableServerTracingAfterDisconnect": false
},
"RoomKeyConfig": {
"MaxKeysPerRoom": 10
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1",
"FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}",
"SkinColor": "Xac-W_R330KfOz-pQla9qg",
"HairColor": "0e_jaaObREWTf1AorAZ95g"
}
-2582
View File
@@ -1,2582 +0,0 @@
[
{
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
"AvatarItemId": 620,
"AvatarItemType": 0,
"FriendlyName": "Angled Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "04e348b7-107f-47b4-9331-ae3b9fa184ca,,,",
"AvatarItemId": 1980,
"AvatarItemType": 0,
"FriendlyName": "Braided Bun Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
"AvatarItemId": 1004,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,cypMpMaVeUuRjm4FLH4czQ,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1883,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,LTXy6g4xe0abuFzOLpVDUg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1881,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Dark Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,tMsCUhDslkmdNBfogeSz7w,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1884,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,whWKAUSGp0qprsJomq9Nwg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1882,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,",
"AvatarItemId": 2337,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,_T4zEki_wk-UIPdhSaBr_Q",
"AvatarItemId": 2341,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,5z3jZhYvq0CGmdJ9IcUUsg",
"AvatarItemId": 2340,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,EgEoJzG7A0aJ92qfInbX7g",
"AvatarItemId": 2342,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,NETGR8iysUyxcZM_oGmrwQ",
"AvatarItemId": 2344,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,OlfQDuz1NUOr88AL2n0jXg",
"AvatarItemId": 2343,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,VudVdOAjZUibHmtFeNkvSw",
"AvatarItemId": 2339,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
"AvatarItemId": 993,
"AvatarItemType": 0,
"FriendlyName": "Fonzie Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
"AvatarItemId": 81,
"AvatarItemType": 0,
"FriendlyName": "Flat Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
"AvatarItemId": 1620,
"AvatarItemType": 0,
"FriendlyName": "Round Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0d1ae26e-aaa4-4914-ad70-c9ab8cfabc61,,,",
"AvatarItemId": 1979,
"AvatarItemType": 0,
"FriendlyName": "Box Braids with Cuff Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,,,",
"AvatarItemId": 1740,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,6fmUy748hU2dgwgaijSkUA,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1739,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,buuNFd8sK0eap78TL1gMNQ,-UFHTNvJs0CGDStb0PFkbQ,",
"AvatarItemId": 1741,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,dBR1uNgk206eVoWntVzVAg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1738,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,LIgagNf4gk68EYQdlDruxg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1736,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,zPGCmxM_b0yTt7tqMaomcw,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1737,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
"AvatarItemId": 25,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 983,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 29,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 31,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 30,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,724c79a3-822c-422f-9462-a5374ee0211c,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 26,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White))",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 669,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 670,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
"AvatarItemId": 991,
"AvatarItemType": 0,
"FriendlyName": "Emo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1940c740-695d-4481-a802-bb11b6561635,",
"AvatarItemId": 2116,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Untucked Tee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
"AvatarItemId": 95,
"AvatarItemType": 0,
"FriendlyName": "Shagg Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
"AvatarItemId": 986,
"AvatarItemType": 0,
"FriendlyName": "Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
"AvatarItemId": 998,
"AvatarItemType": 0,
"FriendlyName": "Ponytail Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
"AvatarItemId": 994,
"AvatarItemType": 0,
"FriendlyName": "Lori Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
"AvatarItemId": 1021,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 682,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 681,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
"AvatarItemId": 1022,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 255,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 254,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 253,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,",
"AvatarItemId": 2345,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,0p_eXn2gXUKrshSEmrQx6Q",
"AvatarItemId": 2350,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,3cPbhpDX-EqDQy6eN1E5Ug",
"AvatarItemId": 2346,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,fAmEEm6ziUO2ZXTscQWtSg",
"AvatarItemId": 2351,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gEuE-X67b0-q5C0Tv11_nA",
"AvatarItemId": 2348,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gk_mQ-jsakmxhZYYNlRl_w",
"AvatarItemId": 2349,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
"AvatarItemId": 417,
"AvatarItemType": 0,
"FriendlyName": "Half Up Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
"AvatarItemId": 582,
"AvatarItemType": 0,
"FriendlyName": "Back Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2822aa50-4344-434d-b46f-fd9443f865b9,,,",
"AvatarItemId": 1981,
"AvatarItemType": 0,
"FriendlyName": "Curly Bob Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
"AvatarItemId": 999,
"AvatarItemType": 0,
"FriendlyName": "Punky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
"AvatarItemId": 1009,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Plaid, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1014,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
"AvatarItemId": 674,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Denim)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
"AvatarItemId": 672,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Argyle)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
"AvatarItemId": 1016,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Flowers, Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1015,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1012,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1011,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1013,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,",
"AvatarItemId": 2878,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,aEsXeTI_SEOclbm82n17tg",
"AvatarItemId": 2880,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,pZDvYABTN06KdJ5Ck60XLA",
"AvatarItemId": 2879,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "36b2ccf4-4f42-4dc4-aeaf-f3d10b2a6d30,,,",
"AvatarItemId": 1982,
"AvatarItemType": 0,
"FriendlyName": "Etched Fade Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "37f7b94a-92ba-4f5e-b94c-de00acfa4722,,,",
"AvatarItemId": 1983,
"AvatarItemType": 0,
"FriendlyName": "Etched Flattop Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
"AvatarItemId": 6,
"AvatarItemType": 0,
"FriendlyName": "Headband (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 11,
"AvatarItemType": 0,
"FriendlyName": "Headband (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 663,
"AvatarItemType": 0,
"FriendlyName": "Headband (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 12,
"AvatarItemType": 0,
"FriendlyName": "Headband (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 667,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 13,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 16,
"AvatarItemType": 0,
"FriendlyName": "Headband (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
"AvatarItemId": 668,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 664,
"AvatarItemType": 0,
"FriendlyName": "Headband (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 20,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 7,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 666,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 14,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 665,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 15,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 10,
"AvatarItemType": 0,
"FriendlyName": "Headband (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
"AvatarItemId": 1199,
"AvatarItemType": 0,
"FriendlyName": "Pompadour Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
"AvatarItemId": 112,
"AvatarItemType": 0,
"FriendlyName": "Seventies Stache",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,,,",
"AvatarItemId": 1742,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,gsHBUqA1gE2XNTPA3VvBLQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1745,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,JUQn0_PLIkytCrkAg0FflQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1744,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
"AvatarItemId": 1017,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4eaaad39-aa44-4791-8b67-2eafa8305850,,,",
"AvatarItemId": 1977,
"AvatarItemType": 0,
"FriendlyName": "Afro Puffs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
"AvatarItemId": 1480,
"AvatarItemType": 0,
"FriendlyName": "Cornrows Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
"AvatarItemId": 124,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Sash",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
"AvatarItemId": 149,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 468,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 469,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
"AvatarItemId": 131,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 461,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 460,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 459,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
"AvatarItemId": 193,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 196,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 195,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 194,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6e48363d-cc62-40a8-87f9-a69fae90c002,,,",
"AvatarItemId": 1978,
"AvatarItemType": 0,
"FriendlyName": "Box Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
"AvatarItemId": 1024,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 297,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 296,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
"AvatarItemId": 1001,
"AvatarItemType": 0,
"FriendlyName": "Spiky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
"AvatarItemId": 997,
"AvatarItemType": 0,
"FriendlyName": "Pig Tails Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
"AvatarItemId": 1020,
"AvatarItemType": 0,
"FriendlyName": "T-Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 230,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
"AvatarItemId": 677,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Zebra)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 227,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
"AvatarItemId": 679,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Rainbow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
"AvatarItemId": 676,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Camo)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 228,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
"AvatarItemId": 678,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Dots)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 229,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
"AvatarItemId": 585,
"AvatarItemType": 0,
"FriendlyName": "Short Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "849cabc9-db40-4448-b923-defe4e700072",
"AvatarItemId": 5954,
"AvatarItemType": 0,
"FriendlyName": "Bride of Frank Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
"AvatarItemId": 198,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 199,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 200,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 201,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
"AvatarItemId": 987,
"AvatarItemType": 0,
"FriendlyName": "Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
"AvatarItemId": 1621,
"AvatarItemType": 0,
"FriendlyName": "Square Earrings ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
"AvatarItemId": 1018,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
"AvatarItemId": 675,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
"AvatarItemId": 91,
"AvatarItemType": 0,
"FriendlyName": "Pulp Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
"AvatarItemId": 1478,
"AvatarItemType": 0,
"FriendlyName": "Pencil Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
"AvatarItemId": 1006,
"AvatarItemType": 0,
"FriendlyName": "Beard (Close)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
"AvatarItemId": 996,
"AvatarItemType": 0,
"FriendlyName": "Part Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
"AvatarItemId": 83,
"AvatarItemType": 0,
"FriendlyName": "Gekko Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
"AvatarItemId": 1619,
"AvatarItemType": 0,
"FriendlyName": "Hoop Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
"AvatarItemId": 361,
"AvatarItemType": 0,
"FriendlyName": "Thick Goatee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf75b23-84e9-436b-bbb8-4180ccd86b94,,,",
"AvatarItemId": 1985,
"AvatarItemType": 0,
"FriendlyName": "Wolf Cut Hair) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c20fdd5-acf1-426c-b85a-2220ceee9472,,,",
"AvatarItemId": 1987,
"AvatarItemType": 0,
"FriendlyName": "Short Curly Twists Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
"AvatarItemId": 150,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 151,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 153,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 152,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
"AvatarItemId": 995,
"AvatarItemType": 0,
"FriendlyName": "Meatball Buns Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,",
"AvatarItemId": 2328,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,hgLq0WyUAUSl2sboUrpTKQ",
"AvatarItemId": 2330,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,JzLPlVNG20G9mxG_4PF8Fg",
"AvatarItemId": 2332,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Navy)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,M5uFQQpoQE-5CKMJnDv_LA",
"AvatarItemId": 2331,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,VuMDnpnK50mzgUMwJ4F6Jw",
"AvatarItemId": 2333,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,",
"AvatarItemId": 2359,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,0zldWzi4H0OQwsWPt6gl-w",
"AvatarItemId": 2364,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,a0A1q_e1q06qquiu2KaZhA",
"AvatarItemId": 2361,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,M_BhFve7SkC7IJoymLiCLg",
"AvatarItemId": 2363,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,rwESUxIrQEKA2hdQpcaPaA",
"AvatarItemId": 2362,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
"AvatarItemId": 1002,
"AvatarItemType": 0,
"FriendlyName": "Undercut Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a52353bf-8693-45fb-a04a-19cf62a06482",
"AvatarItemId": 5955,
"AvatarItemType": 0,
"FriendlyName": "Curlers Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
"AvatarItemId": 726,
"AvatarItemType": 0,
"FriendlyName": "Bald Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
"AvatarItemId": 990,
"AvatarItemType": 0,
"FriendlyName": "Chunky Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,,,",
"AvatarItemId": 1772,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Black) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,Aex0Zh1RQ0u9oTDFF1cFIg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1781,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,El0s9F6cfkywcKOkqwbJtQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1779,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,J9cK9BKtHUinG-DwAoPi3g,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1774,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Dreen)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,N0fBxjrCekO-HUqmc3MufQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1775,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,oNoEl-nDqk-HLsfz1538oA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1776,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,pQ_3mZI7yEaxMw9n_Fr4Sg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1778,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,TpFWTdEW2kqtpvc0sPv-dw,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1777,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,-VF3K4miGE28LxMgqVehRQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1773,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,vxZVeH2b_E6XlqyqCW0hBA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1780,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,3a1oW3_MY0e4lDN2V1X4ig,za8Mz14Ww0KtC6wavUPXdg,",
"AvatarItemId": 1771,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Nonbinary Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,9jB8U3yxjE6hanEZgMdvtA",
"AvatarItemId": 2202,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - AAPI (AAPI Heritage Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
"AvatarItemId": 1458,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,DIeasJUIj0KNetAzPzuIrg,o3Q2gADhh06dgaipkkdMsA,",
"AvatarItemId": 1757,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Bi Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,EkA09Hj2m0u3c5STynn3Bw",
"AvatarItemId": 2887,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - ANAM (Autism & Neurodiversity Awareness Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,G6Udm6kMdE28vmRY5KPanA,Fw79pqDPP0C8H07AnTY-Vg,",
"AvatarItemId": 1756,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Lesbian Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,MfF0wizDjUa7RUE-AVJpvA",
"AvatarItemId": 2194,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Intersex Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PIwziHk1BUuYkAf6kuK6wA",
"AvatarItemId": 2059,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - BHM (Black History Month)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PRLBrEEZhUumm3VnYVJP6Q",
"AvatarItemId": 2192,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Aromantic Pride)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sf962XJ0tkis0svkMkZCXQ,4bI7QRvdnEansPkwx8Di9A,",
"AvatarItemId": 1791,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Pan Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
"AvatarItemId": 1457,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,tm7Cnd3LzkqWHA1JMrgoeA,nbLr3c3lOkiWhNg9IYbt6Q,",
"AvatarItemId": 1770,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Ace Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,Xt3Ndm8mC0qsLClPIbgwUw",
"AvatarItemId": 2193,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Genderfluid Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,",
"AvatarItemId": 2881,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,7ZRNVpxhWk-j8R3oj0oVXg",
"AvatarItemId": 2883,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,8WJI7RAuaEy4KvQiQhoKBw",
"AvatarItemId": 2882,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,",
"AvatarItemId": 2352,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,irw2uj4c2Eil0ndPCTqodQ",
"AvatarItemId": 2355,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,pPZ3sDw3B06mKiYsAiAt9w",
"AvatarItemId": 2358,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,sO8e2epLrUud243Xi9odXg",
"AvatarItemId": 2357,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,s-QTVT5eTECKHL9FnHFCAQ",
"AvatarItemId": 2354,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,UnHAtXkd2kCdamzw6B3Mvg",
"AvatarItemId": 2356,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
"AvatarItemId": 583,
"AvatarItemType": 0,
"FriendlyName": "High Pony Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
"AvatarItemId": 1008,
"AvatarItemType": 0,
"FriendlyName": "Business Tie (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
"AvatarItemId": 1005,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,5H0I1anlkkuUYIM28_ZOlA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1879,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,9tZq7Xvfj0WhhLBAuAacxA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1878,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,BqNgpjP7a0qmm3I3DNbKLA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1880,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
"AvatarItemId": 1200,
"AvatarItemType": 0,
"FriendlyName": "Dutch Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
"AvatarItemId": 1007,
"AvatarItemType": 0,
"FriendlyName": "Beard (Thick)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
"AvatarItemId": 727,
"AvatarItemType": 0,
"FriendlyName": "Receding Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
"AvatarItemId": 1019,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328",
"AvatarItemId": 571,
"AvatarItemType": 0,
"FriendlyName": "Alpaca Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": -1,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
"AvatarItemId": 1749,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,0c496f32-0011-4c4d-9778-59446f70c032",
"AvatarItemId": 565,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,3JrW3FFUvEe6HZ9VjOVjMw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1971,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,jeEWPFvzaEGmXaUrxTFvwg,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1974,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,NjCLn5A5h0-V8jB72etCNQ,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1973,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,OT5APcpbpEiMNNah8n4Txw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1972,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,t2fOeVWloUOKDlIaf4Sxnw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1970,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
"AvatarItemId": 1477,
"AvatarItemType": 0,
"FriendlyName": "Afro Updo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
"AvatarItemId": 1000,
"AvatarItemType": 0,
"FriendlyName": "Flowhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
"AvatarItemId": 556,
"AvatarItemType": 0,
"FriendlyName": "Large Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
"AvatarItemId": 989,
"AvatarItemType": 0,
"FriendlyName": "Buzz Cut Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
"AvatarItemId": 143,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 144,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 148,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Green, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 145,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 147,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 146,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
"AvatarItemId": 992,
"AvatarItemType": 0,
"FriendlyName": "Fauxhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
"AvatarItemId": 988,
"AvatarItemType": 0,
"FriendlyName": "Bob with Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
"AvatarItemId": 584,
"AvatarItemType": 0,
"FriendlyName": "Long Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,",
"AvatarItemId": 2875,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,1n-ekJos-EeViiz_zG4jEg",
"AvatarItemId": 2876,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
"AvatarItemId": 587,
"AvatarItemType": 0,
"FriendlyName": "Dreads Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ec474314-fd79-441d-852b-d8999274fe57,,,",
"AvatarItemId": 1986,
"AvatarItemType": 0,
"FriendlyName": "Wavy Middle Part Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
"AvatarItemId": 1023,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 288,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 684,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 289,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 685,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,5rLtRfNWO0qT7X-0sJkUXg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1859,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Lime)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 287,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Gold)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 292,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 683,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Ai85jH88H06ze74VdWolfw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1863,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pure Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 687,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 686,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Dg3DxHr8GU2e190TGveXqw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1858,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Baby Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,DiJGFIO-5Ue8rkEhvF5-Mg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1860,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Sienna)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 294,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,fWxn_AarpESryNA9UN8wvA,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1857,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,M0nOQl44DkG_GjTXzAEQCQ,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1862,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,NRjJuinZ4ki8qaEPvP84tw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1864,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Mint)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,sEKIlMfbJ0eED6VtcXcQrw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1856,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Army Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Sg5yKkqJEEulQeUvRhFgJg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1861,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Prussian Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,x3kGXCD0UEWkjfXqu_kuxg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1855,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Z2L6AqDdcUqxiN1mVw-X9g,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1865,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "edc7888c-dc42-48ab-8815-884d227760af,,,",
"AvatarItemId": 1984,
"AvatarItemType": 0,
"FriendlyName": "Short Locs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
"AvatarItemId": 1003,
"AvatarItemType": 0,
"FriendlyName": "Undercut Short Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
"AvatarItemId": 390,
"AvatarItemType": 0,
"FriendlyName": "Top Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
"AvatarItemId": 1459,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
"AvatarItemId": 1460,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,",
"AvatarItemId": 2334,
"AvatarItemType": 0,
"FriendlyName": "Headdress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,1WBsT99QM0anps6ZoWE59Q",
"AvatarItemId": 2336,
"AvatarItemType": 0,
"FriendlyName": "Headdress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffdda4d3-a768-4335-a883-aa3f9dc897bf,",
"AvatarItemId": 2995,
"AvatarItemType": 0,
"FriendlyName": "Trihawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
"AvatarItemId": 621,
"AvatarItemType": 0,
"FriendlyName": "Long Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "oYvOo47DJE-DEPlpn5xNUw,whs9olNz9Uani7Gou31mvw,y6aASPUwDkas0W5Yl27ydQ,",
"AvatarItemId": 1450,
"AvatarItemType": 0,
"FriendlyName": "Belt Bag (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
"AvatarItemId": 728,
"AvatarItemType": 0,
"FriendlyName": "Van Dyke Beard",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
}
]
+2 -2
View File
@@ -4782,8 +4782,8 @@
},
{
"Key": "ShareCam.VideoEnabled",
"Value": "false",
"ActiveExperiments": "video_recording_2025q2",
"Value": "true",
"ActiveExperiments": "",
"StartTime": null,
"EndTime": null
},
@@ -0,0 +1,981 @@
{
"ProgressionEventId": 13,
"Name": "Anime Season",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 83,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "asec16t4zlrlpn7ddct1tpjbn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 84,
"ProgressionEventId": 13,
"GiftDropId": 15642,
"GiftDropIds": [
15642
],
"ImageName": "bhnowifgzj7yqn8yenud48wvk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 85,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "804hhdj7eugd0ebefgi7i450k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 86,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "24m5t3glureyoj6qk0csg3gfa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 87,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "6t3bh0cysaaf8cq1985uykyqm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 88,
"ProgressionEventId": 13,
"GiftDropId": 15579,
"GiftDropIds": [
15579
],
"ImageName": "9afiojqywq28a9hvw9k6z96u0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 89,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "3hhyelwld7gr7xcydmtake5ka.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 70,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 90,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "dz9pqfvgkt8w3pruvqd5hws8j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 70,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 91,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "dkwv56rpw7df98mm5zgklj02m.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 92,
"ProgressionEventId": 13,
"GiftDropId": 15582,
"GiftDropIds": [
15582
],
"ImageName": "0xwod7lzm0zbqogrpdrvxwgxh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 93,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "49il0ry6hd75o26079qvysrw8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 94,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "ckykgyrgxwf6guz83q4x25f1x.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 95,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "2mvonbbn7igpdcbkm9lmbrex6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 96,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "3fmhzm3f8j8iwmikiq4tt8xq6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 97,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "ckahnsok8kfarvtqdhzbo42d6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 200,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 98,
"ProgressionEventId": 13,
"GiftDropId": 15581,
"GiftDropIds": [
15581
],
"ImageName": "9hfyowdb6lrsayrv6rgfy45bh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 200,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 99,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "1o03eyr37gmp1zm4o7ofc936b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 100,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "9mtv2qroauql1o57efaodyz0x.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 101,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "611lb9ddygir3fc96vjbc6lys.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 260,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 102,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "27x3n35qf9nlhha4pa9ut73m2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 260,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 103,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "4ko0och2lu6tz55r23czwxy3n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 104,
"ProgressionEventId": 13,
"GiftDropId": 15578,
"GiftDropIds": [
15578
],
"ImageName": "777clrrjfvadn3a3p9j3r8lq4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 105,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "1a10x4p9w0wncea4v6hs2kz56.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 320,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 106,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "3fd6vliml6xffrztxgygnsx28.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 320,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 107,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "5xtlbxccbhh366g5mbw30y1y0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 350,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 108,
"ProgressionEventId": 13,
"GiftDropId": 15585,
"GiftDropIds": [
15585
],
"ImageName": "c7nboei2r02423p2tm32qh9kc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 350,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 109,
"ProgressionEventId": 13,
"GiftDropId": 3880,
"GiftDropIds": [
3880
],
"ImageName": "1d5nimrm9oie7ig4wwr9f6fan.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 410,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 110,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "bj7usomic48jawtned9hjucyn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 410,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 111,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "c185oly3ylat030qkw24aphhc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 470,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 112,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "b732uynmakcfgcmbr8g1an6lu.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 470,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 113,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "15aomg6mkmxf4cjqjhgs82mlg.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 530,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 114,
"ProgressionEventId": 13,
"GiftDropId": 15584,
"GiftDropIds": [
15584
],
"ImageName": "2o20kavqm9o2catpp2qx8kaqk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 530,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 115,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "1gs1u99c9bxme1azznn8ngvqq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 590,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 116,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "1z5wp5lwqlffvt7h3fg1y378b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 590,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 117,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "05r0rmf9y4d67h6ye8jpite75.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 118,
"ProgressionEventId": 13,
"GiftDropId": 15580,
"GiftDropIds": [
15580
],
"ImageName": "0npm6i03bs8osho7686vjtzad.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 119,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "1gxungkle9bvp0qdprldcnft1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 125,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "5zavnpdapdem2xc0aq7axt5b5.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 124,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "8sszy7p5dlune762vn8lm1oy7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 770,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 123,
"ProgressionEventId": 13,
"GiftDropId": 15583,
"GiftDropIds": [
15583
],
"ImageName": "e2ncor6ccwj5t6cqugmjug4c7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 770,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 129,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "bl1t78iofsjb1qtgdbklilbb0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 850,
"RewardIndex": 40,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 128,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "10pqse99purq5xjws537orysb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 850,
"RewardIndex": 41,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 127,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "3knjal5w2lrict10ygu6xaumw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 42,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 126,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "alk0chwlwxme9oxd1fytlamxa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 43,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 135,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "0t880a28a8uy40b3p1jyhri26.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1030,
"RewardIndex": 44,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 134,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "cus32m9rew5niammslay2x2eo.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1030,
"RewardIndex": 45,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 133,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "6l4df8qa6i77gs1ooq2k8uj1s.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1130,
"RewardIndex": 46,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 132,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "ee896zb9514fljxf4xzn5ez0a.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1130,
"RewardIndex": 47,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 131,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "8b8n5mqgbmmbkmae81ssldy5y.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1250,
"RewardIndex": 48,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 130,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "c9ho9mdvnbks3r1iqga1ulon1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1250,
"RewardIndex": 49,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 121,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "e4es0hla8k9rmyuccuzjvxbrh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1370,
"RewardIndex": 50,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 120,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "56j8qyv68hd1o8zsqp03vnuxl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1370,
"RewardIndex": 51,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 141,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "criqbrh93fdcug6o04za19zvs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1510,
"RewardIndex": 52,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 140,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "92uz0mh3vq4vv3ok672zcx0qy.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1510,
"RewardIndex": 53,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 139,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "4fpa5no0vdm60jul2yu002nec.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1650,
"RewardIndex": 54,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 138,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "97ucjiycstjyholpsddtaq7er.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1650,
"RewardIndex": 55,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 137,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "2tboxk0avvq9aozt1q5an6vmz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 56,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 136,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "0a70iz2k5gr2lbpi7x4izqr3l.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 57,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 142,
"ProgressionEventId": 13,
"GiftDropId": 15698,
"GiftDropIds": [
15698
],
"ImageName": "0eenxm5lf4eqh3po9qcdfllxk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 58,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 143,
"ProgressionEventId": 13,
"GiftDropId": 15697,
"GiftDropIds": [
15697
],
"ImageName": "40nrm63jjeuxy5amf4xw0nr9n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 59,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-05-30T14:00:00Z",
"EndTime": "2025-06-27T14:00:00Z",
"CollectionEndTime": "2025-06-28T14:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "6e7cosfz20dokv5179njfqc6m.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,149 @@
{
"ProgressionEventId": 8,
"Name": "Base Event",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 30,
"ProgressionEventId": 8,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 270,
"RoomId": 41854064,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 265,
"RoomId": 8364812036745705361,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 271,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 267,
"RoomId": 8786672571011050982,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 273,
"RoomId": 59058753,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 266,
"RoomId": 41922506,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 272,
"RoomId": 70225004,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 274,
"RoomId": 50107153,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 260,
"RoomId": 944261190986353689,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 262,
"RoomId": 41854320,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 264,
"RoomId": 1063552974137030672,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 275,
"RoomId": 3662988090243921994,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 269,
"RoomId": 31382618,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 261,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 263,
"RoomId": 74897901,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 268,
"RoomId": 42201785,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 15
}
],
"RoomUnlockStartOffsetTicks": 600000000,
"RoomUnlockIntervalTicks": 600000000,
"RoomUnlockBatchSize": 1,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:01:00",
"RoomUnlockInterval": "00:01:00"
}
],
"StartTime": "5555-02-06T00:00:00Z",
"EndTime": "5555-02-07T00:00:00Z",
"CollectionEndTime": "5555-02-08T00:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,117 @@
{
"ProgressionEventId": 2,
"Name": "Coming in Hot! Progression Event Alpha",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 1,
"ProgressionEventId": 2,
"GiftDropId": 3798,
"GiftDropIds": [
3798
],
"ImageName": "7w7xumgr5rtaouze5e5lwspea.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 30,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 2,
"ProgressionEventId": 2,
"GiftDropId": 3779,
"GiftDropIds": [
3779
],
"ImageName": "eynegc0nils797wgjqz96a2ds.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 3,
"ProgressionEventId": 2,
"GiftDropId": 3025,
"GiftDropIds": [
3025
],
"ImageName": "2ikt2bx6xwiyyr6s8qducxjr1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 4,
"ProgressionEventId": 2,
"GiftDropId": 4079,
"GiftDropIds": [
4079
],
"ImageName": "dziv7z4fwbejheoy7yvrjlby1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 6,
"ProgressionEventId": 2,
"GiftDropId": 4078,
"GiftDropIds": [
4078
],
"ImageName": "6fepaaug17ai3guqpazy3v86o.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 7,
"ProgressionEventId": 2,
"GiftDropId": 3974,
"GiftDropIds": [
3974
],
"ImageName": "7958ooqtj2o04rkcdqtyb3rko.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 250,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2022-09-13T23:00:00Z",
"EndTime": "2022-09-16T23:00:00Z",
"CollectionEndTime": "2022-09-17T23:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 20,
"BoostXpMultiplier": 3,
"PurchasableXpBoostId": null,
"ActiveExperiment": "growth_progression_events",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,703 @@
{
"ProgressionEventId": 9,
"Name": "Invasion 2!",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 48,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "03msdpjjdn4rye4a5rzlb0iy4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 49,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "eg02namtz6220on6tswdh1a7m.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 170,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 50,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "dhtb7pja4o4wau6a4nb9i6hmm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 330,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 51,
"ProgressionEventId": 9,
"GiftDropId": 5000,
"GiftDropIds": [
5000
],
"ImageName": "8th0lp6vzerjmvesnk9rayofk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 540,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 52,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5173t89qwmeqyo5z1m6w87g59.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 750,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 53,
"ProgressionEventId": 9,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "1q562e2xf83t8mk71tq6ivttb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1070,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 54,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "bsyfz1t3uzuaorgvzosp47ryc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1280,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 55,
"ProgressionEventId": 9,
"GiftDropId": 5047,
"GiftDropIds": [
5047
],
"ImageName": "1oysf2o2iqrvvkbzgxj2nt5ls.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 56,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6efp1yda2ld5508dty6r7r2li.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2020,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 57,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "9qtg9rduxkg1vwt10ta18qxoj.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2870,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 58,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "7ylm9s3ipm79nb97tbidj9sfw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3080,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 59,
"ProgressionEventId": 9,
"GiftDropId": 4954,
"GiftDropIds": [
4954
],
"ImageName": "7cnvplzo7iqzvgwwyg13x9yul.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3930,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 60,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "b6jdkpp062231ywmjppkc0skm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4140,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 61,
"ProgressionEventId": 9,
"GiftDropId": 5272,
"GiftDropIds": [
5272
],
"ImageName": "24624vyh7d3pkjzmpabdo884n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4990,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 62,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "5wyzjcnty2ejkmdhwrj9a179p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 5200,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 63,
"ProgressionEventId": 9,
"GiftDropId": 5302,
"GiftDropIds": [
5302
],
"ImageName": "d3vuktasdn3tfvdjjzkcbwjd8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 6050,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 64,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "0vhtp1bz55aj6cch5z922vhiy.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 24,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 202,
"RoomId": 79637721166461380,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 205,
"RoomId": 7755621295025968191,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 242,
"RoomId": 76890764,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 200,
"RoomId": 8483543681332714280,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 204,
"RoomId": 75208136,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 201,
"RoomId": 7036488310533039471,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 203,
"RoomId": 49268683,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 207,
"RoomId": 58246484,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 197,
"RoomId": 887680104196187339,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 249,
"RoomId": 40613764,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 251,
"RoomId": 72079035,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 198,
"RoomId": 73692017,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 206,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 12
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2592000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "3.00:00:00"
},
{
"KeepsakeRoomListId": 25,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2713,
"UnlockItemGiftDropId": 5281,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 212,
"RoomId": 1629507034485430449,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 208,
"RoomId": 5786874011574248004,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 210,
"RoomId": 3207992100513208840,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 209,
"RoomId": 3200554354111267322,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 213,
"RoomId": 77570566,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 211,
"RoomId": 57707466,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 5
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
},
{
"KeepsakeRoomListId": 26,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2701,
"UnlockItemGiftDropId": 5269,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 245,
"RoomId": 26723336,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 243,
"RoomId": 4496117626402779973,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 219,
"RoomId": 64885245733710823,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 217,
"RoomId": 66460043,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 221,
"RoomId": 9004916221398793266,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 215,
"RoomId": 75570087,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 214,
"RoomId": 43401202,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 216,
"RoomId": 1751471322540793226,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 7
}
],
"RoomUnlockStartOffsetTicks": 1728000000000,
"RoomUnlockIntervalTicks": 864000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "2.00:00:00",
"RoomUnlockInterval": "1.00:00:00"
},
{
"KeepsakeRoomListId": 27,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2703,
"UnlockItemGiftDropId": 5271,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 226,
"RoomId": 7576729427803564326,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 258,
"RoomId": 31369470,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 229,
"RoomId": 70225004,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 230,
"RoomId": 8837447252376723733,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 256,
"RoomId": 73821809,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 222,
"RoomId": 3101788489080959468,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 227,
"RoomId": 10266603,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 228,
"RoomId": 5792488805990360638,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 257,
"RoomId": 8364883655950427728,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 8
}
],
"RoomUnlockStartOffsetTicks": 2592000000000,
"RoomUnlockIntervalTicks": 2592000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "3.00:00:00",
"RoomUnlockInterval": "3.00:00:00"
},
{
"KeepsakeRoomListId": 28,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2699,
"UnlockItemGiftDropId": 5267,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 250,
"RoomId": 7021872,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 254,
"RoomId": 74897901,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 241,
"RoomId": 64936037,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 231,
"RoomId": 31518506,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 239,
"RoomId": 189633977796607371,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 255,
"RoomId": 4708745686835418998,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 237,
"RoomId": 5628775642092102621,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 235,
"RoomId": 351964245102334138,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 240,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 253,
"RoomId": 4747460318795739950,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 238,
"RoomId": 47579443,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2023-03-02T20:33:00Z",
"EndTime": "2023-03-15T20:35:00Z",
"CollectionEndTime": "2023-03-16T20:35:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "58e9a05b-6106-4d8b-9fce-de4fec6c4d5b",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "1u0mceq0gypn478mqymsmc6es.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
+557
View File
@@ -0,0 +1,557 @@
{
"ProgressionEventId": 6,
"Name": "Invasion",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 14,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8p6bvkicn08fxm736v7vc6px8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 15,
"ProgressionEventId": 6,
"GiftDropId": 4423,
"GiftDropIds": [
4423
],
"ImageName": "29d6vkhsoavxvy2fbub3p8623.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 16,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "9etpy89ul022jlnixh2pz1t81.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 120,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 17,
"ProgressionEventId": 6,
"GiftDropId": 377,
"GiftDropIds": [
377
],
"ImageName": "d6y2itbia3t43bcn2rlxjr9g2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 18,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "388dhe2inzetzkupstnbfzelf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 210,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 19,
"ProgressionEventId": 6,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "05u5fjm5rdakjnoknn43uxjra.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 270,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 20,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "1vgxmpgz6tavxiy389rv89xtn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 340,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 21,
"ProgressionEventId": 6,
"GiftDropId": 5039,
"GiftDropIds": [
5039
],
"ImageName": "960jtbwz6swvytlgschy82g2k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 420,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 22,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8gs80hrx6bp617nqc7o65g6s3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 23,
"ProgressionEventId": 6,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "c5zppw23v8jovvc5u356hl8or.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 24,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "537qmmlviwslvgc5gudhc9wgq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 25,
"ProgressionEventId": 6,
"GiftDropId": 3729,
"GiftDropIds": [
3729
],
"ImageName": "d4cb6v5ck49ir3jqjhwrzxd92.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1020,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 26,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "9ihssplq503eqyybg1g0hc8n4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1280,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 27,
"ProgressionEventId": 6,
"GiftDropId": 330,
"GiftDropIds": [
330
],
"ImageName": "d1rswffe1ma4i03f88yjztwna.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1610,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 28,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8ci3t9f4o51uuvr7u140t0ejb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2040,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 29,
"ProgressionEventId": 6,
"GiftDropId": 5032,
"GiftDropIds": [
5032
],
"ImageName": "1cwj4ceyz3w8q0s7iz7p4euyh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2580,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 30,
"ProgressionEventId": 6,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "5r5wx9zmnpk1v6tj943nj45cs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 3,
"ProgressionEventId": 6,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 83,
"RoomId": 26265683,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 82,
"RoomId": 26003309,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 94,
"RoomId": 41855338,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 88,
"RoomId": 59046620,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 85,
"RoomId": 59058753,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 81,
"RoomId": 32217089,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 89,
"RoomId": 9112061,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 80,
"RoomId": 59100897,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 95,
"RoomId": 32840405,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 92,
"RoomId": 16860104,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 98,
"RoomId": 59070317,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 91,
"RoomId": 78042908,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 100,
"RoomId": 31886274,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 96,
"RoomId": 76714234,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 90,
"RoomId": 5242677,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 93,
"RoomId": 59058035,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 97,
"RoomId": 25982226,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 77,
"RoomId": 59045944,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 78,
"RoomId": 42598725,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 86,
"RoomId": 42146023,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 19
},
{
"KeepsakeRoomId": 75,
"RoomId": 54586671,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 20
},
{
"KeepsakeRoomId": 38,
"RoomId": 76890764,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 21
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 504000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "14:00:00"
},
{
"KeepsakeRoomListId": 4,
"ProgressionEventId": 6,
"UnlockItemAvatarItemId": 2371,
"UnlockItemGiftDropId": 4508,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 71,
"RoomId": 67219271,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 43,
"RoomId": 59106455,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 72,
"RoomId": 25251760,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 84,
"RoomId": 77993488,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 42,
"RoomId": 41885666,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 74,
"RoomId": 41854064,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 76,
"RoomId": 41892811,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 41,
"RoomId": 31382618,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 87,
"RoomId": 31370276,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 79,
"RoomId": 69004612,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 99,
"RoomId": 42201785,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2023-10-18T20:45:00Z",
"EndTime": "2023-11-01T23:00:00Z",
"CollectionEndTime": "2023-11-02T23:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "9833aa2e-30f8-4fe6-9ba8-54ae3eff5dd8",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "7cbeqeya66819q7jahtlldkax.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,661 @@
{
"ProgressionEventId": 15,
"Name": "Medieval Season",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 184,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "119ujjiogkl6o8hvn0bv1e9v8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 185,
"ProgressionEventId": 15,
"GiftDropId": 31964,
"GiftDropIds": [
31964
],
"ImageName": "2ekumj2jf1uz8yw8d7gmtnunm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 186,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "01vkyx5jx6em155gbfpdy4fxa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 187,
"ProgressionEventId": 15,
"GiftDropId": 18178,
"GiftDropIds": [
18178
],
"ImageName": "4x07bt9riqjsnr8reyyuu1hh8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 188,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "523hupp6etxjopnc8eqrnihks.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 189,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "55el0zslgj93g4g0ob2tlaiyx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 190,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "4z1sudgzj0fcvuzh85ygggbbs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 191,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "atoz95u6cuuavzkxx8ahbaqvd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 196,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "230klvvpb0fxmd9514h7ikvl9.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 197,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "di0eruhrxnqcqfu8ovjxo02c3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 198,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "22j1uti988vvw686gzcu5ke17.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 199,
"ProgressionEventId": 15,
"GiftDropId": 31968,
"GiftDropIds": [
31968
],
"ImageName": "bmt82rnj5xrry556ifjo9u29f.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 200,
"ProgressionEventId": 15,
"GiftDropId": 15500,
"GiftDropIds": [
15500
],
"ImageName": "epdwizupz0rstj2bzod82kmza.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 201,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "36t3tpm79wuydbdomdu26alkv.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 202,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "6s3w0zksn3wwdbcq1ia77lyz7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 203,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "97dm76eryr3gwtl6ue1zcmacw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 204,
"ProgressionEventId": 15,
"GiftDropId": 15572,
"GiftDropIds": [
15572
],
"ImageName": "1cwr07kjp9rvvi6pod9xhuugx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 205,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "4jnib0tx57plpgm6le0ek7c68.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 206,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "evymtv1hf5uyibfglhp91ly5l.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 207,
"ProgressionEventId": 15,
"GiftDropId": 31969,
"GiftDropIds": [
31969
],
"ImageName": "93xulm1o1e5i8hepqfg6i6dld.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 208,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "1l9oio4bq2umlhpom71jeka87.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 209,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "4qrt06kn0qxikchcsb4xf7cp6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 210,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "5js3ybdcbnkwpaz68huelwanj.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 211,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "bok9gvl9mp81pud1xcp1owzum.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 212,
"ProgressionEventId": 15,
"GiftDropId": 15558,
"GiftDropIds": [
15558
],
"ImageName": "a88y623frl8axhvp1equcqbw0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 213,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "2trla90had5222g1roc7myvys.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 214,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "e226ung2j4vwcwhbrvqurenue.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 215,
"ProgressionEventId": 15,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "br79hvrvevfdxjnw30auft6ps.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 216,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "02ibg78pact35tmgo17h47r0d.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 217,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "bx673w6t4y0sus5lqavuuhw34.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 218,
"ProgressionEventId": 15,
"GiftDropId": 18180,
"GiftDropIds": [
18180
],
"ImageName": "dmz15b9tuc95xxp4emqt2oemi.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 219,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "0qaxg6x1j5k5m75d9gius4mdp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 220,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "9xjv03v8l6bme8dz3t6e3bzsr.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 221,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "b6xga15ruxpacniaqcnjnv1er.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 222,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "8c2h0r2swf6bpi9671j29drus.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 223,
"ProgressionEventId": 15,
"GiftDropId": 18179,
"GiftDropIds": [
18179
],
"ImageName": "bj47w995if9qpgwzs3h9b2nzb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 224,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "e2jryr3ypkzni6kkga234jfoi.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 225,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "8ks2xl4ce9fz3xbpsp794vmjf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 226,
"ProgressionEventId": 15,
"GiftDropId": 17065,
"GiftDropIds": [
17065
],
"ImageName": "3ldt5498un7cv6mt52ybkd7x1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 227,
"ProgressionEventId": 15,
"GiftDropId": 17064,
"GiftDropIds": [
17064
],
"ImageName": "7x6406x170ca8k6xxn49gpp67.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-08-08T14:00:00Z",
"EndTime": "2025-08-25T14:00:00Z",
"CollectionEndTime": "2025-08-26T14:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 1440,
"BoostXpMultiplier": 1,
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "76lkj9zjul69p2jtjwwb3gtyp.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,117 @@
{
"ProgressionEventId": 5,
"Name": "Progression Event Alpha Repeat",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 8,
"ProgressionEventId": 5,
"GiftDropId": 3798,
"GiftDropIds": [
3798
],
"ImageName": "c6o7fs5n863b70ib8l72db471.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 30,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 9,
"ProgressionEventId": 5,
"GiftDropId": 3779,
"GiftDropIds": [
3779
],
"ImageName": "dr989ihctyomlfrq9cvy1m53o.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 10,
"ProgressionEventId": 5,
"GiftDropId": 3025,
"GiftDropIds": [
3025
],
"ImageName": "1kgv7gxn97k59qpd8ulfupg9p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 11,
"ProgressionEventId": 5,
"GiftDropId": 4079,
"GiftDropIds": [
4079
],
"ImageName": "bg5irppfjfcvspbyg0xupdzzz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 12,
"ProgressionEventId": 5,
"GiftDropId": 4078,
"GiftDropIds": [
4078
],
"ImageName": "cc5c98ttyl081t6wxadsaovh4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 13,
"ProgressionEventId": 5,
"GiftDropId": 3974,
"GiftDropIds": [
3974
],
"ImageName": "213ynroo1di0tvm9najsuxwrw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 250,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2022-09-20T23:00:00Z",
"EndTime": "2022-09-23T23:00:00Z",
"CollectionEndTime": "2022-09-24T23:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 20,
"BoostXpMultiplier": 3,
"PurchasableXpBoostId": null,
"ActiveExperiment": "growth_second_progression_event",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
+277
View File
@@ -0,0 +1,277 @@
{
"ProgressionEventId": 3,
"Name": "Rec Room",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 1,
"ProgressionEventId": 3,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 6,
"RoomId": 41855338,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 7,
"RoomId": 54586671,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 27,
"RoomId": 76714234,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 13,
"RoomId": 9112061,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 5,
"RoomId": 59058753,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 1,
"RoomId": 5242677,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 26,
"RoomId": 59046620,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 23,
"RoomId": 42598725,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 37,
"RoomId": 59100897,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 12,
"RoomId": 59058035,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 24,
"RoomId": 78042908,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 15,
"RoomId": 25982226,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 3,
"RoomId": 59070317,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 4,
"RoomId": 32840405,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 8,
"RoomId": 42146023,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 14,
"RoomId": 32217089,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 22,
"RoomId": 76890764,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 28,
"RoomId": 59045944,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 25,
"RoomId": 26003309,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 11,
"RoomId": 16860104,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 19
},
{
"KeepsakeRoomId": 2,
"RoomId": 26265683,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 20
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 0,
"RoomUnlockBatchSize": 0,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "00:00:00"
},
{
"KeepsakeRoomListId": 2,
"ProgressionEventId": 3,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 34,
"RoomId": 67219271,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 30,
"RoomId": 41885666,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 16,
"RoomId": 69004612,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 31,
"RoomId": 25251760,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 29,
"RoomId": 41892811,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 18,
"RoomId": 31382618,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 33,
"RoomId": 42201785,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 36,
"RoomId": 77993488,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 20,
"RoomId": 59106455,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 32,
"RoomId": 31370276,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 17,
"RoomId": 41854064,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 0,
"RoomUnlockBatchSize": 0,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "00:00:00"
}
],
"StartTime": "2023-12-14T00:00:00Z",
"EndTime": "2023-12-15T00:00:00Z",
"CollectionEndTime": "2023-12-16T00:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,445 @@
{
"ProgressionEventId": 12,
"Name": "Science Experiment: Restoration",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 66,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "djfaxfi8a6af5mvtc3qmh3jow.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 67,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6fx5ck93714znx3eyldgfxs0j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 110,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 68,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "2hbq188nsz4wvh8qzbz60ciz4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 69,
"ProgressionEventId": 12,
"GiftDropId": 3783,
"GiftDropIds": [
3783
],
"ImageName": "2w3tsxe7ocrn4smgkrocmiex1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 580,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 70,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5ox4r2qpvqwhxlpsh2fnysdpf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 950,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 71,
"ProgressionEventId": 12,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "2is18pivakktvlegkrth0acpw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1410,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 72,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "2hzczu8kudpizq3sjmmq2klyp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1960,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 73,
"ProgressionEventId": 12,
"GiftDropId": 5517,
"GiftDropIds": [
5517
],
"ImageName": "cb9y4mey6y94xd7tkfxz4znnh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2700,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 74,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "e9n6quz84ppvxkvszgaqhgjo1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3340,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 75,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "d6ika8z7hn8c245yt0hn0kgiz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4130,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 76,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5ydvmfqzoiicmiuz4tvvraahh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4920,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 77,
"ProgressionEventId": 12,
"GiftDropId": 5524,
"GiftDropIds": [
5524
],
"ImageName": "37w5i7re60wjsgn4l1yocw425.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 5760,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 78,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "3xbb886g6ylgmhjm3c4ifzpua.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 6550,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 81,
"ProgressionEventId": 12,
"GiftDropId": 5528,
"GiftDropIds": [
5528
],
"ImageName": "bvhbfhwqpu5qj9pilb5i9thcl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 7440,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 82,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "7hyz2xgjisqdimlbzshe6y5v0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 800,
"RewardIndex": 14,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 31,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 276,
"RoomId": 76890764,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 279,
"RoomId": 59058753,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 280,
"RoomId": 70225004,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 281,
"RoomId": 41922506,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 278,
"RoomId": 1063552974137030672,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 277,
"RoomId": 8786672571011050982,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 5
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 32,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2942,
"UnlockItemGiftDropId": 5531,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 285,
"RoomId": 74897901,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 284,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 282,
"RoomId": 41854064,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 283,
"RoomId": 42201785,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 3
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 33,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2936,
"UnlockItemGiftDropId": 5525,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 286,
"RoomId": 944261190986353689,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 288,
"RoomId": 41854320,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 287,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 2
}
],
"RoomUnlockStartOffsetTicks": 828000000000,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "23:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 34,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2419,
"UnlockItemGiftDropId": 4953,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 289,
"RoomId": 8364812036745705361,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 292,
"RoomId": 50107153,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 290,
"RoomId": 31382618,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 291,
"RoomId": 3662988090243921994,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 3
}
],
"RoomUnlockStartOffsetTicks": 864000000000,
"RoomUnlockIntervalTicks": 1692000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "1.00:00:00",
"RoomUnlockInterval": "1.23:00:00"
}
],
"StartTime": "2023-05-17T20:25:00Z",
"EndTime": "2023-05-29T20:25:00Z",
"CollectionEndTime": "2023-05-30T20:25:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "8869236d-830a-497d-a155-2f4d7ce88c13",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "8z3cjwkqoa24yyu2ha059s28y.jpg",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,578 @@
{
"ProgressionEventId": 7,
"Name": "Science Party!",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 31,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "aaed5tx5tan53wnahv00j0dqp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 32,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "50hv6aaon37ldw0l9k6ujnyzd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 33,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "05xtgqiljov5lqpfmtl0r229h.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 150,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 34,
"ProgressionEventId": 7,
"GiftDropId": 3788,
"GiftDropIds": [
3788
],
"ImageName": "07t4xzjqh8wsz34qzgau846ie.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 210,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 35,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8unssdi33pepexzci1fa7ekzc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 36,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "bzalktli2kkbr5o0l8hwc85bp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 390,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 37,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "cynkckele9webz9vxtpb4ub63.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 510,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 38,
"ProgressionEventId": 7,
"GiftDropId": 5034,
"GiftDropIds": [
5034
],
"ImageName": "1u4rcnhwj45z3jkia5gspofb2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 660,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 39,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "4rofrk3908ubai7h6wl8k8mc3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 840,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 40,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "bn4b3ydufhuqd2kqu0ejhl8d3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1060,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 41,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "b7263qy82jdh6nkxteqy3j7wl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1320,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 42,
"ProgressionEventId": 7,
"GiftDropId": 3889,
"GiftDropIds": [
3889
],
"ImageName": "ea48ip5d566wyomm3vr76n8v4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1620,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 43,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6vvnks7ggj3ml5368tuxcmt3h.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 44,
"ProgressionEventId": 7,
"GiftDropId": 4959,
"GiftDropIds": [
4959
],
"ImageName": "ag5yesrz6z9zom62u84272srf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2370,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 45,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "2bo973tl5fkp76pslpxd7myp2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2820,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 46,
"ProgressionEventId": 7,
"GiftDropId": 5106,
"GiftDropIds": [
5106
],
"ImageName": "5g7k91zycq4tcunlmhj277162.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3320,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 47,
"ProgressionEventId": 7,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "7kj6usg7wfr522j6gc3yqudej.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 21,
"ProgressionEventId": 7,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 140,
"RoomId": 31709424,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 102,
"RoomId": 2330357920977922697,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 109,
"RoomId": 2748754362604835048,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 136,
"RoomId": 57228091,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 106,
"RoomId": 77747629,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 139,
"RoomId": 76890764,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 133,
"RoomId": 4163663717250077171,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 112,
"RoomId": 7638707323349236913,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 107,
"RoomId": 25952571,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 115,
"RoomId": 70047190,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 114,
"RoomId": 36754794,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 111,
"RoomId": 3981920779437373918,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 103,
"RoomId": 41873233,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 134,
"RoomId": 35221854,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 116,
"RoomId": 68171853,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 104,
"RoomId": 64386353,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 132,
"RoomId": 37711570,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 101,
"RoomId": 17830076,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 137,
"RoomId": 52815862,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 113,
"RoomId": 21478010,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 19
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
},
{
"KeepsakeRoomListId": 22,
"ProgressionEventId": 7,
"UnlockItemAvatarItemId": 2554,
"UnlockItemGiftDropId": 5108,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 127,
"RoomId": 9473003,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 123,
"RoomId": 50107153,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 128,
"RoomId": 8971419584305936263,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 122,
"RoomId": 2402812124088696720,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 130,
"RoomId": 57707466,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 118,
"RoomId": 21582210,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 117,
"RoomId": 4708745686835418998,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 135,
"RoomId": 789919,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 138,
"RoomId": 31422335,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 120,
"RoomId": 21911777,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 124,
"RoomId": 6005761176800757444,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 10
},
{
"KeepsakeRoomId": 126,
"RoomId": 72145061,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 11
},
{
"KeepsakeRoomId": 119,
"RoomId": 42288509,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 12
},
{
"KeepsakeRoomId": 121,
"RoomId": 11034150,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 13
},
{
"KeepsakeRoomId": 131,
"RoomId": 2134942440841839507,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 14
},
{
"KeepsakeRoomId": 125,
"RoomId": 649924194922273060,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 15
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2022-12-21T18:35:00Z",
"EndTime": "2023-01-03T18:35:00Z",
"CollectionEndTime": "2023-01-04T18:35:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "5c0a2aa2-f419-49cd-abf9-d4614c3e5036",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "a5cscydpx5x2i6lphnsv5xfk6.jpg",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,661 @@
{
"ProgressionEventId": 14,
"Name": "Summer Party",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 144,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "8t69rx7il3qacste29a6obz5w.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 145,
"ProgressionEventId": 14,
"GiftDropId": 17008,
"GiftDropIds": [
17008
],
"ImageName": "a5hfoi04rs8n7vaflsb0upy0n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 146,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "dyu1yu4zip5sae8q5gi5l72pv.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 147,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "0hygq3q4gyaex5c3l7peqoj6a.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 148,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "b7oxbidp1dcm03bt4pwxr8ur7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 149,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "9650alp4fzdx3vn6lyy13mtg3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 150,
"ProgressionEventId": 14,
"GiftDropId": 17083,
"GiftDropIds": [
17083
],
"ImageName": "5yhva1w958hknw1vn7pt6ms6n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 151,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "61cdhwhloej6zy5ypdmogt248.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 152,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "c6u9av1tjoeufnjpov3weoe2j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 153,
"ProgressionEventId": 14,
"GiftDropId": 17078,
"GiftDropIds": [
17078
],
"ImageName": "734oescsnasxod5lj0bkp262i.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 154,
"ProgressionEventId": 14,
"GiftDropId": 17025,
"GiftDropIds": [
17025
],
"ImageName": "8g5gzg65fpt54tlgnlpebnqb7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 155,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "33qnr08uznr13tvua2rkybwaa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 156,
"ProgressionEventId": 14,
"GiftDropId": 17058,
"GiftDropIds": [
17058
],
"ImageName": "aj7sxyq6u7kch8v4grh1ja5w9.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 157,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "cjz3c14kgtz96cmckpncnifn6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 158,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "6uu6py0qs0gax9cvachj6910j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 159,
"ProgressionEventId": 14,
"GiftDropId": 17079,
"GiftDropIds": [
17079
],
"ImageName": "2gq9xiv5ojy7682mc9xzld42p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 160,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "1fmvxbij1e3z4rsax1xaiejn8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 161,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "380z3zyut971wvwrzz8pj1z5b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 162,
"ProgressionEventId": 14,
"GiftDropId": 17061,
"GiftDropIds": [
17061
],
"ImageName": "2gpauvs1hseg7ptr5pcd09za3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 163,
"ProgressionEventId": 14,
"GiftDropId": 17081,
"GiftDropIds": [
17081
],
"ImageName": "6obtoj80pjn1g8cizwdbvxvxd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 164,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "c04l6nmz0z69jn6dfjfptcsi6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 165,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "035iqb84hbeo3hp0i9p9ov6ox.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 166,
"ProgressionEventId": 14,
"GiftDropId": 17059,
"GiftDropIds": [
17059
],
"ImageName": "3oodo9mhvy0kdac30tnzpp2wo.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 167,
"ProgressionEventId": 14,
"GiftDropId": 17080,
"GiftDropIds": [
17080
],
"ImageName": "f1jj5jvro9pw83kz9qrqhl3zq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 168,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "1yti0qsf9qp1a0gok2lve1tw8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 169,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "0osnuz105rvfe3bm6ktubtvjw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 170,
"ProgressionEventId": 14,
"GiftDropId": 17060,
"GiftDropIds": [
17060
],
"ImageName": "1a3ehh1lt5u15dbsp7djj68y3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 171,
"ProgressionEventId": 14,
"GiftDropId": 17082,
"GiftDropIds": [
17082
],
"ImageName": "36gvdi900qbrcvvugg4hqsncm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 172,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "dl5a8wlvl46sd20ujwkfqy6vs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 173,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "0s6pdag7186jrtozh4i8blgog.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 174,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "bbtkhdvgh0akznofr9et71jn2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 175,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "9qjbcxizpxh8wv26ka1mkbfz0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 176,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "d41623xsr285d4ico556zx95f.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 177,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "bdq4siuzhyxnc0ow0elurdu77.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 178,
"ProgressionEventId": 14,
"GiftDropId": 17025,
"GiftDropIds": [
17025
],
"ImageName": "9if9e7u9k24sfys7ui1mqovmx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 179,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "2zja5d03bdsc6gbwc1ymgl7m3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 180,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "80nsy4tf5ysrrxklukvn2pe2i.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 181,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "7j3oshdr6u0rck15coy2p0l7j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 182,
"ProgressionEventId": 14,
"GiftDropId": 17042,
"GiftDropIds": [
17042
],
"ImageName": "7jtl3d7xflfyhofzstw7jg9og.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 183,
"ProgressionEventId": 14,
"GiftDropId": 17041,
"GiftDropIds": [
17041
],
"ImageName": "dtnlvtn5z24lbskhx6jmzrgy7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-07-04T00:00:00Z",
"EndTime": "2025-07-21T00:00:00Z",
"CollectionEndTime": "2025-07-22T00:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 1440,
"BoostXpMultiplier": 1,
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "drimdw4wgoyxhywh87v8ul24o.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,227 @@
{
"ProgressionEventId": 16,
"Name": "Treat Collection Challenge",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 241,
"ProgressionEventId": 16,
"GiftDropId": 33148,
"GiftDropIds": [
33148
],
"ImageName": "5u8mhj7w2qpr1f6gv1i2j9agh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 242,
"ProgressionEventId": 16,
"GiftDropId": 33274,
"GiftDropIds": [
33274
],
"ImageName": "524wcausenuws6tcy01f5kfj3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 575,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 243,
"ProgressionEventId": 16,
"GiftDropId": 33282,
"GiftDropIds": [
33282
],
"ImageName": "0wr3qcgxfrotusbxasn4xqxll.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1150,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 244,
"ProgressionEventId": 16,
"GiftDropId": 33242,
"GiftDropIds": [
33242
],
"ImageName": "7ccyt74z6qe1a3gyhvgpajx6q.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1600,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 245,
"ProgressionEventId": 16,
"GiftDropId": 33252,
"GiftDropIds": [
33252
],
"ImageName": "9oufkpwzzblczfxvkhm42qz8q.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2000,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 247,
"ProgressionEventId": 16,
"GiftDropId": 33237,
"GiftDropIds": [
33237,
33238,
33239
],
"ImageName": "e416lpu3mlynh1h3ace7bb66k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2000,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": 100
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 35,
"ProgressionEventId": 16,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 308,
"RoomId": 8055432965292997844,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 303,
"RoomId": 8623922633989916245,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 296,
"RoomId": 2720689679841941691,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 306,
"RoomId": 1198369430637700542,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 305,
"RoomId": 5060281615610518191,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 299,
"RoomId": 5664046151556621084,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 302,
"RoomId": 3579409834391380003,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 300,
"RoomId": 7430395197140819449,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 309,
"RoomId": 170126,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 297,
"RoomId": 5988747458686605260,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 301,
"RoomId": 8323646838241355522,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 298,
"RoomId": 4055690517114419831,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 304,
"RoomId": 1408763564617909051,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 12
}
],
"RoomUnlockStartOffsetTicks": 10000000,
"RoomUnlockIntervalTicks": 10000000,
"RoomUnlockBatchSize": 100,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:01",
"RoomUnlockInterval": "00:00:01"
}
],
"StartTime": "2025-10-17T18:00:00Z",
"EndTime": "2025-10-23T18:00:00Z",
"CollectionEndTime": "2025-10-24T18:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "af4c4b42-7d8b-4084-9e34-66d6da665f8f",
"ActiveExperiment": "liveops_q42025_rectober_treats",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "485daw4zpr3dwyhrayusrr5hv.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,20 @@
{
"ProgressionEventId": 17,
"Name": "Tricks Challenge",
"IsEnabled": true,
"Rewards": [],
"KeepsakeRoomLists": [],
"StartTime": "2025-10-29T18:00:00Z",
"EndTime": "2025-11-03T03:00:00Z",
"CollectionEndTime": "2025-11-04T03:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "6b162910-f07d-409d-bae8-be4854358059",
"ActiveExperiment": "liveops_q42025_rectober_tricks",
"ChallengesIconImageName": "15g8dw5os81x3wyjp1fcq20ah.png",
"RewardsPipImageName": "0ibvu6e7i67j4cxkakbif3hwr.png",
"EventInfoImageName": "ew5owqjujje8a4cloy6lifzqh.png",
"EventLeaderboardId": 17,
"InitialCurrencyGrant": 275
}
@@ -0,0 +1,20 @@
{
"ProgressionEventId": 18,
"Name": "Yetis Challenge",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [],
"StartTime": "2025-12-03T19:00:00Z",
"EndTime": "2025-12-08T03:00:00Z",
"CollectionEndTime": "2025-12-09T03:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "e4a7b4d4-f609-4008-9874-f6cd01b896ea",
"ActiveExperiment": "liveops_2025q4_yeti",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "d99f392w7hv5r2wzwxpcxtwhi.png",
"EventLeaderboardId": 18,
"InitialCurrencyGrant": 300
}
+4 -4
View File
@@ -4,6 +4,9 @@
"main": "src/api.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// The progression events are bundled as TEXT, not parsed: they are served byte for byte
// because their 64-bit room ids don't survive a JSON parse (see routes/progression.ts).
"rules": [{ "type": "Text", "globs": ["**/progression-events/*.json"], "fallthrough": true }],
// Shared `recflare` DB. Created manually with `wrangler d1 create recflare`; its
// id lives in RECFLARE_D1 (see .env) and the "local" placeholder below is
// spliced out at deploy time. The `api` worker owns the `relationships` table
@@ -66,10 +69,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+26 -10
View File
@@ -5,16 +5,19 @@ authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/deviceauthorization` | Studio device login: mint a user code and point the browser at WWW `/device` |
| POST | `/connect/device/approve` | Website approves that code for the signed-in account |
| POST | `/connect/device/deny` | Website refuses it |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
## API documentation
@@ -45,6 +48,10 @@ route without documenting it fails rather than silently shipping an incomplete s
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`urn:ietf:params:oauth:grant-type:device_code`** — Rec Room Studio polling a
code from `POST /connect/deviceauthorization`. While the player has not approved
it on the website, the response is HTTP 400 `authorization_pending`. Studio
treats `expires_in` on the token as seconds.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
@@ -55,6 +62,15 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
and developer does not include it. It is stamped from the account's `hasStudio` flag,
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
role, read from the same role snapshot as the build rollout gate. Staff add and remove
individual people at `/settings/recroomstudio` on the website (or
`runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
@@ -0,0 +1,15 @@
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
-- generated column, so "every Plus member" is an index walk rather than a scan of every
-- account's JSON. The reader is the operator's periodic token reload
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
-- `balance` row of each Plus member in one statement.
--
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
-- as the subscriber list and never grows with the account table. A query must say
-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false
-- and NULL when the key is absent, so neither a revoked nor a never-granted account is
-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror
-- in apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
@@ -0,0 +1,37 @@
-- Record the roles a linked identity held in its platform's community, for the one
-- platform that has any: `PlatformType.Discord` (101), written by the website's benefits
-- claim from the member record Discord itself served
-- (`GET /users/@me/guilds/{guild}/member`). Steam and Meta links leave it NULL — there is
-- no such thing there — so the column is nullable and has no default.
--
-- Until now the claim asked Discord for the member's roles, tested them against the
-- configured qualifying list, and threw the answer away; all that survived was `hasPlus`,
-- which records "held A qualifying role once" and not WHICH one. That is the question an
-- operator has to answer by hand in Discord for every player who writes in — and the only
-- way to tell a supporter from a booster from staff after the fact, or to see that a tier
-- was granted under a role that has since been retired.
--
-- A JSON ARRAY of role id snowflakes (`["1077…001","1077…002"]`), not one id: a member
-- holds as many roles as they hold, and the claim qualifies on ANY of them, so storing a
-- single one would have to pick arbitrarily. Snowflakes stay STRINGS — one exceeds 2^53,
-- so a JSON number would round and stop matching the real role. The list is the whole
-- member record's roles, not just the qualifying ones: it is a snapshot of who they were
-- in the guild, and filtering it to the config of the day would make it unreadable the
-- moment the config changed.
--
-- It is a SNAPSHOT, taken at claim time and refreshed only when the same player claims
-- again. Nothing sweeps it: reading a member's roles needs that member's own OAuth token
-- (this design deliberately holds no bot token — see apps/www/src/discord.ts), so the
-- server cannot re-read them unprompted. Read it as "the roles they held when they
-- claimed", the same tense `hasPlus` carries, and never as "the roles they hold".
--
-- No index. Nothing queries BY role yet; when something does, it will want
-- `json_each(role)` and an expression index built for that query, not a plain one over
-- the array text, which could only serve an exact-whole-array match. Kept in sync with
-- PLATFORM_SCHEMA_DDL in src/platform-db.ts.
--
-- Safe to run before or after the deploy that ships it: an added nullable column is
-- invisible to the currently-deployed code, which names its columns explicitly and never
-- SELECTs *.
ALTER TABLE platform_account ADD COLUMN role TEXT;
@@ -0,0 +1,19 @@
-- Device-login grants (owned by the auth worker). Named generically: Rec Room
-- Studio is the first device to use it, but any device-code login lands here. Studio
-- posts POST /connect/deviceauthorization here, then polls /connect/token with
-- grant_type=urn:ietf:params:oauth:grant-type:device_code. The browser page that
-- approves the code lives on www (`/device`); this table is only the handshake.
--
-- The device_code is a bearer secret until it is consumed, so only its SHA-256
-- is stored. The user_code is the short code the player sees and types, so it
-- has to be stored as itself. Kept in sync with STUDIO_DEVICE_SCHEMA_DDL in
-- src/studio-device.ts.
CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
@@ -0,0 +1,15 @@
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
-- generated column, so "every influencer" is an index walk rather than a scan of every
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
-- which the client asks for the whole list on every launch so it can badge influencers
-- wherever they turn up.
--
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
-- flag is set, so it is as small as the influencer list. A query must say exactly
-- `is_influencer = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false and
-- NULL when the key is absent, so neither a revoked nor a never-granted account is in it.
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
-- apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
+784 -83
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
countAccountsByDeviceId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
@@ -13,16 +14,22 @@ import {
getPasswordHash,
getRoomById,
hashPassword,
isRolloutGameVersion,
isSupportedGameVersion,
rolloutGameBuilds,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
supportedGameBuilds,
updateAccount,
verifyPassword,
writeAuditLog,
} from '@repo/domain'
import {
flagVar,
intVar,
logger,
withCleanSpec,
@@ -30,16 +37,26 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import {
generateToken,
TOKEN_TTL_SECONDS,
validateAndGetAccountId,
validateIdentityAssertion,
} from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { buildEndpoints } from '../../ns/src/endpoints'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
DeviceAuthorizationRequest,
DeviceAuthorizationResponse,
DeviceDecisionRequest,
DeviceDecisionResponse,
FakeCachedLogin,
form,
json,
@@ -47,23 +64,36 @@ import {
PlatformIdsRequest,
PlatformType,
RestrictionDto,
roleFilter,
roleLookup,
TokenRequest,
TokenResponse,
} from './openapi'
import {
accountHoldsDiscordRole,
countAccountsForPlatformIdentity,
getLinksForIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
hasStudioAccess,
isPlatformIdentityLinked,
linkPlatformIdentity,
parseRoleIdList,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import {
approveDeviceGrant,
beginDeviceGrant,
denyDeviceGrant,
DEVICE_CODE_GRANT,
pollDeviceGrant,
studioClientMatches,
} from './studio-device'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { App, Env } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
@@ -71,8 +101,8 @@ const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The `error_description` a grant is refused with when the caller's account is not itself
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
* account (see bans-db's linked arms). A fixed
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* real sentence in front of a player — anything varying would fall through to the generic
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
@@ -80,10 +110,27 @@ const TOKEN_SCOPE =
* account is banned" would be a lie, and naming the account we matched them to would hand
* out somebody else's moderation record.
*
* A DIRECTLY banned account is not refused here at all — see the token grant.
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
* or a linked one — see the token grant: the client needs a token to reach the block screen,
* and `match` is what holds a blocked player to their dorm.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
/**
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
* session. No account linked to the identity means a NEW one is made and linked, so a
* Discord account is a way to join without Steam or Meta; the player then sets a password
* on the website and signs in with it anywhere — a sideloaded Quest included.
*/
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
/** What the grant answers when one identity opens several accounts; never true today. */
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
@@ -112,24 +159,106 @@ const FAKE_OCULUS_CACHED_LOGIN = {
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
*
* Two independent arms, because they fail in opposite ways:
* Three independent arms, because they fail in different ways:
* - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
* spoofed and can't be reset by changing networks. Only binds on a platform
* create_account — the password/anonymous path has no platform identity to count,
* so the IP arm is the only thing standing between it and bulk signup.
* so the other two arms are all that stands between it and bulk signup.
* - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks.
* count by hopping networks — but a VPN gives them a fresh counter per exit, which
* is exactly how one player made 150 accounts, six per address, in a weekend.
* - Per client device id. Narrower than an IP (a household shares an address, not an
* install) and stable across VPN hops, which is the hole in the IP arm it covers. It
* counts the last-seen `deviceId`, which a login moves; see the field's notes in
* accounts-db for why an immutable copy isn't worth keeping. Client-chosen and
* unverified, so it catches a stock client, not a script that fabricates ids. Skipped
* when the signup posts no `device_id` (the website's don't).
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
* These are the defaults. An operator overrides any arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
* small private server that trusts everyone it invites will want, and which the IP arm in
* particular is worth reaching for if a shared network is being locked out.
*/
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
/**
* Whether a `create_account` with no platform — a password account, which is what the
* website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default:
* such an account has no identity but the address it came from, so the caps above are
* the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts.
* That is how one player made 150 accounts in a weekend. A game-client signup always
* carries a Steam or Meta identity and is untouched by this switch.
*
* Refused as `invalid_grant` with this description, which `www` translates for the form
* (see its auth-messages.ts) — keep the two in step.
*/
const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/**
* The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
* driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
* `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
* in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
* (a refresh, the website) is untouched by either.
*
* 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant`
* with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff
* included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what
* makes the update prompt stick, since a client that got a token anyway would play on.
* Dropping a build from the list is how its players are made to upgrade.
* 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF
* (developer or moderator) or its Discord link holds a role named in
* `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with
* ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a
* brand-new account has no Discord link to hold a role, so the player makes their
* account on a released build, links Discord on the website, and comes back. Ending the
* rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone.
*
* `ver` is client-reported and unverified, like everything else about the build. That is
* fine for what these are: rollout and upgrade gates, not a security boundary. A player
* who lies about their build gets a token the surfaces of the build they claim would have
* handed them anyway, and then runs a client the server isn't answering for.
*/
const ROLLOUT_GATED_DESCRIPTION =
'this build is limited to Discord supporters; link your Discord on the website first'
const UNSUPPORTED_BUILD_DESCRIPTION =
'this game version is not supported by this server; update your game'
/**
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
*/
const rolloutRoleIds = parseRoleIdList
/**
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
* flag on the account, the same two `staff_login` audits — always may, configured roles or
* not: they are who tests a build before it reaches supporters, so a closed gate stays open
* to them. Anyone else needs a Discord link (platform 101, written by the website's benefits
* claim and refreshed by `www`'s daily sweep) recording at least one of the configured
* roles. The roles are a SNAPSHOT (see PlatformLink.roles), so a role granted in Discord
* today opens the gate once the player re-claims or the sweep runs — not instantly.
*
* An empty role list skips the link read: with no supporter role named, nobody is a
* supporter.
*/
async function accountMayUseRolloutBuild(
db: D1Database,
accountId: number,
roleIds: readonly string[]
): Promise<boolean> {
const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
return accountHoldsDiscordRole(db, accountId, roleIds)
}
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -200,22 +329,84 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
if (role === 'screenshare') return true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
/**
* The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body
* as a bool, not as an object — or an empty 404 for a player this server has no account for,
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
*/
async function roleAnswer(
c: Context<App>,
role: 'developer' | 'moderator' | 'screenshare',
id: string
) {
logger.info(`${role} role lookup`, { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(holdsRole(account, role))
}
/**
* The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as
* an array of ints. A different question from the single lookup and so a different shape —
* never that route's boolean.
*
* A FILTER, so nothing here is an error: an id that names no account, or names one without the
* role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the
* question "which of these are developers" has the answer "none of them", not "no such thing".
*
* Answered in the order the query asked, de-duplicated, so a caller can read the result
* against its own list. One round trip however many ids are named: `getAccountsByIds` splits
* the read across statements when it exceeds D1's bound-parameter limit.
*/
async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator') {
// `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would
// collapse them to the first and silently answer about one player out of the set.
const asked = c.req.queries('id') ?? []
const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))]
logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length })
if (ids.length === 0) return c.json([])
const accounts = await getAccountsByIds(c.env.DB, ids)
const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId))
return c.json(ids.filter((id) => holders.has(id)))
}
/**
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
* tokens are deterministic.
* extras, plus `junior` off the account's own `isJunior` flag.
*
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
* treats that exact claim as Full access, which is what lets the editor upload. The
* caller resolves it (it costs a link read) so this stays a pure projection of the
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
studio: boolean
): string[] {
const roles = ['screenshare']
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (studio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -241,6 +432,226 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* The browser page Studio opens lives on WWW, not on this worker. `ns` is what
* told the editor where WWW is, so the URI has to be built from the same domain
* and the same subdomain override or the tab opens on a host the player doesn't have.
*/
function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } {
const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW
const uri = `${www}/device`
return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` }
}
function oauthError(
c: Context<App>,
error: string,
error_description: string,
status: 400 | 500 = 400
) {
return c.json({ error, error_description }, status)
}
/**
* Issue the same token body every other grant returns, for an account that has
* already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/
async function issueSession(
c: Context<App>,
accountId: number,
grantType: string,
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
extra: Record<string, unknown> = {}
) {
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
})
}
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const roleAccount = await getAccount(c.env.DB, accountId)
if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists')
const accessToken = await generateToken(
String(accountId),
roleAccount.platformId ?? '',
accountPlatform(roleAccount),
jwtSecret,
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
accountPrivileges(roleAccount),
GAME_VERSION,
roleAccount.hasPlus === true
)
const refreshToken = await issueRefreshToken(c.env.DB, accountId)
await setLastLoginTime(c.env.DB, accountId, new Date().toISOString())
await setLoginContext(c.env.DB, accountId, { ip: clientIp })
const staffRoles = (['developer', 'moderator'] as const).filter((role) =>
holdsRole(roleAccount, role)
)
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'staff_login',
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount.username ?? null,
deviceId: '',
deviceClass: 0,
version: null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
...extra,
})
}
/**
* The `identity_assertion` grant: sign into — or create — the account behind a platform
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
*
* Handled before the password fallback and outside the version/rollout gates, like the
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
* there is none yet) is decided here, from the same table `cached_login` reads, so the
* website can never choose an account id for itself.
*
* Creating an account when the identity opens none is deliberate — it is the Discord
* sign-up — and takes the same guards `create_account` does for a verified platform
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
* cap (`www` forwards the browser's real address over the service binding) and the
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
* has no identity but its address, and this one has a Discord user Discord itself vouched
* for, one account per user. The new account carries no `platform`/`platformId` on its
* blob — only the link — so its DTO and token read like a password account's and the
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
*/
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing an identity assertion: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
if (identity === null) {
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
}
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
if (links.length > 1) {
// The benefits claim keeps a Discord identity on one account, so this is a data
// problem rather than a player's choice to make; refused rather than guessed.
logger.error('identity assertion names an identity linked to several accounts', {
platform: identity.platform,
accountIds: links.map((l) => l.accountId),
})
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
}
if (links.length === 1) {
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
}
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const blocked = await resolveBan(c.env.DB, null, {
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (blocked) {
logger.info('identity signup refused: player banned', {
via: blocked.via,
bannedAccountId: blocked.bannedAccountId,
ip: clientIp,
platform: identity.platform,
})
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
}
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
if (
maxPerPlatformId > 0 &&
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
maxPerPlatformId
) {
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
}
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
}
const account = await createAccount(c.env.DB, {
platforms: 0,
lastLoginTime: new Date().toISOString(),
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
logger.info('created an account from an identity assertion', {
accountId: account.accountId,
platform: identity.platform,
ip: clientIp,
})
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
}
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const userCode = typeof body.user_code === 'string' ? body.user_code : ''
const result = await decide(c.env.DB, userCode, id)
if (result.kind === 'ok') return c.json({ ok: true as const })
if (result.kind === 'expired')
return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.')
if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.')
return oauthError(c, 'invalid_grant', 'that code was not found.')
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
@@ -547,6 +958,83 @@ const app = new Hono<App>()
}
)
// Studio editor device login. The POST the editor makes is
// `https://auth.<domain>/connect/deviceauthorization` — Auth is service enum 0,
// and the path is the literal `connect/deviceauthorization`. The browser half
// of the handshake is the verification_uri, which points at WWW `/device`.
.post(
'/connect/deviceauthorization',
describeRoute({
tags: ['Token'],
summary: 'Start a Rec Room Studio device login',
description: [
'RFC 8628 device authorization, called by Rec Room Studio (not the game).',
'The editor POSTs `client_id=recroom.studio` and its embedded client secret',
'to the Auth host. The response `verification_uri` is the website (`WWW /device`);',
'`verification_uri_complete` is that page with `?user_code=` and is what Studio',
'opens in the browser. Studio then polls `POST /connect/token` with',
'`grant_type=urn:ietf:params:oauth:grant-type:device_code`.',
].join(' '),
requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'),
responses: {
200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'),
400: json(OAuthError, 'client_id or client_secret was not the Studio editor'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const grant = await beginDeviceGrant(c.env.DB)
const { uri, complete } = studioVerificationUri(c.env, grant.userCode)
return c.json({
device_code: grant.deviceCode,
user_code: grant.userCode,
verification_uri: uri,
verification_uri_complete: complete,
expires_in: grant.expiresIn,
interval: grant.interval,
})
}
)
.post(
'/connect/device/approve',
describeRoute({
tags: ['Token'],
summary: 'Approve a Studio device code',
description:
'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is approved'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, approveDeviceGrant)
)
.post(
'/connect/device/deny',
describeRoute({
tags: ['Token'],
summary: 'Deny a Studio device code',
description:
'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is denied'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, denyDeviceGrant)
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post(
'/connect/token',
@@ -554,14 +1042,18 @@ const app = new Hono<App>()
tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT',
description: [
'Issues an access token (plus a single-use refresh token) for one of four grants,',
'Issues an access token (plus a single-use refresh token) for one of six grants,',
'selected by `grant_type`. Every grant returns the same body on success.',
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`password` becomes the login credential. Without a `platform` this is a PASSWORD',
'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —',
'off by default, refused as `invalid_grant` "password signup is disabled". Subject',
'to three independent signup caps,',
'per verified platform id, per signup IP and per client `device_id`',
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
'any disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
@@ -573,6 +1065,17 @@ const app = new Hono<App>()
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
'',
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
'the same link table as `cached_login`: one linked account is signed into; none means a',
'NEW account is created and linked (the body then carries `created: true`), under the',
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
'switch — the Discord user is the identity a password account lacks. The new account',
'has no password until its owner sets one on the website; with one, it signs in',
'anywhere the `password` grant does. Several linked accounts is refused.',
'',
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
@@ -601,26 +1104,50 @@ const app = new Hono<App>()
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. A junior also carries',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
'and whether the ban is its own or reaches it through the evasion arms.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
'and show the player the block screen that explains the ban; the ban itself is',
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
'gets them as far as that screen and no further.',
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
'token gets them as far as that screen and no further.',
'',
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
'an account has no ban of its own for the block screen to describe, so there is',
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'account refused may belong to a housemate of the banned player rather than to them.',
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
'(`ip`, `platform`, or `off`).',
'',
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.',
'',
'**Support gate.** A grant whose `ver` names a build this server does not serve —',
'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)',
'and not under rollout — is refused as `invalid_grant` "this game version is not',
'supported by this server; update your game", for every grant and every account.',
'`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the',
'list is how its players are made to upgrade. Grants posting no `ver` are untouched.',
'',
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
'(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends',
'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to',
'Discord supporters" unless the account is staff (developer or moderator) or its',
'Discord link (made by the website’s benefits claim) holds one of the roles in the',
'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
'everyone but staff. `create_account` on such a',
'build is refused before it mints anything, since a new account has no Discord link.',
'Grants posting no `ver` (a refresh, the website) and every other build are untouched.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -651,6 +1178,35 @@ const app = new Hono<App>()
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// Studio's poll. Handled before the password fallback, which would otherwise
// treat this grant_type as a username/password login and 400 it.
if (grantType === DEVICE_CODE_GRANT) {
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''
const polled = await pollDeviceGrant(c.env.DB, deviceCode)
if (polled.kind === 'pending') {
return oauthError(
c,
'authorization_pending',
'the player has not approved this login yet'
)
}
if (polled.kind === 'denied') {
return oauthError(c, 'access_denied', 'the player denied this login')
}
if (polled.kind === 'expired') {
return oauthError(c, 'expired_token', 'the device code expired')
}
if (polled.kind !== 'approved') {
return oauthError(c, 'invalid_grant', 'unknown device code')
}
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
}
// The website's Discord sign-in, likewise before the fallback — and before the
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -674,9 +1230,9 @@ const app = new Hono<App>()
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
// running rather than this server's GAME_VERSION — `match` reads it back off the
// token when it writes presence. Unverified like the device fields, and only ever
// echoed, never trusted for a decision (the version CHECK is `api`'s
// `/api/versioncheck/v4`, against its own list).
// token when it writes presence. Unverified like the device fields: echoed, and
// gated (below, and the rollout gate further down), but never trusted as a
// credential.
//
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
// undefined and generateToken falls back to GAME_VERSION. An empty string is
@@ -690,6 +1246,23 @@ const app = new Hono<App>()
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server
// doesn't serve gets no token, whatever the grant and whoever the account. Decided
// before any credential is read — it depends on nothing but the operator's list, so
// there is nothing about an account to probe here, and a closed door costs no D1
// round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update.
if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) {
logger.info('token refused: game version is not supported', {
grantType,
version,
ip: clientIp,
})
return c.json(
{ error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION },
400
)
}
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
@@ -700,7 +1273,7 @@ const app = new Hono<App>()
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
// binds no platformId, and is open only while PASSWORD_SIGNUP says so.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
@@ -789,6 +1362,32 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A rollout-gated build can't sign UP: the account it would mint has no Discord
// link, so it could never hold the role the gate wants. Refused before anything
// is read or minted, like the switch below — no row left behind, no cap slot
// burned.
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
logger.info('signup refused: build is limited to Discord supporters', {
version,
ip: clientIp,
})
return c.json(
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
400
)
}
// The password-account path (no platform) is a switch, off by default: see
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
// a closed door costs no D1 round trip and burns no cap slot.
if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) {
logger.info('signup refused: password signup is disabled', { ip: clientIp })
return c.json(
{ error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION },
400
)
}
// A banned player's next move is a new account, so the evasion arms are checked
// BEFORE one is minted — against the only identity a signup has, the IP it came
// from and the platform identity it just proved. Refusing after the fact (as the
@@ -820,7 +1419,7 @@ const app = new Hono<App>()
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// identity is unknown (no verified platform id / no client IP / no device id) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
@@ -828,6 +1427,10 @@ const app = new Hono<App>()
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
const maxPerDeviceId = intVar(
c.env.MAX_ACCOUNTS_PER_DEVICE_ID,
DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID
)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
@@ -862,6 +1465,20 @@ const app = new Hono<App>()
400
)
}
if (
maxPerDeviceId > 0 &&
deviceId !== '' &&
(await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId
) {
logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this device',
},
400
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
@@ -987,33 +1604,63 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A DIRECTLY banned account still gets its token. The client needs one to reach
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// (category, time left, "Rule violation") — refused here, they would only ever see
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
// matchmake for a banned player, so the token gets them as far as the block screen
// and no further. Logged, so the operator can see a banned player signing in.
// The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
// and Discord supporters only, so the account signing in on it must be staff or hold
// one of the configured roles.
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong
// password on a gated build is still "invalid account_id or password", so the
// gate can't be used to probe whether an account exists. Unlike a ban, this IS a
// refusal: there is no block screen to reach, and the client surfaces the
// description on its sign-in failure.
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
logger.info('login refused: build is limited to Discord supporters', {
accountId,
grantType,
version,
// The COUNT, not the ids: zero here means the gate is closed because
// nothing is configured, which reads very differently from "not a
// supporter".
roleIdCount: roleIds.length,
})
return c.json(
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
400
)
}
}
// EVERY account gets its token, banned or not — a ban of its own, or a link to
// somebody else's through the evasion arms. The client needs one to reach `api`'s
// moderationBlockDetails, which is where a banned player is TOLD they are banned
// (category, time left, "Rule violation"); refused here they would only ever see a
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
// the block screen and no further.
//
// Ban EVASION is still refused here: an account that merely shares a device or
// network with a banned one has no ban of its own for that screen to describe, so
// there is nothing to let it in for — and letting it in is exactly what the evader
// wants. Checked once here, after the grant has resolved an account, so it covers
// every grant: password, cached_login and a refresh_token redeemed by a client that
// has been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
// That includes ban EVASION, which this used to refuse. An evader signing in lands
// in their dorm and can enter no room — the same place the ban leaves them — and
// refusing the token instead only ever produced a client stuck on a failed login.
// `create_account` is still refused before it mints anything (above): that is making
// a NEW account to evade with, which is a different act from signing into one.
//
// Logged either way, and the arm is logged with it: "banned" and "shares a network
// with somebody banned" are very different things to be reading in a log.
//
// Deliberately AFTER the credential checks — a wrong password is still "invalid
// account_id or password", so this can't be used to probe whether an account exists
// or is banned without knowing it.
//
// The request's own IP and proven identity are passed alongside the account, so a
// ban also reaches an old, clean account logged into from the banned player's
// device or network — the stored ips alone would only catch that on the SECOND
// login. create_account was already refused before it minted anything (above);
// this still runs for it, so a signup that raced one is refused too.
// ban also reaches an old, clean account logged into from the banned player's device
// or network — the stored ips alone would only catch that on the SECOND login.
const ban = await resolveBan(c.env.DB, Number(accountId), {
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban && ban.via !== 'account') {
logger.info('token refused: ban evasion', {
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
@@ -1021,15 +1668,6 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
if (ban) {
logger.info('token issued to banned account', {
accountId,
grantType,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
@@ -1062,7 +1700,10 @@ const app = new Hono<App>()
platformId,
platform,
jwtSecret,
accountRoles(roleAccount),
accountRoles(
roleAccount,
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
),
accountPrivileges(roleAccount),
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
@@ -1076,6 +1717,58 @@ const app = new Hono<App>()
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
// call there); this is the row saying the session those actions came from began — and
// from what device, build and IP, which is the first thing anyone asks when an action
// further down the log looks wrong. The flags themselves are the trigger, so an account
// granted the role between logins starts being recorded the next time it signs in.
//
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
// and recording those would bury the logins under hourly repeats that say nothing new
// about who came in or from where.
//
// Written after the token is minted, so the row means a login that actually happened,
// and caught rather than awaited into the response: a failed audit insert must not turn
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
// instead — the one thing a gap in an audit trail should never do quietly.
const staffRoles =
roleAccount && grantType !== 'refresh_token'
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
: []
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: Number(accountId),
action: 'staff_login',
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
// field here that says WHERE the session came from rather than what it could do.
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
// than omitted, so a row can't be read as "we didn't record one".
//
// Then: which powers the session carries, HOW they signed in, and the same
// unverified-but-recorded device/build context the account row keeps — kept here
// too so the row stands on its own months later, after the account's
// `lastLoginIp` and device have been overwritten by every login since.
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount?.username ?? null,
deviceId,
deviceClass,
version: version ?? null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
@@ -1172,26 +1865,34 @@ const app = new Hono<App>()
// is off by default and only an operator grants it (via `runx admin grant-developer`,
// which sets the account's isDeveloper flag); it also rides in the token's `role`
// claim (see accountRoles).
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
const { id } = c.req.param()
logger.info('developer role lookup', { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(account.isDeveloper === true)
})
.get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) =>
roleAnswer(c, 'developer', c.req.param('id'))
)
// The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers,
// as an array of ints. A route of its own rather than an optional `:id?` segment, and a
// different answer shape from the single lookup above: both are kept because the client asks
// both ways (the path form is the 2023 one).
.get('/role/developer', describeRoute(roleFilter('developer')), (c) =>
roleFilterAnswer(c, 'developer')
)
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
// the token's `role` claim.
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
const { id } = c.req.param()
logger.info('moderator role lookup', { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(account.isModerator === true)
})
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) =>
roleAnswer(c, 'moderator', c.req.param('id'))
)
// Moderator's bulk form, kept in step with developer's above.
.get('/role/moderator', describeRoute(roleFilter('moderator')), (c) =>
roleFilterAnswer(c, 'moderator')
)
// Studio queries each room owner's screenshare role before validating video players.
.get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) =>
roleAnswer(c, 'screenshare', c.req.param('id'))
)
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
+68
View File
@@ -26,6 +26,17 @@ export type Env = SharedHonoEnv & {
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
/**
* Whether `create_account` accepts a signup with NO platform identity — the password
* account the website makes (`www` `/api/signup`), or anything else that posts the
* grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a
* server opens web signup deliberately. Read through `flagVar`. The `www` worker reads
* the same knob to hide its form and refuse its endpoint, so the two agree for free.
* Platform signups (Steam, Meta) are never governed by this — they always have an
* identity to authenticate with later, and the caps above are what limit them.
*/
PASSWORD_SIGNUP?: string | number | boolean
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
@@ -38,6 +49,63 @@ export type Env = SharedHonoEnv & {
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
/**
* The Discord role ids that may sign in on a ROLLOUT-GATED client build (see
* `ROLLOUT_GAME_VERSIONS` in @repo/domain) — numeric snowflakes, separated by commas
* and/or whitespace, exactly like `www`'s DISCORD_BENEFITS_ROLE_IDS. ANY one of them
* qualifies. A login whose posted `ver` is a gated build is refused unless the account's
* Discord link (`platform_account`, platform 101, written by the website's benefits
* claim) records one of these roles — or the account is STAFF (developer or moderator),
* who are always let in, configured roles or not.
*
* Unset — or parsing to no ids — leaves the gate CLOSED: nobody but staff gets a token on
* a gated build. That is the safe reading of "supporters only" when no supporter role has been
* named, and it keeps a build that isn't ready from being reachable by accident.
* Logins on every other build are never touched by this.
*/
DISCORD_ROLLOUT_ROLE_IDS?: string
/**
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
* role can be let into Studio without touching anyone's account. Same grammar as
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
* read from the same Discord link snapshot. Set for every worker from the root `.env`
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
* nobody — the flag alone decides. The flag still works beside it.
*/
STUDIO_ROLLOUT?: string
/**
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
* the default `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the
* root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so this refuses exactly the builds
* `api`'s `/api/versioncheck/v4` tells to update — dropping a build from the list is how
* its players are made to upgrade. Read through `supportedGameBuilds`, which also folds
* in every build under rollout (below). A grant posting no `ver` is never gated.
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout — the ones the Discord-role gate above applies to —
* overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: same format, or `off` to end the
* rollout and open those builds to every account. Set for every worker
* (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so `match`'s rollout voice pool follows the same
* list. A build here is served whether or not `SUPPORTED_GAME_VERSIONS` names it. Read
* through `rolloutGameBuilds`.
*/
ROLLOUT_GAME_VERSIONS?: string
/**
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at
* `www.<domain>/device`, so auth has to know the same domain `ns` advertises.
*/
DOMAIN: string
/**
* The same `SUBDOMAINS` JSON `ns` applies. A `www` override has to move the
* verification URI with the host the editor was told WWW lives on.
*/
SUBDOMAINS: string
}
/** Variables can be extended */
+113 -7
View File
@@ -105,7 +105,16 @@ export const FakeCachedLogin = CachedLogin.extend({
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
error: z.enum([
'invalid_grant',
'invalid_request',
'invalid_client',
'server_error',
// RFC 8628. Studio polls until it sees something other than authorization_pending.
'authorization_pending',
'expired_token',
'access_denied',
]),
error_description: z.string(),
})
@@ -128,8 +137,19 @@ export const TokenResponse = z.object({
*/
export const TokenRequest = z.object({
grant_type: z
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
.describe('Anything unrecognised (including absent) is treated as a password grant'),
.enum([
'create_account',
'cached_login',
'refresh_token',
'password',
'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
])
.describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
'and identity_assertion, the website’s Discord sign-in'
),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
@@ -156,6 +176,22 @@ export const TokenRequest = z.object({
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
client_id: z.string().optional().describe('Studio device grant: `recroom.studio`'),
client_secret: z
.string()
.optional()
.describe('Studio device grant: the secret embedded in the editor'),
device_code: z
.string()
.optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
assertion: z
.string()
.optional()
.describe(
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
),
device_id: z
.string()
.optional()
@@ -171,6 +207,33 @@ export const TokenRequest = z.object({
),
})
/** `POST /connect/deviceauthorization` form body. Studio's editor sends exactly these two. */
export const DeviceAuthorizationRequest = z.object({
client_id: z.string().describe('`recroom.studio`'),
client_secret: z.string().describe('The secret compiled into Rec Room Studio'),
})
/** RFC 8628 response. Property names match Studio's `DeviceAuthorizationResponse`. */
export const DeviceAuthorizationResponse = z.object({
device_code: z.string(),
user_code: z.string().describe('Shown in the editor; also on verification_uri_complete'),
verification_uri: z.string().describe('WWW `/device`, without the code'),
verification_uri_complete: z
.string()
.describe('The URL Studio opens in the browser, `verification_uri?user_code=`'),
expires_in: z.int().describe('Seconds until the code dies. Studio treats this as seconds'),
interval: z.int().describe('Seconds Studio waits between polls'),
})
/** `POST /connect/device/approve` and `/connect/device/deny` form body. */
export const DeviceDecisionRequest = z.object({
user_code: z.string().describe('The code Studio is showing, spaces and hyphens ignored'),
})
export const DeviceDecisionResponse = z.object({
ok: z.literal(true),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
@@ -232,14 +295,18 @@ export const RestrictionDto = z.object({
DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'),
})
export function roleLookup(role: 'developer' | 'moderator') {
export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
return {
tags: ['Roles'],
summary: `Whether a player has the ${role} role`,
description:
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.`,
role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
parameters: [
{
name: 'id',
@@ -256,6 +323,45 @@ export function roleLookup(role: 'developer' | 'moderator') {
}
}
/**
* The BULK form: `/role/{role}?id=1&id=2` answers which of those players hold the role, as an
* array of ints. Not the single lookup's boolean — a different question, so a different shape;
* the two live side by side because the client asks both ways.
*/
export function roleFilter(role: 'developer' | 'moderator') {
return {
tags: ['Roles'],
summary: `Which of these players have the ${role} role`,
description:
`The subset of the accounts named by \`?id=\` that hold the ${role} role, as an ARRAY ` +
`OF INTS — not a boolean, and not an object. Ids are repeated (\`?id=1&id=2\`), not ` +
`comma-joined.\n\n` +
`It is a FILTER, so a player who does not hold the role is simply absent from the ` +
`answer, as is an id matching no account — neither is an error, and a query naming no ` +
`ids answers \`[]\`. Results keep the order the query asked in, and a repeated id ` +
`yields at most one entry.\n\n` +
`The role is off by default and granted only by an operator via ` +
`\`runx admin grant-${role}\`.`,
parameters: [
{
name: 'id',
in: 'query' as const,
required: false,
description:
'An account id to test, repeated once per player. Ids that are non-numeric, unknown ' +
'or lack the role are left out of the answer rather than erroring.',
style: 'form' as const,
explode: true,
schema: { type: 'array' as const, items: { type: 'string' as const } },
example: ['2', '42'],
},
],
responses: {
200: json(z.int().array(), `The ids, of those asked for, that hold the ${role} role`),
},
}
}
/** Bulk cached-login lookup form body: repeated `id=` fields. */
export const PlatformIdsRequest = z.object({
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
+268 -9
View File
@@ -27,17 +27,28 @@
* `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.
*
* That entitlement is also the one thing a link carries beyond the identity itself: the
* `role` column (migration 0010) records the Discord roles the claim read, so what a
* player was granted Plus FOR survives the claim instead of collapsing into a bare
* `hasPlus`. The `www` worker's daily cron re-reads them through a bot token when one is
* configured (apps/www/src/discord-roles.ts). See {@link PlatformLink.roles}.
*/
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, plus
* 0010's `role` column — which is an ALTER on prod and a plain column here, since this
* builds the table from nothing).
*/
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
role TEXT,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
@@ -113,6 +124,19 @@ export interface PlatformLink {
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
/**
* The roles this identity held in its platform's community when the link was last
* written — Discord role id snowflakes, kept as strings (one exceeds 2^53). Empty for
* every platform that has no such thing, which is all of them but Discord.
*
* A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
* player's own OAuth token served, and the `www` worker's daily cron re-reads every
* Discord link through a bot token when the operator has configured one
* (apps/www/src/discord-roles.ts) — so with a bot it trails the guild by at most a day,
* and without one it says what they held when they claimed. Stored as a JSON array in
* the `role` column (migration 0010).
*/
roles: string[]
}
interface LinkRow {
@@ -120,10 +144,39 @@ interface LinkRow {
platform: number
platformId: string
linkedAt: string
/** The `role` column: a JSON array of role ids, or NULL for a link that has none. */
role: string | null
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt FROM platform_account`
linked_at AS linkedAt, role FROM platform_account`
/**
* A stored `role` array back into role ids. Tolerant by design — every failure mode is
* "this link has no roles recorded", which is the truth for every Steam and Meta link and
* the honest answer for a row written before 0010. A NULL, a value that isn't JSON, or a
* JSON value that isn't an array of strings all land there rather than throwing: the roles
* are a record, and no caller's correctness depends on them, so a bad one must not take
* down the login picker that reads the same rows.
*/
const parseRoles = (raw: string | null): string[] => {
if (raw === null || raw === '') return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === 'string') : []
} catch {
return []
}
}
/** One row as the rest of the codebase sees it. */
const toLink = (row: LinkRow): PlatformLink => ({
accountId: row.accountId,
platform: row.platform,
platformId: row.platformId,
linkedAt: row.linkedAt,
roles: parseRoles(row.role),
})
/**
* Link a verified platform identity to an account. Idempotent — re-logging in on the
@@ -132,24 +185,89 @@ const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS pl
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*
* `roles` records what that identity held in its platform's community — Discord role ids,
* from the benefits claim's member read. Omitting it (every login path: Steam and Meta
* have no such thing) leaves the column NULL rather than writing an empty array, so
* "never had roles" stays distinguishable in the table from "claimed holding none". An
* EMPTY array is a real value and is stored as one.
*
* Unlike the rest of the row, roles are REFRESHED on a repeat link: they're a snapshot of
* a membership that moves, and the later claim is the fresher reading. That is also why
* the update is separate from the insert rather than an upsert — the insert's
* `meta.changes` is what tells a new link from an existing one, and an
* `ON CONFLICT DO UPDATE` reports a change either way, which would make every re-login
* look like a first link to {@link linkLoginIdentity}'s primary-identity write. The second
* statement only runs on the Discord path, and only when the row was already there.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string
platformId: string,
roles?: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const role = roles === undefined ? null : JSON.stringify([...roles])
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at, role)
VALUES (?1, ?2, ?3, ?4, ?5)`
)
.bind(accountId, platform, platformId, new Date().toISOString(), role)
.run()
if (res.meta.changes > 0) return true
if (roles !== undefined) {
await setPlatformLinkRoles(db, accountId, platform, platformId, roles)
}
return false
}
/**
* Overwrite the roles recorded on an EXISTING link. The daily sweep's write
* (apps/www/src/discord-roles.ts), and the second half of {@link linkPlatformIdentity}'s
* re-claim. Returns whether a link matched: the sweep reads links and writes them back, so
* a false here means the row went away in between, which is worth a log line and nothing
* more.
*/
export async function setPlatformLinkRoles(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
`UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
)
.bind(accountId, platform, platformId, new Date().toISOString())
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
.run()
return res.meta.changes > 0
}
/**
* Every link on one platform, oldest first — the sweep's read, which walks all of them.
*
* Unfiltered by {@link CACHED_LOGIN_PLATFORMS} on purpose — its one caller asks about
* Discord, the platform that filter exists to exclude. It is not a lookup by identity, so
* the privacy argument for the filter (see {@link getLinksForPlatformId}) doesn't apply:
* nothing here is reachable from a request.
*/
export async function getLinksForPlatform(
db: D1Database,
platform: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`)
.bind(platform)
.all<LinkRow>()
return results.map(toLink)
}
/**
* The accounts a platform identity opens — what the login-screen picker lists.
* Ordered oldest link first so the list is stable between launches (D1 row order
@@ -170,7 +288,7 @@ export async function getLinksForPlatformIdentity(
)
.bind(platform, platformId)
.all<LinkRow>()
return results
return results.map(toLink)
}
/**
@@ -193,7 +311,148 @@ export async function getLinksForPlatformId(
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.all<LinkRow>()
return results
return results.map(toLink)
}
/**
* Every account a platform identity is linked to, on ANY platform — including the ones
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
*
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
* every public route: the filter there exists so nobody can learn which account a Discord
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
* grant, where the identity being asked about has just been PROVEN by the worker that
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
* it is about and nobody else. Add a caller only under the same condition.
*/
export async function getLinksForIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results.map(toLink)
}
/**
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
*
* This is what "has this account claimed its Discord benefits" means now that a Discord
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
* gift and its page both ask this rather than "is there a link", so a player who made
* their account from Discord is still owed, and still shown, their first claim.
* An EMPTY role list counts as recorded: that is a claim that found no roles.
*/
export async function hasRecordedRoles(
db: D1Database,
accountId: number,
platform: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
)
.bind(accountId, platform)
.first<{ ok: number }>()
return row !== null
}
/**
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
* this call was the one that did it; false when the link is missing or already carries a
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
* is the caller's next step.
*
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
* sign-in is already inserted by the time the claim comes, so this gives the claim the
* same single-winner write on the roles column instead.
*/
export async function recordFirstRoles(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
)
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
.run()
return res.meta.changes > 0
}
/**
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
*/
export function parseRoleIdList(raw: string | undefined): string[] {
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
/**
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
* re-claims or the sweep runs — not instantly.
*/
export async function accountHoldsDiscordRole(
db: D1Database,
accountId: number,
roleIds: readonly string[]
): Promise<boolean> {
if (roleIds.length === 0) return false
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
}
/**
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
* `/api/studio-access` reports. Two grants, either suffices:
*
* - the account's own `hasStudio` flag, set by staff on the website or
* `runx admin grant-studio`;
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
* whole Discord role the same access without touching anyone's account. Unset, or
* naming no role, it grants nobody — the flag alone decides, as before.
*
* Every reader goes through this so the three surfaces cannot disagree: a role holder
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
* see the same answer. The flag is checked first so an account that has it costs no
* link read.
*/
export async function hasStudioAccess(
db: D1Database,
account: { accountId: number; hasStudio?: boolean } | null | undefined,
studioRolloutRoleIds: string | undefined
): Promise<boolean> {
if (!account) return false
if (account.hasStudio === true) return true
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
@@ -205,7 +464,7 @@ export async function getLinksForAccount(
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results
return results.map(toLink)
}
/**
+211
View File
@@ -0,0 +1,211 @@
/**
* Rec Room Studio's device-login handshake.
*
* The editor (RecRoom.Studio.Editor.Core, `MKLLKHBNNLP`) POSTs
* `connect/deviceauthorization` to the **Auth** service — service enum value 0,
* the host `ns` advertises as `Auth` — with `client_id=recroom.studio` and the
* client secret embedded in that assembly. It then opens `verification_uri_complete`
* in the browser and polls `POST connect/token` on the same Auth host with
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` until the code is
* approved or `expires_in` seconds pass.
*
* `GET account/me` after a successful poll is a different service: enum 11,
* **Accounts**. This module does not serve that.
*
* The page the editor opens is **WWW** `/device`. Auth only mints the codes and,
* once that page has approved one, the token.
*/
/** What Studio posts as `client_id` on the device grant. */
export const STUDIO_CLIENT_ID = 'recroom.studio'
/**
* The matching `client_secret` compiled into the Studio editor. It is not a
* server secret — anyone with the editor binary has it — but checking it keeps
* a random POST from minting codes.
*/
export const STUDIO_CLIENT_SECRET = '54oc5C4BH7jpqp42KhChkNZOLP0U3yTx'
/** `grant_type` Studio polls with. RFC 8628. */
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
/** How long the player has to approve, in seconds. Studio reads this as seconds. */
export const DEVICE_TTL_SECONDS = 10 * 60
/** Minimum poll spacing Studio is told to use, in seconds. */
export const DEVICE_POLL_INTERVAL_SECONDS = 5
/** Unambiguous alphabet. Studio pretty-prints a code only when it is exactly 6 chars. */
const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const USER_CODE_LENGTH = 8
export const STUDIO_DEVICE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
]
type GrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'
export interface DeviceGrant {
userCode: string
deviceCode: string
expiresIn: number
interval: number
}
export type DevicePoll =
| { kind: 'pending' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'invalid' }
| { kind: 'approved'; accountId: number }
interface GrantRow {
account_id: number | null
status: GrantStatus
expires_at: number
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
function randomUserCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(USER_CODE_LENGTH))
let code = ''
for (const byte of bytes) code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]
return code
}
function randomDeviceCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/** Strip spaces and hyphens and uppercase, so a code typed as Studio displays it still matches. */
export function normalizeUserCode(raw: string): string {
return raw.replace(/[\s-]/g, '').toUpperCase()
}
export function studioClientMatches(body: Record<string, unknown>): boolean {
return body.client_id === STUDIO_CLIENT_ID && body.client_secret === STUDIO_CLIENT_SECRET
}
export async function beginDeviceGrant(db: D1Database): Promise<DeviceGrant> {
const now = nowSeconds()
const deviceCode = randomDeviceCode()
const deviceCodeHash = await sha256(deviceCode)
for (let attempt = 0; attempt < 5; attempt++) {
const userCode = randomUserCode()
try {
await db
.prepare(
`INSERT INTO device_grant
(device_code_hash, user_code, account_id, status, created_at, expires_at)
VALUES (?1, ?2, NULL, 'pending', ?3, ?4)`
)
.bind(deviceCodeHash, userCode, now, now + DEVICE_TTL_SECONDS)
.run()
return {
userCode,
deviceCode,
expiresIn: DEVICE_TTL_SECONDS,
interval: DEVICE_POLL_INTERVAL_SECONDS,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('UNIQUE')) throw err
}
}
throw new Error('could not allocate a studio device code')
}
async function readByHash(db: D1Database, deviceCode: string): Promise<GrantRow | null> {
return db
.prepare(`SELECT account_id, status, expires_at FROM device_grant WHERE device_code_hash = ?1`)
.bind(await sha256(deviceCode))
.first<GrantRow>()
}
/**
* One poll from Studio. An approved code is consumed exactly once: two polls
* racing each other cannot both walk away with a session.
*/
export async function pollDeviceGrant(db: D1Database, deviceCode: string): Promise<DevicePoll> {
const row = await readByHash(db, deviceCode)
if (!row) return { kind: 'invalid' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
if (row.status === 'pending') return { kind: 'pending' }
if (row.status === 'denied') return { kind: 'denied' }
if (row.status !== 'approved' || row.account_id == null) return { kind: 'invalid' }
const consumed = await db
.prepare(
`UPDATE device_grant SET status = 'consumed'
WHERE device_code_hash = ?1 AND status = 'approved' AND expires_at > ?2`
)
.bind(await sha256(deviceCode), nowSeconds())
.run()
if ((consumed.meta.changes ?? 0) < 1) return { kind: 'invalid' }
return { kind: 'approved', accountId: row.account_id }
}
export type DeviceDecision =
{ kind: 'ok' } | { kind: 'missing' } | { kind: 'expired' } | { kind: 'used' }
async function decide(
db: D1Database,
userCode: string,
accountId: number,
status: 'approved' | 'denied'
): Promise<DeviceDecision> {
const code = normalizeUserCode(userCode)
if (code === '') return { kind: 'missing' }
const updated = await db
.prepare(
`UPDATE device_grant
SET status = ?2, account_id = ?3
WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?4`
)
.bind(code, status, accountId, nowSeconds())
.run()
if ((updated.meta.changes ?? 0) > 0) return { kind: 'ok' }
const row = await db
.prepare(`SELECT status, expires_at, account_id FROM device_grant WHERE user_code = ?1`)
.bind(code)
.first<{ status: GrantStatus; expires_at: number; account_id: number | null }>()
if (!row) return { kind: 'missing' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
// Approving again from the same account is the player double-clicking Allow.
if (status === 'approved' && row.status === 'approved' && row.account_id === accountId)
return { kind: 'ok' }
return { kind: 'used' }
}
export function approveDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'approved')
}
export function denyDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'denied')
}
+1178 -29
View File
@@ -5,7 +5,9 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import {
AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION,
getAccount,
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
@@ -13,9 +15,16 @@ import {
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
updateAccount,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import {
generateIdentityAssertion,
generateToken,
TOKEN_TTL_SECONDS,
validateIdentityAssertion,
} from '@repo/jwt'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import {
banFromReport,
createReport,
@@ -30,6 +39,11 @@ import {
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import {
STUDIO_CLIENT_ID,
STUDIO_CLIENT_SECRET,
STUDIO_DEVICE_SCHEMA_DDL,
} from '../../studio-device'
import type { Env } from '../../context'
@@ -70,6 +84,9 @@ beforeAll(async () => {
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
// migrations don't run here, so build it from the mirrored DDL.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
@@ -92,6 +109,10 @@ beforeAll(async () => {
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker) — every room read joins the published
// room badges from this table.
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
@@ -469,6 +490,60 @@ describe('auth worker routes', () => {
).resolves.toBe(1)
})
// The `role` column (migration 0010): what the benefits claim read about a member, kept
// so "why does this account have Plus" is answerable later. Exercised through the helper
// rather than the claim route, which runs through discord.com.
test('records the roles a claimed Discord identity held, and refreshes them on a re-claim', async () => {
const discordId = '308994132968210434'
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31400, username: 'RoleClaimer', hasPlus: true }))
.run()
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter])
const first = await getLinksForAccount(env.DB, 31400)
// Snowflakes come back as STRINGS — parsed as numbers they'd round past 2^53 and stop
// matching the real role.
expect(first.map((l) => l.roles)).toEqual([[supporter]])
// A re-claim is the same row (INSERT OR IGNORE, so `linkedAt` is the first claim's)
// with a FRESH role list: a membership moves, and this reading is the later one.
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter, booster])
const second = await getLinksForAccount(env.DB, 31400)
expect(second).toHaveLength(1)
expect(second[0]?.roles).toEqual([supporter, booster])
expect(second[0]?.linkedAt).toBe(first[0]?.linkedAt)
// Losing every role still stores the reading. Empty is a fact about the claim, not an
// absence of one — and it is what distinguishes this from a Steam link, below.
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [])
expect((await getLinksForAccount(env.DB, 31400))[0]?.roles).toEqual([])
const stored = await env.DB.prepare(
'SELECT role FROM platform_account WHERE account_id = 31400'
).first<{ role: string | null }>()
expect(stored?.role).toBe('[]')
})
// Every login path omits the argument, because Steam and Meta have no such thing. The
// column stays NULL there rather than becoming `[]`, so the table can still tell "never
// had roles" from "claimed holding none".
test('leaves the role column null for a link with no roles to record', async () => {
const steamId = '76561197962463299'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31401, username: 'NoRoles' }))
.run()
await linkPlatformIdentity(env.DB, 31401, PlatformType.Steam, steamId)
const row = await env.DB.prepare(
'SELECT role FROM platform_account WHERE account_id = 31401'
).first<{ role: string | null }>()
expect(row?.role).toBeNull()
// A NULL reads back as no roles rather than throwing — which is also how a row written
// before 0010 reads.
expect((await getLinksForAccount(env.DB, 31401))[0]?.roles).toEqual([])
})
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
@@ -623,6 +698,8 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// Studio upload access is a whitelist, not a default role.
expect(payload.role).not.toContain('betastudio')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
@@ -664,6 +741,138 @@ describe('auth worker routes', () => {
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
// developer is a different grant. It does not set the account's hasStudio flag.
expect(payload.role).not.toContain('betastudio')
})
// RecFlare Studio treats the role claim `betastudio` as permission to upload.
// It is stamped from the account's hasStudio flag on every mint path — password,
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
await seedAccount(9811, 'StudioPlayer')
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'betastudio'
)
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// developer and moderator stay in front of betastudio, so the claim order is stable.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 9812,
username: 'StudioStaff',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
hasStudio: true,
})
)
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
])
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as { device_code: string; user_code: string }
const session = await postToken(
`grant_type=password&username=StudioPlayer&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// Revoke between issue and refresh. The refresh re-reads the account.
const login = await postToken(`account_id=9811&password=${LOGIN_PASSWORD}`)
await updateAccount(env.DB, 9811, { hasStudio: false })
await updateAccount(env.DB, 9812, { hasStudio: false })
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
})
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
// as a flagged account is, with no account flag involved, from the same Discord link
// snapshot the build rollout gate reads. Unset, the flag alone decides.
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
const studioRole = '1077000000000000031'
const otherRole = '1077000000000000032'
await seedAccount(9813, 'StudioRoleHolder')
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
studioRole,
])
await seedAccount(9814, 'StudioRoleOutsider')
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
otherRole,
])
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
const original = env.STUDIO_ROLLOUT
try {
// Unset, or naming no role: the link grants nothing.
delete env.STUDIO_ROLLOUT
expect((await login(9813)).role).not.toContain('betastudio')
env.STUDIO_ROLLOUT = ' , '
expect((await login(9813)).role).not.toContain('betastudio')
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
expect((await login(9813)).role).toEqual(['gameClient', 'screenshare', 'betastudio'])
// A Discord link holding only other roles is not in the rollout.
expect((await login(9814)).role).not.toContain('betastudio')
// The account flag never moved: this is the var, not a grant written anywhere.
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
// next refresh — the same path a revoked flag takes.
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
delete env.STUDIO_ROLLOUT
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
} finally {
if (original === undefined) delete env.STUDIO_ROLLOUT
else env.STUDIO_ROLLOUT = original
}
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
@@ -743,6 +952,29 @@ describe('auth worker routes', () => {
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 95,
username: 'InfluencerPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isInfluencer: true,
})
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
await updateAccount(env.DB, 95, { isInfluencer: false })
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(revoked.role).not.toContain('influencer')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
@@ -864,6 +1096,74 @@ describe('auth worker routes', () => {
expect(row!.data).not.toContain('2.0')
})
// The grant the live client actually logs in with, and the one path whose device
// recording had no test — `create_account` and the password grant were both pinned,
// cached_login only happened to work. The body is the shape the 2025 client posts (see
// the capture the device id below comes from): device_id alongside the platform
// attestation, on every sign-in.
//
// Nothing verifies a device id — the client picks it — so it is recorded and never
// authorized on. It is stored for ban EVASION to read later: an account logging in from
// the same install as a banned one. See `getAccountsByDeviceId`.
test('POST /connect/token cached_login stores the login device on the account', async () => {
const metaId = '27061366730201234'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5151,
username: 'CachedDevicePlayer',
platform: 1,
platformId: metaId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5151, 1, metaId)
const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88'
const res = await metaLogin(
`grant_type=cached_login&account_id=5151&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}` +
`&device_id=${deviceId}&device_class=2&isInitialLogin=true&locale=en`,
true
)
expect(res.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(5151)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe(deviceId)
expect(account.deviceClass).toBe(2)
// And findable by it, which is the whole reason it is kept.
expect((await getAccountsByDeviceId(env.DB, deviceId)).map((a) => a.accountId)).toContain(5151)
})
// A refresh is NOT a login: the client redeems a stored token and re-attests nothing,
// so there is no device to believe and the grant records none. Pinned because the
// alternative is worse than it looks — trusting a device_id posted alongside a refresh
// would let a client move an account's recorded device without ever proving anything,
// which is exactly the signal ban evasion is meant to read.
test('POST /connect/token refresh_token leaves the recorded device alone', async () => {
const first = await postToken(
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}` +
'&device_id=dev-77-real&device_class=2'
)
expect(first.status).toBe(200)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` +
'&device_id=dev-77-spoofed&device_class=9'
)
expect(refreshed.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(77)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe('dev-77-real')
expect(account.deviceClass).toBe(2)
})
test('POST /connect/token refreshes the stored device on a credential login', async () => {
// Account 42 was seeded with no device; a later login records the one it came from.
const res = await postToken(
@@ -909,6 +1209,405 @@ describe('auth worker routes', () => {
expect(other.status).toBe(200)
})
test('POST /connect/token caps the accounts created from one device', async () => {
// One install, many networks: the VPN-hopping signup the IP cap can't see. Each
// signup posts a fresh IP so only the device arm can be what refuses it.
const deviceId = 'dev-capped-install'
for (let i = 0; i < 3; i++) {
const ok = await postToken(
`grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`,
`198.51.100.${40 + i}`
)
expect(ok.status).toBe(200)
}
// The 4th signup from that device is refused — the cap is 3.
const capped = await postToken(
`grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`,
'198.51.100.43'
)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/device/)
// Another device is unaffected, and a signup that posts no device id (the website's)
// can't be attributed to one, so the arm skips it rather than lumping it in.
const other = await postToken(
'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2',
'198.51.100.44'
)
expect(other.status).toBe(200)
const deviceless = await postToken(
'grant_type=create_account&platform_id=steam-devcap5',
'198.51.100.45'
)
expect(deviceless.status).toBe(200)
// The capped device can still LOG IN to what it has — the cap is on signup only.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`,
'198.51.100.46'
)
expect(login.status).toBe(200)
})
test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => {
// The switch the website's signup hangs off. Every other create_account test here
// runs with it ON (see vitest.config.ts); this one is the closed door.
const original = env.PASSWORD_SIGNUP
try {
env.PASSWORD_SIGNUP = 'off'
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const refused = await postToken(
'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2',
'198.51.100.60'
)
expect(refused.status).toBe(400)
expect(refused.json.error).toBe('invalid_grant')
expect(refused.json.error_description).toBe('password signup is disabled')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
// Logging in is not what the switch gates: an existing password account still gets
// its token, whatever the switch says.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.60'
)
expect(login.status).toBe(200)
// A platform signup is a different door: it still reaches platform verification
// (and fails THERE, on the missing ticket) rather than being refused by the switch.
const steam = await postToken(
'grant_type=create_account&platform=0&platform_id=76561198000000001',
'198.51.100.60'
)
expect(steam.status).toBe(400)
expect(steam.json.error_description).not.toBe('password signup is disabled')
// Unset is off too — the default an operator has to opt out of.
delete env.PASSWORD_SIGNUP
const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61')
expect(unset.status).toBe(400)
expect(unset.json.error_description).toBe('password signup is disabled')
// And `on` reopens it.
env.PASSWORD_SIGNUP = 'on'
const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62')
expect(open.status).toBe(200)
} finally {
env.PASSWORD_SIGNUP = original
}
})
// The rollout gate: build 20260323.14 is for Discord supporters only. A login posting it
// as `ver` is refused unless the account's Discord link holds a role named in
// DISCORD_ROLLOUT_ROLE_IDS; every other build, and a grant posting no `ver`, is untouched.
describe('rollout gate on build 20260323.14', () => {
const GATED = '20260323.14'
const REFUSED =
'this build is limited to Discord supporters; link your Discord on the website first'
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
const outsider = '1077000000000000009'
async function seedAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
const login = (username: string, ver?: string) =>
postToken(
`grant_type=password&username=${username}&password=${LOGIN_PASSWORD}` +
(ver === undefined ? '' : `&ver=${ver}`)
)
async function withRoleIds<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
// `env` is shared by every test in this file, so the override is restored in
// `finally` rather than leaking a configured gate into the tests around this one.
const original = env.DISCORD_ROLLOUT_ROLE_IDS
if (value === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
else env.DISCORD_ROLLOUT_ROLE_IDS = value
try {
return await run()
} finally {
if (original === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
else env.DISCORD_ROLLOUT_ROLE_IDS = original
}
}
beforeAll(async () => {
await seedAccount(9301, 'gate_supporter')
await linkPlatformIdentity(env.DB, 9301, PlatformType.Discord, '500000000000000001', [
supporter,
])
await seedAccount(9302, 'gate_outsider')
await linkPlatformIdentity(env.DB, 9302, PlatformType.Discord, '500000000000000002', [
outsider,
])
await seedAccount(9303, 'gate_unlinked')
// A Steam link is not a Discord link — it carries no roles to hold.
await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303')
// Staff, with no Discord link at all.
for (const [id, username, flag] of [
[9304, 'gate_developer', 'isDeveloper'],
[9305, 'gate_moderator', 'isModerator'],
] as const) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
[flag]: true,
})
)
.run()
}
})
test('staff are let in with no Discord link, even with the gate closed', async () => {
for (const roleIds of [undefined, supporter]) {
await withRoleIds(roleIds, async () => {
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_moderator', GATED)).status).toBe(200)
})
}
})
test('with no roles configured the gate is closed to every non-staff account', async () => {
await withRoleIds(undefined, async () => {
const res = await login('gate_supporter', GATED)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
})
// A value that parses to no ids is the same as unset.
await withRoleIds(' , ', async () => {
expect((await login('gate_supporter', GATED)).status).toBe(400)
})
})
test('a configured role lets its holder in, and the token carries the build', async () => {
await withRoleIds(`${booster},${supporter}`, async () => {
const res = await login('gate_supporter', GATED)
expect(res.status).toBe(200)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('9301')
expect(payload['rn.ver']).toBe(GATED)
})
})
test('the list is split on commas and/or whitespace, like DISCORD_BENEFITS_ROLE_IDS', async () => {
await withRoleIds(` ${booster} ,\n${supporter} `, async () => {
expect((await login('gate_supporter', GATED)).status).toBe(200)
})
})
test('a Discord link holding only other roles, or no Discord link at all, is refused', async () => {
await withRoleIds(`${supporter},${booster}`, async () => {
const other = await login('gate_outsider', GATED)
expect(other.status).toBe(400)
expect(other.json.error_description).toBe(REFUSED)
const unlinked = await login('gate_unlinked', GATED)
expect(unlinked.status).toBe(400)
expect(unlinked.json.error_description).toBe(REFUSED)
})
})
test('other builds, and grants posting no ver, are never gated', async () => {
await withRoleIds(undefined, async () => {
expect((await login('gate_outsider')).status).toBe(200)
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
expect((await login('gate_outsider', '20250718.01')).status).toBe(200)
expect((await login('gate_unlinked', '20230414')).status).toBe(200)
})
})
test('every point release of the gated build is gated', async () => {
// The gate is on the BUILD (the bare date): the client's `.NN` is a same-day
// rebuild, so `.14`, `.2` and the date alone are all the rollout build.
await withRoleIds(undefined, async () => {
for (const version of ['20260323', '20260323.2', '20260323.14']) {
const res = await login('gate_outsider', version)
expect(res.status, version).toBe(400)
expect(res.json.error_description, version).toBe(REFUSED)
}
})
})
test('the credential is checked first, so the gate cannot probe for accounts', async () => {
await withRoleIds(undefined, async () => {
const res = await postToken(
`grant_type=password&username=gate_outsider&password=wrong&ver=${GATED}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid account_id or password')
})
})
test('create_account on the build is refused before anything is minted', async () => {
await withRoleIds(`${supporter}`, async () => {
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const res = await postToken(
`grant_type=create_account&platform_id=steam-gated&password=hunter2&ver=${GATED}`
)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
})
})
// The two lists behind the gates are vars (`SUPPORTED_GAME_VERSIONS`,
// `ROLLOUT_GAME_VERSIONS`), so an operator can end the rollout or drop a build without
// a deploy of new code. Same env-override discipline as withRoleIds.
describe('the lists come from vars', () => {
const UNSUPPORTED = 'this game version is not supported by this server; update your game'
async function withVars<T>(
vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string },
run: () => Promise<T>
): Promise<T> {
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
const apply = (values: typeof originals) => {
for (const key of ['SUPPORTED_GAME_VERSIONS', 'ROLLOUT_GAME_VERSIONS'] as const) {
if (values[key] === undefined) delete env[key]
else env[key] = values[key]
}
}
apply({ ...originals, ...vars })
try {
return await run()
} finally {
apply(originals)
}
}
test('a build dropped from SUPPORTED_GAME_VERSIONS is refused a token, staff included', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414,20260323' }, async () => {
await withRoleIds(undefined, async () => {
// The 2025 build, every point release of it: this is the forced upgrade.
for (const version of ['20250718', '20250718.01', '20250718.2']) {
const res = await login('gate_outsider', version)
expect(res.status, version).toBe(400)
expect(res.json, version).toEqual({
error: 'invalid_grant',
error_description: UNSUPPORTED,
})
}
// Staff get no exception here — the build is not served, full stop.
expect((await login('gate_developer', '20250718.01')).status).toBe(400)
// The builds that ARE listed, and a grant posting no `ver`, are untouched;
// the rollout gate still applies to the one under rollout.
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
expect((await login('gate_outsider')).status).toBe(200)
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
})
test('create_account on an unsupported build is refused before anything is minted', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const res = await postToken(
'grant_type=create_account&platform_id=steam-unsupported&password=hunter2&ver=20250718.01'
)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: UNSUPPORTED })
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
})
})
test('ROLLOUT_GAME_VERSIONS=off ends the rollout: the build opens to everyone', async () => {
await withVars({ ROLLOUT_GAME_VERSIONS: 'off' }, async () => {
await withRoleIds(undefined, async () => {
const res = await login('gate_outsider', GATED)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string)['rn.ver']).toBe(GATED)
expect((await login('gate_unlinked', GATED)).status).toBe(200)
// ...and accounts can be created on it, since nothing needs a Discord link.
const created = await postToken(
`grant_type=create_account&platform_id=steam-rollout-over&password=hunter2&ver=${GATED}`
)
expect(created.status).toBe(200)
})
})
})
test('ROLLOUT_GAME_VERSIONS names which builds are gated', async () => {
// Move the gate onto the beta build: it is now supporters-only and the 2026
// build is open. Both stay served.
await withVars({ ROLLOUT_GAME_VERSIONS: '20250718' }, async () => {
await withRoleIds(supporter, async () => {
const outsider = await login('gate_outsider', '20250718.01')
expect(outsider.status).toBe(400)
expect(outsider.json.error_description).toBe(REFUSED)
expect((await login('gate_supporter', '20250718.01')).status).toBe(200)
expect((await login('gate_outsider', GATED)).status).toBe(200)
})
})
})
test('a build under rollout is served even when SUPPORTED_GAME_VERSIONS omits it', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
await withRoleIds(supporter, async () => {
// Rolling out, so the ROLLOUT gate answers, not the support one.
expect((await login('gate_supporter', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
// In neither list, nobody reaches it — a rollout pulled back entirely.
await withVars(
{ SUPPORTED_GAME_VERSIONS: '20230414', ROLLOUT_GAME_VERSIONS: 'off' },
async () => {
await withRoleIds(supporter, async () => {
for (const username of ['gate_supporter', 'gate_developer']) {
const res = await login(username, GATED)
expect(res.status, username).toBe(400)
expect(res.json.error_description, username).toBe(UNSUPPORTED)
}
})
}
)
})
test('entries are read as builds, so a copied point release still matches', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: ' 20230414, 20250718.01 ' }, async () => {
expect((await login('gate_outsider', '20250718.2')).status).toBe(200)
expect((await login('gate_outsider', '20250718')).status).toBe(200)
})
// Blank is unset: the default list applies, including the alpha builds.
await withVars({ SUPPORTED_GAME_VERSIONS: ' ' }, async () => {
expect((await login('gate_outsider', '20231207')).status).toBe(200)
})
})
})
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
@@ -930,6 +1629,24 @@ describe('auth worker routes', () => {
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
// The device arm reads its own var the same way.
const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID
try {
env.MAX_ACCOUNTS_PER_DEVICE_ID = 1
const device = '&device_id=dev-var-install&device_class=2'
const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`)
expect(first.status).toBe(200)
const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/device/)
env.MAX_ACCOUNTS_PER_DEVICE_ID = 0
const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
@@ -1295,6 +2012,22 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('')
})
test('GET /role/screenshare/:id matches the token feature gate for existing accounts', async () => {
for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
}
})
test('GET /role/screenshare/:id does not grant a role to unknown accounts', async () => {
for (const id of ['999999', 'nonsense']) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(404)
expect(await res.text()).toBe('')
}
})
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200)
@@ -1328,11 +2061,155 @@ describe('auth worker routes', () => {
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
})
// `/role/{role}?id=` is a BULK FILTER — which of these players hold the role, as an array
// of ints — not the single lookup's boolean. Different question, different shape.
test('GET /role/developer?id= answers which of the players are developers', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4244, username: 'DevPlayer2', isDeveloper: true }))
.run()
// INTS, not strings, and not a boolean.
const one = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242`)
expect(one.status).toBe(200)
expect(await one.json()).toEqual([4242])
// The subset that holds the role: 42 exists without the flag and 99999 not at all, and
// neither is an error — a filter answers "none of them", never 404.
const many = await exports.default.fetch(
`${ORIGIN}/role/developer?id=42&id=4242&id=99999&id=4244`
)
expect(many.status).toBe(200)
expect(await many.json()).toEqual([4242, 4244])
// Ordered as asked, so a caller can read the answer against its own list.
const reversed = await exports.default.fetch(`${ORIGIN}/role/developer?id=4244&id=4242`)
expect(await reversed.json()).toEqual([4244, 4242])
// A repeated id yields at most one entry.
const repeated = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242&id=4242`)
expect(await repeated.json()).toEqual([4242])
})
test('GET /role/developer?id= answers [] rather than erroring', async () => {
// No ids, only unknown ids, only un-flagged ids, and a non-numeric id all answer an empty
// array with a 200 — nothing about this question is a 400 or a 404.
for (const query of ['', '?id=99999', '?id=42', '?id=nonsense', '?id=']) {
const res = await exports.default.fetch(`${ORIGIN}/role/developer${query}`)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual([])
}
})
// The repeated parameter is the whole point: reading only the first would silently answer
// about one player out of the set.
test('GET /role/developer?id= reads every repeated id, not just the first', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/role/developer?id=42&id=4242`)
expect(await res.json()).toEqual([4242])
})
test('GET /role/moderator?id= filters on the moderator flag alone', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
.run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
// The two roles are independent flags: a developer is not implicitly a moderator.
const res = await exports.default.fetch(`${ORIGIN}/role/moderator?id=4242&id=4343&id=42`)
expect(await res.json()).toEqual([4343])
})
// The single lookup keeps its own shape — the bulk form must not have changed it.
test('GET /role/developer/:id is still a bare boolean, not an array', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/4242`)).json()).toBe(true)
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/42`)).json()).toBe(false)
expect((await exports.default.fetch(`${ORIGIN}/role/developer/99999`)).status).toBe(404)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('Studio device login is approved on the website and polled on auth', async () => {
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const bad = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'client_id=recroom.studio&client_secret=nope',
})
expect(bad.status).toBe(400)
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}
// The editor opens this in a browser. It is the website, not the auth API.
expect(codes.verification_uri).toBe('https://www.rec.example.com/device')
expect(codes.verification_uri_complete).toBe(
`https://www.rec.example.com/device?user_code=${codes.user_code}`
)
expect(codes.expires_in).toBeGreaterThan(0)
expect(codes.interval).toBeGreaterThan(0)
const pending = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(pending.status).toBe(400)
expect(pending.json.error).toBe('authorization_pending')
const session = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(typeof signedIn.json.access_token).toBe('string')
expect(signedIn.json.expires_in).toBe(TOKEN_TTL_SECONDS)
// Studio treats expires_in as seconds, same as the game client.
const devicePayload = decodePayload(signedIn.json.access_token as string)
expect(devicePayload.sub).toBe('42')
// Player42 has no hasStudio flag, so a Studio login is still Limited.
expect(devicePayload.role).not.toContain('betastudio')
const again = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(again.status).toBe(400)
expect(again.json.error).toBe('invalid_grant')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -1358,11 +2235,17 @@ describe('auth worker routes', () => {
'GET /eac/challenge',
'GET /oculus/nonce',
'GET /privileges/me/restrictions',
'GET /role/developer',
'GET /role/developer/{id}',
'GET /role/moderator',
'GET /role/moderator/{id}',
'GET /role/screenshare/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
'POST /connect/device/approve',
'POST /connect/device/deny',
'POST /connect/deviceauthorization',
'POST /connect/token',
])
@@ -1518,12 +2401,12 @@ describe('banned accounts', () => {
})
})
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. Unlike the banned account
// itself, such an account has no ban of its own for the block screen to describe, so
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
// the BAN_EVASION_MATCH knob.
// The ban follows the player past the account it was written on. SIGNING IN is not where
// that is enforced: a linked account gets its token like any other, and `match` holds it to
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
// account to evade with is a different act from signing into one. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
@@ -1542,38 +2425,38 @@ describe('ban evasion at the token endpoint', () => {
const login = (id: number, ip?: string) =>
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
test('an account sharing a banned account’s platform identity cannot log in', async () => {
test('an account sharing a banned account’s platform identity still signs in', async () => {
await account(6301, 'EvaderOne')
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
await banAccount(6301)
await account(6302, 'EvaderTwo')
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
// A token, like anyone else: the client has to load in before it can be told anything,
// and `match` is what keeps this account out of every room but its dorm.
const res = await login(6302)
expect(res.status).toBe(400)
// A vaguer sentence than a direct ban: this account may belong to somebody else.
expect(res.json.error_description).toBe('this device or network is blocked')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
})
test('an account sharing a banned account’s IP cannot log in', async () => {
test('an account sharing a banned account’s IP still signs in', async () => {
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
await banAccount(6303)
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
const res = await login(6304)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
expect((await login(6304)).status).toBe(200)
})
// The address the request arrives from counts, so an account that never logged in
// from the banned network before is caught on the first attempt rather than the second.
test('the request’s own IP is matched even when the account has none stored', async () => {
// The address the request arrives from counts, so a SIGNUP from the banned network is
// caught on the first attempt rather than the second.
test('the request’s own IP is matched at signup even when the account has none stored', async () => {
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
await banAccount(6305)
await account(6306, 'CleanElsewhere')
expect((await login(6306, '203.0.113.31')).status).toBe(400)
// The same account from any other network signs in normally.
expect((await postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
// Signing in from that same network is not refused — only minting a new account is.
expect((await login(6306, '203.0.113.31')).status).toBe(200)
expect((await login(6306, '198.51.100.31')).status).toBe(200)
})
@@ -1602,7 +2485,8 @@ describe('ban evasion at the token endpoint', () => {
expect(res.status).toBe(200)
})
// 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. It governs the
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
@@ -1614,18 +2498,283 @@ describe('ban evasion at the token endpoint', () => {
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await login(6321)).status).toBe(200)
expect((await login(6322)).status).toBe(400)
// And signup from that network is open again.
// Signup from the banned IP is open again, since that arm is off...
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
// ...while the platform arm still refuses one from the banned headset.
expect(
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
.status
).toBe(400)
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself signs in whatever the knob says — its ban is
// enforced at matchmake, and the knob only governs the linked arms.
expect((await login(6320)).status).toBe(200)
// Signing in is not what the knob gates: every one of these gets a token whatever it
// says — the housemate, the evader, and the banned account itself.
for (const arms of ['platform', 'off']) {
env.BAN_EVASION_MATCH = arms
for (const id of [6320, 6321, 6322]) {
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
}
}
} finally {
env.BAN_EVASION_MATCH = original
}
})
})
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
// the row that says the session it came from began, and from what device, build and IP.
describe('staff login audit', () => {
interface AuditRow {
player_id: number
action: string
data: string | null
date: string
}
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
const { results } = await env.DB.prepare(
`SELECT player_id, action, data, date FROM audit_log
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
)
.bind(accountId)
.all<AuditRow>()
return results
}
/** Seed an account with the given flags and a known password. */
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
username: `Staff${accountId}`,
passwordHash: await hashPassword(LOGIN_PASSWORD),
...data,
})
)
.run()
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
const res = await postToken(
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
'&device_id=dev-mod&device_class=2&ver=20230414',
'203.0.113.9'
)
expect(res.status).toBe(200)
const rows = await staffLogins(9601)
expect(rows).toHaveLength(1)
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
expect(data).toMatchObject({
ip: '203.0.113.9',
roles: ['moderator'],
grantType: 'password',
username: 'ModPlayer',
deviceId: 'dev-mod',
deviceClass: 2,
version: '20230414',
})
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
// the reader of a raw row should not have to hunt for it.
expect(Object.keys(data)[0]).toBe('ip')
})
// Both flags on one account record both — the row says which powers the session carries,
// not merely that it carries some.
test('an account holding both flags records both roles', async () => {
await seedAccount(9602, { isDeveloper: true, isModerator: true })
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
const rows = await staffLogins(9602)
expect(rows).toHaveLength(1)
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
'developer',
'moderator',
])
})
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
// and recording every login would bury the ones that are.
test('an ordinary login records nothing', async () => {
await seedAccount(9603, {})
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9603)).toHaveLength(0)
})
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
// where — so the login is recorded once, and the refreshes after it are not.
test('a refresh_token grant is not a login and is not recorded', async () => {
await seedAccount(9604, { isDeveloper: true })
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
expect(first.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
)
expect(refreshed.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
})
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
// granting the role starts the trail at their next sign-in rather than retroactively.
test('an account granted the role starts being recorded on its next login', async () => {
await seedAccount(9605, {})
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(0)
await env.DB.prepare(
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
)
.bind(9605)
.run()
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(1)
})
})
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
// resolves it through the link table like a cached_login, or makes the account when there
// is none. Everything here mints the assertion the way www does, with the test key.
describe('identity_assertion grant', () => {
const SIGNING_KEY = 'test-signing-key'
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
const redeem = async (assertion: string, ip?: string) =>
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
test('an assertion round-trips, and nothing else passes as one', async () => {
const assertion = await assertionFor('700000000000000001')
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
platform: PlatformType.Discord,
platformId: '700000000000000001',
})
// A different key, and an access token signed with the RIGHT key: both refused. The
// second is the one that matters — the same secret signs sessions, so the audience is
// what keeps a stolen token from being replayed as proof of a platform identity.
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
const session = await generateToken('42', '', 4, SIGNING_KEY)
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
// And the other direction: an assertion carries no `sub`, so it is no session.
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${assertion}`,
},
body: 'user_code=ABCDEFGH',
})
expect(res.status).toBe(401)
})
test('signs into the one account the identity is linked to', async () => {
await seedAccount(9400, 'DiscordLinked')
// Linked the way the benefits claim links: with roles recorded.
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
const res = await redeem(await assertionFor('700000000000009400'))
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
expect(res.json).not.toHaveProperty('created')
expect(typeof res.json.refresh_token).toBe('string')
})
test('refuses a missing, forged or replayed-session assertion', async () => {
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
const forged = await generateIdentityAssertion(
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
'not-the-key'
)
const res = await redeem(forged)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid or expired identity assertion')
// An access token for the linked account, presented as the assertion.
const session = await generateToken('9400', '', 4, SIGNING_KEY)
expect((await redeem(session)).status).toBe(400)
})
test('creates and links an account when the identity opens none, once', async () => {
const discordId = '700000000000009401'
const first = await redeem(await assertionFor(discordId))
expect(first.status).toBe(200)
expect(first.json.created).toBe(true)
const accountId = Number(decodePayload(first.json.access_token as string).sub)
expect(accountId).toBeGreaterThan(0)
// Linked on the Discord platform, and ONLY there: the blob carries no platform
// identity (the snowflake stays off the DTO and the token), like a password account.
const links = await getLinksForAccount(env.DB, accountId)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[PlatformType.Discord, discordId],
])
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>()
const blob = JSON.parse(row!.data) as Record<string, unknown>
expect(blob.platformId).toBeUndefined()
expect(blob.passwordHash).toBeUndefined()
// Placed in Orientation like any signup.
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
.bind(accountId)
.first()
expect(presence).not.toBeNull()
// The same Discord again is a sign-IN to that account, not a second account.
const again = await redeem(await assertionFor(discordId))
expect(again.status).toBe(200)
expect(again.json).not.toHaveProperty('created')
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The stretch of the feature: a Discord-made account has no password, and a password is
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
// website's password form posts with an EMPTY old password the first time.
test('a Discord-made account can set a password and then sign in with it', async () => {
const created = await redeem(await assertionFor('700000000000009402'))
const accountId = decodePayload(created.json.access_token as string).sub as string
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
const set = await changePassword(
'oldPassword=&newPassword=hunter22',
created.json.access_token as string
)
expect(set.status).toBe(200)
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
})
test('refuses an identity that opens several accounts rather than guessing', async () => {
await seedAccount(9403, 'TwoLinksA')
await seedAccount(9404, 'TwoLinksB')
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
const res = await redeem(await assertionFor('700000000000009403'))
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
})
// The signup caps reach this path as they reach create_account — www forwards the
// browser's real address over the service binding, so the per-IP cap counts browsers.
test('the per-IP signup cap applies to accounts made this way', async () => {
const ip = '198.51.100.77'
for (let i = 0; i < 3; i++) {
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
expect(res.status).toBe(200)
expect(res.json.created).toBe(true)
}
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
expect(fourth.status).toBe(400)
expect(fourth.json.error_description).toBe('too many accounts created from this network')
// Signing IN from that network is untouched: the cap is on creation.
const existing = await redeem(await assertionFor('700000000000009500'), ip)
expect(existing.status).toBe(200)
})
})
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// Password (no-platform) signup is OFF by default, and most create_account tests
// post no `platform` — they'd all test the closed door. The switch itself is
// covered by a test that flips it off and back.
PASSWORD_SIGNUP: 'on',
},
},
}),
+11 -5
View File
@@ -9,6 +9,11 @@
// history separate from the rooms worker's migrations on the same database. The
// "local" placeholder is replaced with the real id from RECFLARE_D1 (see .env) at
// deploy time.
//
// It also WRITES one table it doesn't own: a sign-in by an account holding the
// isDeveloper/isModerator flag is recorded on `audit_log` as a `staff_login`
// (apps/api/migrations/0024_audit_log.sql, owned by the `api` worker). That is the row
// saying the session every later audited action came from began, and from what IP.
"d1_databases": [
{
"binding": "DB",
@@ -43,10 +48,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
// The signup caps (MAX_ACCOUNTS_PER_PLATFORM_ID, MAX_ACCOUNTS_PER_IP) are deliberately
// NOT set here. They're injected at deploy time from the gitignored .env
@@ -55,6 +57,10 @@
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Overridden at deploy time, same as `ns`. The device-login response builds
// the browser URL from these (`https://www.<DOMAIN>/device`).
"DOMAIN": "rec.example.com",
"SUBDOMAINS": "{}"
}
}
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
+11
View File
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import type { App } from './context'
@@ -22,5 +23,15 @@ const app = new Hono<App>()
.get('/', async (c) => {
return c.text('hello, world!')
})
.get('/iam/me/channels/:channel', async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const channel = c.req.param('channel')
if (!['None', 'Announcements', 'JustInTimeTutorials'].includes(channel)) {
return c.body(null, 400)
}
// GetChannelMessages decodes a bare List<InAppMessageDTO>. No campaigns are configured.
return c.json([])
})
export default app
+38 -2
View File
@@ -1,8 +1,44 @@
import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest'
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { sign } from 'hono/jwt'
import { beforeAll, expect, it } from 'vitest'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('cards-test-secret')
})
async function bearer() {
const token = await sign(
{ sub: '205', exp: Math.floor(Date.now() / 1000) + 60 },
'cards-test-secret',
'HS256'
)
return { Authorization: `Bearer ${token}` }
}
it('response with hello world', async () => {
const res = await SELF.fetch('https://example.com')
expect(res.status).toBe(200)
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
})
it('in-app message channels return a bare list and require authentication', async () => {
const url = 'https://example.com/iam/me/channels/Announcements'
expect((await SELF.fetch(url)).status).toBe(401)
for (const channel of ['Announcements', 'JustInTimeTutorials', 'None']) {
const response = await SELF.fetch(`https://example.com/iam/me/channels/${channel}`, {
headers: await bearer(),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
}
expect(
(await SELF.fetch('https://example.com/iam/me/channels/Unknown', { headers: await bearer() }))
.status
).toBe(400)
})
+1 -4
View File
@@ -16,10 +16,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+10 -3
View File
@@ -15,13 +15,20 @@ route of its own, bundled rather than an asset, because its file is named differ
its path.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
served as `application/octet-stream`; the worker never interprets what it hands back.
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
studio worker writes the rows), and the route serves only a filename that table records.
`HEAD` on that path is 200 when a bundle is stored under the name.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
images are served by `img`.
images are served by `img`. Studio room bundles are uploaded by the `studio` worker into
the same bucket. The worker binds the shared D1 database to resolve those filenames and
does not migrate it.
## API documentation
+186 -4
View File
@@ -3,8 +3,10 @@ import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
applyNoExecuteHeaders,
withCleanSpec,
withDefaultCors,
withNoExecute,
withNotFound,
withOnError,
writeContentRange,
@@ -167,6 +169,13 @@ const app = new Hono<App>()
// unchanged by who may read a response they already had to name exactly.
.use('*', withDefaultCors())
// The blob routes force `application/octet-stream` over whatever type an upload was
// stored with, which is what keeps a stored HTML body from rendering; `nosniff` makes
// that type binding on the browser, and the sandboxing CSP covers the config files,
// which go out under their own types. The hand-rolled HEAD below is outside this chain
// and stamps the same headers itself.
.use('*', withNoExecute())
.onError(withOnError())
.notFound(withNotFound())
@@ -329,6 +338,155 @@ const app = new Hono<App>()
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// Custom avatar item assetbundles by name. A first-party custom avatar item (see the
// `api` worker's `custom_avatar_item`) is rendered from its `CurrentSaves`, each of
// which names a built Unity assetbundle by bare filename (`UnityAsset`, e.g.
// `anx442dm1a79kp9n4kugkbgd0.assetbundle`) that the client fetches from `/avatar/`.
// Streamed from R2 under `avatar/`. The rest of the path is matched so a foldered
// upload would resolve the same way as the other blob prefixes.
.get(
'/avatar/:asset{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a custom avatar item assetbundle',
description: [
'Streams the object stored under `avatar/<asset>` — the built Unity assetbundle the',
'client downloads to render a first-party custom avatar item on one body type. The',
'name comes from a save’s `UnityAsset` (or `UnityAsset2`) in the item’s `CurrentSaves`',
'(see the `api` worker), a bare filename such as `anx442dm1a79kp9n4kugkbgd0.assetbundle`.',
'The worker does not interpret the bytes.',
].join(' '),
parameters: [keyParam('asset', 'The assetbundle filename.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The assetbundle'),
}),
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// SDK source downloads use an opaque upload capability (no bearer on this
// request). Only creator-authenticated save history exposes these filenames.
.get('/avatarunityasset/:filename{.+}', (c) => {
const name = c.req.param('filename')
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
return serveAsset(c, `avatarunityasset/${name}`)
})
// Editable Unity source manifests. Baked bundles are served separately.
.get(
'/roomunityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Download a Studio authoring manifest',
parameters: [keyParam('filename', 'Uploaded JSON source manifest', true)],
responses: assetResponses('The source manifest'),
}),
async (c) => {
const name = c.req.param('filename')
if (name.includes('..')) return c.body(null, 400)
return serveAsset(c, `roomunityasset/${name}`)
}
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
// checks the table before streaming the key. Reads stay unauthenticated, same as
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
// exists, 404 does not.
.on(
'HEAD',
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists',
description: [
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
responses: {
200: { description: 'The bundle exists (no body)' },
400: { description: 'The filename contains `..` (no body)' },
404: { description: 'No single stored bundle has this filename' },
},
}),
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
)
.get(
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a Rec Room Studio room bundle',
description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'a missing table. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The asset bundle'),
}),
(c) => serveUnityAsset(c)
)
/**
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
try {
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await env.DB.prepare(
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename)
.first<{ stored: number }>()
if (!row) return null
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
// Room builds and consumable exports use the game's /room namespace.
// Retain older installations that stored the same filename at the bucket root.
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
return `room/${filename}`
return filename
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
throw err
}
}
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env, name)
return serveAsset(c, key ?? `unityasset/${name}`)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env, name)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 })
const headers = new Headers()
head.writeHttpMetadata(headers)
headers.set('etag', head.httpEtag)
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', CACHE_CONTROL)
headers.set('access-control-allow-origin', '*')
applyNoExecuteHeaders(headers)
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
response.headers.set('content-length', String(head.size))
return response
}
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -343,14 +501,18 @@ app.get(
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'signatures, saved room scenes, invention data, generic client uploads and custom',
'avatar item assetbundles — out of',
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
'from `/config/`.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`, `avatar/`) and',
'served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'`HEAD` on that path is 200 when a bundle is stored under the name.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
@@ -370,4 +532,24 @@ app.get(
)
)
export default app
/**
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
* binding, and the wrapper below must stay mountable there.
*/
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
export default {
fetch(
request: Request,
env: StudioAssetEnv,
executionCtx: ExecutionContext
): Response | Promise<Response> {
const url = new URL(request.url)
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
return studioUnityAssetHead(env, filename)
}
return app.fetch(request, env, executionCtx)
},
}
+5
View File
@@ -6,6 +6,11 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` database, read-only here. Studio bundle downloads check that
// `unity_asset` records the requested filename (which is the R2 key) before
// streaming it. The rooms worker owns that table's migration; this binding has no
// migrations_dir.
DB: D1Database
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`.
CDN_ASSETS: R2Bucket
+161
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
'{"Version":3}'
)
await env.DB.prepare(
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
).run()
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
await env.DB.prepare(
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
)
.bind('avatar-fixture', 'material', name, 'hash')
.run()
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
expect(
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
).toBe(200)
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
headers: { Range: 'bytes=0-7' },
})
expect(response.status).toBe(206)
expect(await response.text()).toBe('UnityFS\0')
})
describe('cdn endpoints', () => {
test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -19,6 +45,38 @@ describe('cdn endpoints', () => {
expect(await res.json()).toEqual({ service: 'cdn', status: 'ok' })
})
// A stored blob carries whatever type it was uploaded with; the routes force
// octet-stream, and these headers make that binding and sandbox anything that is served
// under its own type (the configs). The hand-rolled HEAD sits outside Hono and must
// carry them too.
test('every response carries headers that stop a stored document executing', async () => {
await env.CDN_ASSETS.put('data/a-stored-page', '<script>alert(1)</script>', {
httpMetadata: { contentType: 'text/html' },
})
for (const path of [
'/',
'/config/LoadingScreenTipData',
'/config/RRPlusConfig_v3',
'/data/a-stored-page',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(res.headers.get('x-content-type-options'), path).toBe('nosniff')
expect(res.headers.get('content-security-policy'), path).toBe("default-src 'none'; sandbox")
}
const blob = await exports.default.fetch(`${ORIGIN}/data/a-stored-page`)
expect(blob.headers.get('content-type')).toBe('application/octet-stream')
await env.CDN_ASSETS.put('unityasset/plain.assetbundle', new Uint8Array([1, 2, 3]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/plain.assetbundle`, {
method: 'HEAD',
})
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('3')
expect(head.headers.get('x-content-type-options')).toBe('nosniff')
expect(head.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
})
test('GET /config/LoadingScreenTipData returns the tip array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
expect(res.status).toBe(200)
@@ -271,6 +329,81 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /avatar/:asset streams the custom avatar item assetbundle from R2', async () => {
// A bare filename — what a first-party item's `CurrentSaves[].UnityAsset` names.
const name = 'anx442dm1a79kp9n4kugkbgd0.assetbundle'
await env.CDN_ASSETS.put(`avatar/${name}`, new Uint8Array([7, 8, 9]))
const res = await exports.default.fetch(`${ORIGIN}/avatar/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([7, 8, 9]))
})
test('GET /avatar/:asset 404s when the assetbundle is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/avatar/missing.assetbundle`)
expect(res.status).toBe(404)
})
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, kind)
)`
).run()
// The filename IS the R2 key.
const filename = 'studio/20930/abc111.windows.main.assetbundle'
await env.DB.prepare(
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
)
.bind(filename)
.run()
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
// A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, { method: 'HEAD' })
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
// Current room/consumable builds live in the game's room namespace.
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
headers: { Range: 'bytes=1-2' },
})
expect(current.status).toBe(206)
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
method: 'HEAD',
})
expect(currentHead.status).toBe(200)
expect(currentHead.headers.get('content-length')).toBe('5')
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
.status
).toBe(404)
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -293,12 +426,16 @@ describe('cdn endpoints', () => {
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /avatar/{asset}',
'GET /config/LoadingScreenTipData',
'GET /config/{name}',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /roomunityasset/{filename}',
'GET /sigs/{sigName}',
'GET /unityasset/{filename}',
'HEAD /unityasset/{filename}',
])
// Every operation carries a summary — a path present but undescribed is not
@@ -311,3 +448,27 @@ describe('cdn endpoints', () => {
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
test('Studio authoring source manifests and source files remain distinct from baked bundles', async () => {
await env.CDN_ASSETS.put(
'roomunityasset/studio-source-fixture.json',
'{"Version":3,"Files":[]}',
{ httpMetadata: { contentType: 'application/json' } }
)
const source = await exports.default.fetch(`${ORIGIN}/roomunityasset/studio-source-fixture.json`)
expect(source.status).toBe(200)
expect(await source.json()).toEqual({ Version: 3, Files: [] })
await env.CDN_ASSETS.put('unityasset/studio-source-fixture.txt', 'source data')
const file = await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`)
expect(await file.text()).toBe('source data')
expect(
(
await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`, {
method: 'HEAD',
})
).status
).toBe(200)
expect((await exports.default.fetch(`${ORIGIN}/roomunityasset/missing-source.json`)).status).toBe(
404
)
})
+12 -4
View File
@@ -32,6 +32,17 @@
"directory": "./static",
"run_worker_first": true
},
// Shared `recflare` DB, read so `/unityasset/:filename` can resolve a Studio
// bundle's public filename to the R2 key the studio worker stored. Studio owns
// the migration (`apps/studio/migrations`); this binding deliberately has no
// migrations_dir, so deploying cdn does not try to create those tables.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local"
}
],
// CDN binaries (signature blobs + room build data) are stored as R2 objects
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
"r2_buckets": [
@@ -52,10 +63,7 @@
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
"enabled": false
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment

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