166 Commits

Author SHA1 Message Date
Devin Zuczek 8e0f090d18 mono updates 2026-08-15 16:51:50 -04:00
devin 66c09806f9 [www] beta room uploads (#34)
* WIP room uploads

* add beta warning
2026-08-15 13:14:40 -04:00
Devin Zuczek d12806625d [econ] add giftContext to the key so people get rewards for different activities per day 2026-08-13 12:54:01 -04:00
Devin Zuczek d129900762 [events] event tagging and reporting 2026-08-12 17:42:22 -04:00
Devin Zuczek 3aad586153 [events] add bulkInvite 2026-08-12 17:17:10 -04:00
Devin Zuczek 385e10bd55 [match] matchmake into event room 2026-08-12 17:16:52 -04:00
Devin Zuczek 8e0e92b449 [accounts] clean up email in case null causes issues 2026-08-12 16:30:16 -04:00
Devin Zuczek 8364a0b5f6 [event] fixup even listing, eliminate some misc log errors 2026-08-12 15:57:09 -04:00
Devin Zuczek d3838fb590 [match] just a little cleanup 2026-08-12 14:24:59 -04:00
Devin Zuczek 36c30a396c [match] implement junior match pref 2026-08-12 14:16:21 -04:00
Devin Zuczek da27b7e797 [invention] fix inventions of inventions 2026-08-12 12:07:46 -04:00
Devin Zuczek 42e1fb4ab7 [www] update meta link 2026-08-12 10:08:44 -04:00
Devin Zuczek ca8d40c4ec [techdebt] cleanup duplicate schema definitions 2026-08-11 15:50:55 -04:00
Devin Zuczek 1afa9b7ac3 [rooms] handle pseudo community tag 2026-08-11 15:48:46 -04:00
Devin Zuczek c5ea04b39d [econ] rrplus for developer, for now 2026-08-11 15:47:35 -04:00
Devin Zuczek c2adc1ffbb [econ] finally fix client balances after purchase 2026-08-11 13:10:31 -04:00
Devin Zuczek 4fb1c901b4 [auth] grant screenshare, junior 2026-08-11 13:06:10 -04:00
Devin Zuczek efbd7936db [econ] another attempt to fix client balance 2026-08-11 12:26:11 -04:00
Devin Zuczek 7df783302b [notify] add documentation on push shapes 2026-08-11 11:36:51 -04:00
Devin Zuczek 21024c7852 [commerce] stub out commerce, for historical purposes 2026-08-11 10:51:53 -04:00
Devin Zuczek a012b5165a [econ] maybe fix up balance mismatch on purchases 2026-08-11 10:51:00 -04:00
Devin Zuczek 3e8f0b3e56 oops, move skills 2026-08-11 10:11:51 -04:00
Devin Zuczek 6622302a56 [econ] add documentation about daily/weekly challenges 2026-08-11 10:11:03 -04:00
Devin Zuczek 566b212675 skill to interpret daily objectives 2026-08-11 10:10:55 -04:00
Devin Zuczek 4b17b007e9 [econ] tweak progression slope 2026-08-11 01:18:12 -04:00
Devin Zuczek 9164df98a9 [econ] fix box roll 2026-08-11 00:38:53 -04:00
Devin Zuczek 7c43f2a1f3 [econ] levels 2026-08-11 00:34:03 -04:00
Devin Zuczek af64327fea [api][econ] add levels, xp storage and basic game rewards 2026-08-10 23:31:40 -04:00
Devin Zuczek 4109317f0e [econ] update challenge to only require 3 2026-08-10 23:06:18 -04:00
Devin Zuczek 208c1fe772 [econ] mostly working challenges 2026-08-10 22:55:13 -04:00
Devin Zuczek 0b33e0b46f [econ] reward scaffolding 2026-08-10 19:02:40 -04:00
Devin Zuczek 927c6757bb remove old config 2026-08-10 18:42:10 -04:00
Devin Zuczek aa6fdaf4b2 [img] maybe fix choppy images 2026-08-10 17:58:11 -04:00
Devin Zuczek 3022b3b566 document challenges format 2026-08-10 14:58:42 -04:00
Devin Zuczek e564d3c839 [www] subroom data 2026-08-10 14:10:20 -04:00
Devin Zuczek 30cf83a47d [img] missing r2 libs 2026-08-10 14:10:02 -04:00
Devin Zuczek 793b2ad37a [cdn] fix some range issues where delivered images might be corrupt 2026-08-10 13:56:32 -04:00
Devin Zuczek 6c7a634cb6 [www] subroom listing 2026-08-10 11:14:24 -04:00
Devin Zuczek 5c5988c730 ban hammer evasion 2026-08-10 10:48:22 -04:00
Devin Zuczek 6bbdf989b9 ban hammer 2026-08-10 10:29:24 -04:00
Devin Zuczek 7fbaad1fd8 [www] add basic room list 2026-08-10 10:26:39 -04:00
Devin Zuczek d461961e54 implement /api/messages/v1/sendMultiple 2026-08-09 23:49:01 -04:00
Devin Zuczek c6ec993e2d add api/objectives/v1/updateobjective endpoint even though it does not work right now 2026-08-09 22:51:13 -04:00
Devin Zuczek 3d846ef2ad fix an event test 2026-08-09 22:50:41 -04:00
Devin Zuczek 7e62f2b53c fix jwt unit test 2026-08-09 22:50:11 -04:00
Devin Zuczek a30d70076e fix flake test 2026-08-09 22:49:53 -04:00
Devin Zuczek 37489d05dc ability to swap out a room 2026-08-09 22:25:27 -04:00
Devin Zuczek 60505a2519 add matchmake error enums from dump 2026-08-09 01:08:23 -04:00
Devin Zuczek a3d9fdb8bf eh not the prettiest, but visits is just an increment and we might have others later. room_columns is not user-provided 2026-08-08 14:31:06 -04:00
Devin Zuczek df2d2af75b remove empty instances 2026-08-07 17:24:08 -04:00
Devin Zuczek c2d36009a3 oops, just return a fake signature 2026-08-07 15:17:53 -04:00
Devin Zuczek 4e578f7771 turn off image signing to save CPU 2026-08-07 15:10:09 -04:00
Devin Zuczek 7bbbac6dc9 increase token expiration workaround 2026-08-07 14:46:15 -04:00
Devin Zuczek 1e45afbcee repair account/me/username endpoint 2026-08-07 14:30:45 -04:00
Devin Zuczek 9bb43f7b9c remove unpublished rooms from catalogs 2026-08-07 13:38:24 -04:00
Devin Zuczek 8bd76a4bae allow co-owners to edit tags 2026-08-07 12:35:50 -04:00
Devin Zuczek 3dd6d6420b open up room save endpoints to users with presence 2026-08-07 10:48:52 -04:00
Devin Zuczek 368ca252c1 add missing room save endpoint 2026-08-06 17:44:42 -04:00
Devin Zuczek 51364e482c fix subroom save description clobbering room description 2026-08-06 15:35:35 -04:00
Devin Zuczek 565d9b1aea Revert "fix issue with subroom save list not working in room with no current save"
This reverts commit aeff7d50cd.
2026-08-06 15:15:20 -04:00
Devin Zuczek aeff7d50cd fix issue with subroom save list not working in room with no current save 2026-08-06 14:47:52 -04:00
Devin Zuczek 880c6ab2dc allow underscores in room name 2026-08-06 12:40:38 -04:00
Devin Zuczek f185ef97df add /server/status endpoint 2026-08-05 18:42:19 -04:00
Devin Zuczek cc43d57172 Reapply "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 6b7acc9435.
2026-08-05 17:58:11 -04:00
Devin Zuczek 8b804eaa33 add CDN /data/ endpoint 2026-08-05 17:54:07 -04:00
Devin Zuczek 6b7acc9435 Revert "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 93a46871de.
2026-08-05 17:53:42 -04:00
Devin Zuczek 93a46871de the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems) 2026-08-05 17:27:27 -04:00
Devin Zuczek 4111bc49aa limit slideshow to 10 by default 2026-08-05 17:01:45 -04:00
Devin Zuczek f6561f1ec9 adjust "top" window 2026-08-05 15:49:59 -04:00
Devin Zuczek bc96a6245b hot inventions 2026-08-05 15:45:48 -04:00
Devin Zuczek a73dec7c13 extract published field for convenience 2026-08-05 15:39:25 -04:00
Devin Zuczek ae3bef4cc4 real featured inventions 2026-08-05 15:30:50 -04:00
Devin Zuczek 1f615bab4f fix incorrect balance push notification 2026-08-05 15:27:13 -04:00
Devin Zuczek a986d012f5 invention purchase, at least, how I think they should work 2026-08-05 14:58:55 -04:00
Devin Zuczek b82a5e1dc0 add some basic validation 2026-08-05 14:00:31 -04:00
Devin Zuczek 6bfd4d9e50 validation in some areas, maybe move this to schema later 2026-08-05 12:08:04 -04:00
Devin Zuczek 079c889ccb remove proxies and use native services w/cors 2026-08-05 11:13:15 -04:00
Devin Zuczek 9f4ce07aca track owned inventions 2026-08-04 22:33:47 -04:00
Devin Zuczek dfb1e9ab21 put back stubbed meta auth for sideloaded apks 2026-08-04 21:13:33 -04:00
Devin Zuczek 1d08ed8296 fix too many accounts error because the worker was behind a Cloudflare call, add username change 2026-08-04 21:04:00 -04:00
Devin Zuczek f3e2ab422c add friendly message 2026-08-04 20:10:16 -04:00
Devin Zuczek aa304dbede (wip) attendees 2026-08-04 18:54:40 -04:00
Devin Zuczek 10eb89ac12 (wip) events 2026-08-04 18:44:11 -04:00
Devin Zuczek 65611c15d8 fix custom loading screens, add private endpoint for match into instance 2026-08-04 17:50:21 -04:00
Devin Zuczek d6a0e3e6a6 add a few admin endpoints for matching into instances 2026-08-04 17:49:53 -04:00
Devin Zuczek 7d300fa836 update shape of room instance browser 2026-08-04 17:19:03 -04:00
Devin Zuczek 73bb7c4609 implement room ban 2026-08-04 15:41:49 -04:00
Devin Zuczek dbc6d15ef5 fix issue where polaroids come in with a different URL, pull from other bucket 2026-08-04 14:08:39 -04:00
Devin Zuczek db003d54ef add a non working banlist 2026-08-04 13:26:12 -04:00
Devin Zuczek 05b56e698e add room invites 2026-08-04 12:45:11 -04:00
Devin Zuczek dee7497fe5 add mutual friends endpoint 2026-08-04 12:26:44 -04:00
Devin Zuczek 12f6d7ab61 add warning table (maybe this is just a notification, later) 2026-08-04 12:06:09 -04:00
Devin Zuczek 8d1539de03 add player reports 2026-08-04 11:42:53 -04:00
Devin Zuczek 7e0c26a100 update quest invite 2026-08-04 09:47:16 -04:00
Devin Zuczek f94877347c free invention purchase 2026-08-04 02:01:51 -04:00
Devin Zuczek 23bc159c28 apps
fix birthday which causes various issues
2026-08-04 01:19:51 -04:00
devin 70df3cb6cb Add build status badge to README
Added a badge for the main branch build status.
2026-08-04 00:01:47 -04:00
Devin Zuczek 03b1c59f0d fix 2 test failures due to chat type notification 2026-08-03 22:56:04 -04:00
Devin Zuczek d298977790 drop old platform ID column 2026-08-03 22:48:57 -04:00
devin 821bf54b9b Update deployment requirements and instructions
Emphasize the importance of prerequisites for RecFlare deployment and clarify usage of `just` for installation.
2026-08-03 21:56:42 -04:00
Devin Zuczek 108b061019 increase cardinality of platform accounts 2026-08-03 20:32:23 -04:00
Devin Zuczek af2a2a0683 fix a couple room routes 2026-08-03 19:43:38 -04:00
devin 339a91735b Add account signup and turnstile, subroom perms (#24)
* turnstile

* require turnstile

* homepage refresh

* implemented rooms visited endpoint for friends

* update default profile pic

* add a meta download button

* add subroom permissions

* enable signup
2026-08-03 15:20:10 -04:00
Devin Zuczek a46f6db9d7 meta platform login 2026-08-02 01:57:01 -04:00
Devin Zuczek b3f1d04823 add invention blob hash which does not seem to help 2026-08-01 12:15:56 -04:00
Devin Zuczek 55cb769de9 add github workflow to test 2026-07-31 14:03:35 -04:00
Devin Zuczek 4c6b9679e4 fix room save shape 2026-07-28 23:49:32 -04:00
Devin Zuczek 6a910b27bd update auth for subroom paths 2026-07-28 23:28:25 -04:00
Devin Zuczek 9c8bf7087c fixup autopublish 2026-07-28 23:13:35 -04:00
Devin Zuczek d5ccad51d3 fixed room and subroom saving, add room saves 2026-07-28 20:29:20 -04:00
Devin Zuczek a28b9b4561 add subroom accessibility endpoint 2026-07-28 17:22:15 -04:00
Devin Zuczek 8c773da137 www stuff 2026-07-27 18:46:37 -04:00
Devin Zuczek d5e3d3946e blur bg image 2026-07-24 23:12:15 -04:00
Devin Zuczek beed8451d4 cute 2026-07-24 23:07:53 -04:00
Devin Zuczek 273d62ed80 new www 2026-07-24 23:01:03 -04:00
Devin Zuczek a5136d2bfa cdn openapi 2026-07-24 21:48:10 -04:00
Devin Zuczek 460839458c rooms openapi 2026-07-24 21:32:43 -04:00
Devin Zuczek 27c45792b8 beta subrooms (saving not working) 2026-07-24 21:00:42 -04:00
Devin Zuczek 568717bb53 migrate subrooms to own storage 2026-07-24 16:08:30 -04:00
Devin Zuczek 5ed9e765a5 clean up match code, remove old endpoints 2026-07-24 14:19:08 -04:00
Devin Zuczek cfc29cb175 update presence on drop 2026-07-23 22:19:25 -04:00
Devin Zuczek 82f011c0b0 fix invites 2026-07-23 18:09:16 -04:00
Devin Zuczek f39db8a15e fix chat and notifications 2026-07-23 18:09:03 -04:00
Devin Zuczek 95386c5de3 add club invite 2026-07-23 15:50:56 -04:00
Devin Zuczek a400de4a8b fix issue where avatar store buying broke 2026-07-23 14:35:21 -04:00
Devin Zuczek bceb8f6a4f remove items that do not exist 2026-07-23 14:34:22 -04:00
Devin Zuczek 725540a95b add playerevents endpoint, whatever that is 2026-07-23 14:28:32 -04:00
Devin Zuczek 2f6a40279d add version check for 20230414 client 2026-07-23 12:23:30 -04:00
Devin Zuczek 7ab171b646 v4 avatars 2026-07-23 02:19:17 -04:00
Devin Zuczek 23b78104e8 updating api docs 2026-07-22 11:43:30 -04:00
Devin Zuczek 68b98665b2 update api docs 2026-07-22 10:12:50 -04:00
Devin Zuczek 881663a4fe placeholder for community board 2026-07-22 10:12:50 -04:00
Devin Zuczek 7ca7aa0666 tweaks to chat, but not working right 2026-07-22 10:12:50 -04:00
devin 539e6150b0 Add DorkNet and update postman collection reference
Updated README to include additional project links.
2026-07-22 00:45:05 -04:00
Devin Zuczek df618af435 docs for api 2026-07-21 21:42:14 -04:00
Devin Zuczek ec324558a0 apps/notify some notify fixes 2026-07-21 19:32:38 -04:00
Devin Zuczek 40c38d7a18 add max rooms and clubs per account 2026-07-21 16:49:57 -04:00
Devin Zuczek c853cc1c6f clean up memory 2026-07-21 16:31:59 -04:00
Devin Zuczek 5df06ea168 fix club images 2026-07-21 16:28:53 -04:00
Devin Zuczek c80a25bd24 add club delete (club images not quite working) 2026-07-21 16:11:31 -04:00
Devin Zuczek 2a043d0042 Revert "store images as int"
This reverts commit 56f7695c71.
2026-07-21 15:57:57 -04:00
Devin Zuczek 56f7695c71 store images as int 2026-07-21 15:52:28 -04:00
Devin Zuczek 430825d72e club photos 2026-07-21 13:27:02 -04:00
Devin Zuczek 7f497fcee0 fix #19 clubhouses 2026-07-21 12:59:47 -04:00
Devin Zuczek d56fe3a276 oops update platform here too 2026-07-21 12:15:07 -04:00
Devin Zuczek dd3bcf6868 clean up auth and platform types 2026-07-21 12:14:10 -04:00
Devin Zuczek 9cb1135a12 add overview to api docs 2026-07-21 11:10:37 -04:00
Devin Zuczek cfde2cebf4 add equipment update 2026-07-21 01:52:37 -04:00
Devin Zuczek 086441f6f5 misc cleanup 2026-07-21 01:20:20 -04:00
Devin Zuczek 41fa8b9979 equipment 2026-07-21 01:20:03 -04:00
Devin Zuczek 0f301e5788 relationship notifications (maybe) 2026-07-21 01:19:37 -04:00
Devin Zuczek 08cf44991d chat, little buggy 2026-07-21 01:17:20 -04:00
Devin Zuczek 64bfc9f851 fake route for oculus 2026-07-20 21:38:40 -04:00
Devin Zuczek 21f7eff384 remove unused endpoint - think it was for 2018, this moved to playersettings 2026-07-20 19:21:41 -04:00
Devin Zuczek 019e7ade01 fixup docs 2026-07-20 19:08:41 -04:00
Devin Zuczek 7429ba4536 experimental api docs 2026-07-20 18:41:00 -04:00
Devin Zuczek 30bb6a131c tweaks to monoservice to return ns doc 2026-07-20 18:23:31 -04:00
Devin Zuczek 9ab4199bd5 tweak for returning ns 2026-07-20 18:15:20 -04:00
Devin Zuczek aebd4ca630 use path prefixes to make it easy 2026-07-20 18:10:39 -04:00
Devin Zuczek 86edf0ba66 update lockfile 2026-07-20 17:59:48 -04:00
Devin Zuczek 9eb9476270 econ docs 2026-07-20 17:59:37 -04:00
Devin Zuczek a4ddef2cf7 match docs, tweak for monowrapper 2026-07-20 17:59:00 -04:00
Devin Zuczek 52cd808e0a experimental monolithic service 2026-07-20 17:57:08 -04:00
Devin Zuczek 4cef0bdf5b accounts documentation 2026-07-20 16:20:54 -04:00
Devin Zuczek 355b459dc9 beta openapi docs 2026-07-20 15:33:08 -04:00
Devin Zuczek 3319a5d91a room delete endpoint :( 2026-07-20 11:37:59 -04:00
212 changed files with 59796 additions and 5021 deletions
@@ -0,0 +1,249 @@
---
name: daily-objectives
description: Guide for parsing/writing daily objectives in /api/config/v2
---
# Daily objectives — config shape and the full type enum
Reference for authoring `dailyObjectives` in `GET /api/config/v2`. Extracted from the 20230414
client (`GameAssembly.dll` mtime 2026-07-23). See `SHAPES.md` for the method used.
> **This table survives game upgrades.** Enum *member names and values* are not obfuscated — only
> type and method names re-roll per build. So the ids below stay valid across client versions unless
> Rec Room adds or removes members. The obfuscated names in this file (`PNLFAAAPEID`,
> `LCPOOJEAMJA`, …) are the only part that will go stale.
## Where it lives
`GET api/config/v2` (service `API`) → `JAGPNOHGHBG.DownloadConfigSettings`, deserialized as a bare
`LCPOOJEAMJA` via `SendWithRequiredResponseAsync` — response required, no envelope.
Top-level keys, in declaration order (all accept three casings):
| Wire name | Type |
| --- | --- |
| `levelProgressionMaps` | array of objects |
| **`dailyObjectives`** | **jagged array**`FCAOHDFPEAP[][]` |
| `serverMaintenance` | object |
| `autoMicMutingConfig` | object |econ
| `storefrontConfig` | object |
| `roomKeyConfig` | object |
| `roomCurrencyConfig` | object |
| `shareBaseUrl` | string |
A `Dictionary<int,int>` declared first on the type carries `[IgnoreDataMember]` — client-only, never
on the wire.
## `dailyObjectives` shape
Array of arrays. Each leaf element (`FCAOHDFPEAP`, formatter `BMBOPLBKALL`) has exactly two members:
| Wire name | Type |
| --- | --- |
| `type` | int — a value from the table below |
| `score` | int — the target / threshold |
```json
{
"dailyObjectives": [
[ { "type": 1, "score": 1 },
{ "type": 6, "score": 5 },
{ "type": 31, "score": 3 } ],
[ { "type": 2, "score": 1 },
{ "type": 65, "score": 2 },
{ "type": 300, "score": 1 } ]
]
}
```
**Unverified:** what the outer dimension indexes. The `updateobjective` DTO carries both `index` and
`group`, which lines up with `dailyObjectives[group][index]`, and `DailyObjective1/2/3` existing as
distinct types suggests three slots per set — but neither is confirmed against the consumer. Serve a
distinctive jagged array and watch the `group`/`index` pairs your endpoint receives.
## Numbering scheme
The ids are blocked, which tells you where new entries belong:
| Range | Meaning | Count |
| --- | --- | --- |
| `-1` `15` | meta / rollup / social | 16 |
| `20` `26` | onboarding (OOBE, NUX) | 5 |
| `30` `75` | general engagement | 45 |
| `100`+ | per-activity, one block each | 67 |
Activity blocks follow a `Games` / `Wins` / `<activity-specific>` pattern. Quest (`1000`) additionally
sub-blocks by scenario in steps of 10.
**Careful with `10``15`.** `DailyObjective1/2/3`, `AllDailyObjectives`, `CompleteAnyDaily` and
`CompleteAnyWeekly` read as *rollup* types the reward system uses to track "you finished daily #1",
not as objective definitions themselves. Using them as leaf `type` values in `dailyObjectives` is
probably not what you want. (Inference from naming — not traced.)
## Full enum — `PNLFAAAPEID`, 133 values
| id | name |
| --- | --- |
| -1 | Default |
| 1 | FirstSessionOfDay |
| 2 | AddAFriend |
| 3 | PartyUp |
| 4 | AllOtherChallenges |
| 5 | LevelUp |
| 6 | CheerAPlayer |
| 7 | PointedAtPlayer |
| 8 | CheerARoom |
| 9 | SubscribeToPlayer |
| 10 | DailyObjective1 |
| 11 | DailyObjective2 |
| 12 | DailyObjective3 |
| 13 | AllDailyObjectives |
| 14 | CompleteAnyDaily |
| 15 | CompleteAnyWeekly |
| 20 | OOBE_GoToLockerRoom |
| 21 | OOBE_GoToActivity |
| 22 | OOBE_FinishActivity |
| 25 | NUX_PunchcardObjective |
| 26 | NUX_AllPunchcardObjectives |
| 30 | GoToRecCenter |
| 31 | FinishActivity |
| 32 | VisitACustomRoom |
| 33 | CreateACustomRoom |
| 35 | ScoreBasketInRecCenter |
| 36 | UploadPhotoToRecNet |
| 37 | UpdatePlayerBio |
| 38 | SaveOutfitSlot |
| 39 | PurchaseClothingItem |
| 40 | PurchaseNonClothingItem |
| 41 | DrinkWater |
| 42 | ColorOnWhiteboard |
| 43 | SetBasketballSkin |
| 44 | ThrowBasketball |
| 45 | PlaceInventionInDorm |
| 46 | ChangeDormRoomSkin |
| 47 | ToggleOwnedClothes |
| 48 | EquipHat |
| 49 | LoadOutfit |
| 50 | SaveNewOutfitSlot |
| 51 | SpawnCamera |
| 52 | TakeSelfie |
| 53 | PrintSelfie |
| 54 | TakePictureOfPlayer |
| 55 | PrintPictureOfPlayer |
| 56 | PublishSelfieWithPlayer |
| 57 | SpawnFoodWithOtherPlayers |
| 58 | EmoteInRecCenter |
| 59 | SendRoomChatInRecCenter |
| 60 | UseFrendotron |
| 61 | GoToDormRoom |
| 62 | VisitSpecificRoom |
| 63 | VisitPublicRRO |
| 64 | VisitPublicRoomBySource |
| 65 | FavoriteARoom |
| 66 | TakePhotoWithFilter |
| 67 | OpenYourPlayerProfile |
| 68 | OpenOnlineStatusModal |
| 69 | ChangeProfilePicture |
| 70 | ChangePlayerDisplayName |
| 71 | ChangePlayerDescriptionText |
| 72 | OpenPlayerPronounsModal |
| 73 | OpenOtherPlayersProfile |
| 74 | VisitPlayersPortfolio |
| 75 | FavoriteAFriend |
| 100 | CharadesGames |
| 101 | CharadesWinsPerformer |
| 102 | CharadesWinsGuesser |
| 200 | DiscGolfWins |
| 201 | DiscGolfGames |
| 202 | DiscGolfHolesUnderPar |
| 300 | DodgeballWins |
| 301 | DodgeballGames |
| 302 | DodgeballHits |
| 400 | PaddleballGames |
| 401 | PaddleballWins |
| 402 | PaddleballScores |
| 500 | PaintballAnyModeGames |
| 501 | PaintballAnyModeWins |
| 502 | PaintballAnyModeHits |
| 600 | PaintballCTFWins |
| 601 | PaintballCTFGames |
| 602 | PaintballCTFHits |
| 603 | PaintballFlagCaptures |
| 700 | PaintballTeamBattleWins |
| 701 | PaintballTeamBattleGames |
| 702 | PaintballTeamBattleHits |
| 710 | PaintballFreeForAllWins |
| 711 | PaintballFreeForAllGames |
| 712 | PaintballFreeForAllHits |
| 800 | SoccerWins |
| 801 | SoccerGames |
| 802 | SoccerGoals |
| 900 | BowlingGames |
| 901 | BowlingWins |
| 902 | BowlingStrike |
| 1000 | QuestGames |
| 1001 | QuestWins |
| 1002 | QuestPlayerRevives |
| 1003 | QuestEnemyKills |
| 1010 | QuestGames_Goblin1 |
| 1011 | QuestWins_Goblin1 |
| 1012 | QuestPlayerRevives_Goblin1 |
| 1013 | QuestEnemyKills_Goblin1 |
| 1020 | QuestGames_Goblin2 |
| 1021 | QuestWins_Goblin2 |
| 1022 | QuestPlayerRevives_Goblin2 |
| 1023 | QuestEnemyKills_Goblin2 |
| 1030 | QuestGames_Scifi1 |
| 1031 | QuestWins_Scifi1 |
| 1032 | QuestPlayerRevives_Scifi1 |
| 1033 | QuestEnemyKills_Scifi1 |
| 1040 | QuestGames_Pirate1 |
| 1041 | QuestWins_Pirate1 |
| 1042 | QuestPlayerRevives_Pirate1 |
| 1043 | QuestEnemyKills_Pirate1 |
| 1050 | QuestGames_Dracula1 |
| 1051 | QuestWins_Dracula1 |
| 1052 | QuestPlayerRevives_Dracula1 |
| 1053 | QuestEnemyKills_Dracula1 |
| 2000 | ArenaGames |
| 2001 | ArenaWins |
| 2002 | ArenaPlayerRevives |
| 2003 | ArenaHeroTags |
| 2004 | ArenaBotTags |
| 3000 | RecRoyaleGames |
| 3001 | RecRoyaleWins |
| 3002 | RecRoyaleTags |
| 4000 | StuntRunnerGames |
| 4001 | StuntRunnerWins |
| 5000 | RecRallyGames |
| 5001 | RecRallyWins |
## Machine-readable
```json
{"Default":-1,"FirstSessionOfDay":1,"AddAFriend":2,"PartyUp":3,"AllOtherChallenges":4,"LevelUp":5,"CheerAPlayer":6,"PointedAtPlayer":7,"CheerARoom":8,"SubscribeToPlayer":9,"DailyObjective1":10,"DailyObjective2":11,"DailyObjective3":12,"AllDailyObjectives":13,"CompleteAnyDaily":14,"CompleteAnyWeekly":15,"OOBE_GoToLockerRoom":20,"OOBE_GoToActivity":21,"OOBE_FinishActivity":22,"NUX_PunchcardObjective":25,"NUX_AllPunchcardObjectives":26,"GoToRecCenter":30,"FinishActivity":31,"VisitACustomRoom":32,"CreateACustomRoom":33,"ScoreBasketInRecCenter":35,"UploadPhotoToRecNet":36,"UpdatePlayerBio":37,"SaveOutfitSlot":38,"PurchaseClothingItem":39,"PurchaseNonClothingItem":40,"DrinkWater":41,"ColorOnWhiteboard":42,"SetBasketballSkin":43,"ThrowBasketball":44,"PlaceInventionInDorm":45,"ChangeDormRoomSkin":46,"ToggleOwnedClothes":47,"EquipHat":48,"LoadOutfit":49,"SaveNewOutfitSlot":50,"SpawnCamera":51,"TakeSelfie":52,"PrintSelfie":53,"TakePictureOfPlayer":54,"PrintPictureOfPlayer":55,"PublishSelfieWithPlayer":56,"SpawnFoodWithOtherPlayers":57,"EmoteInRecCenter":58,"SendRoomChatInRecCenter":59,"UseFrendotron":60,"GoToDormRoom":61,"VisitSpecificRoom":62,"VisitPublicRRO":63,"VisitPublicRoomBySource":64,"FavoriteARoom":65,"TakePhotoWithFilter":66,"OpenYourPlayerProfile":67,"OpenOnlineStatusModal":68,"ChangeProfilePicture":69,"ChangePlayerDisplayName":70,"ChangePlayerDescriptionText":71,"OpenPlayerPronounsModal":72,"OpenOtherPlayersProfile":73,"VisitPlayersPortfolio":74,"FavoriteAFriend":75,"CharadesGames":100,"CharadesWinsPerformer":101,"CharadesWinsGuesser":102,"DiscGolfWins":200,"DiscGolfGames":201,"DiscGolfHolesUnderPar":202,"DodgeballWins":300,"DodgeballGames":301,"DodgeballHits":302,"PaddleballGames":400,"PaddleballWins":401,"PaddleballScores":402,"PaintballAnyModeGames":500,"PaintballAnyModeWins":501,"PaintballAnyModeHits":502,"PaintballCTFWins":600,"PaintballCTFGames":601,"PaintballCTFHits":602,"PaintballFlagCaptures":603,"PaintballTeamBattleWins":700,"PaintballTeamBattleGames":701,"PaintballTeamBattleHits":702,"PaintballFreeForAllWins":710,"PaintballFreeForAllGames":711,"PaintballFreeForAllHits":712,"SoccerWins":800,"SoccerGames":801,"SoccerGoals":802,"BowlingGames":900,"BowlingWins":901,"BowlingStrike":902,"QuestGames":1000,"QuestWins":1001,"QuestPlayerRevives":1002,"QuestEnemyKills":1003,"QuestGames_Goblin1":1010,"QuestWins_Goblin1":1011,"QuestPlayerRevives_Goblin1":1012,"QuestEnemyKills_Goblin1":1013,"QuestGames_Goblin2":1020,"QuestWins_Goblin2":1021,"QuestPlayerRevives_Goblin2":1022,"QuestEnemyKills_Goblin2":1023,"QuestGames_Scifi1":1030,"QuestWins_Scifi1":1031,"QuestPlayerRevives_Scifi1":1032,"QuestEnemyKills_Scifi1":1033,"QuestGames_Pirate1":1040,"QuestWins_Pirate1":1041,"QuestPlayerRevives_Pirate1":1042,"QuestEnemyKills_Pirate1":1043,"QuestGames_Dracula1":1050,"QuestWins_Dracula1":1051,"QuestPlayerRevives_Dracula1":1052,"QuestEnemyKills_Dracula1":1053,"ArenaGames":2000,"ArenaWins":2001,"ArenaPlayerRevives":2002,"ArenaHeroTags":2003,"ArenaBotTags":2004,"RecRoyaleGames":3000,"RecRoyaleWins":3001,"RecRoyaleTags":3002,"StuntRunnerGames":4000,"StuntRunnerWins":4001,"RecRallyGames":5000,"RecRallyWins":5001}
```
## Related endpoints
| Endpoint | Purpose |
| --- | --- |
| `GET api/config/v2` | serves `dailyObjectives` (this file) |
| `GET api/objectives/v1/myprogress` | player's current progress |
| `POST api/objectives/v1/updateobjective` | one objective update — `{index, group, progress, visualProgress, isCompleted, hasClaimedReward}``{group, isCompleted, clearedAt}` |
| `POST api/objectives/v1/completegroup` | group completion |
| `POST api/objectives/v1/cleargroup` | group reset |
The objectives endpoints are on the **Econ** service (`econ.*`); config is on **API** (`api.*`).
## How this was extracted
```sh
# in the il2cpp scratchpad, with Il2CppDumper output in ./out/
grep -n "enum PNLFAAAPEID" out/dump.cs # find the block
# then parse `public const PNLFAAAPEID <name> = <value>;` lines until the closing brace
```
The `dailyObjectives` wire name came from the Utf8Json formatter, not the property name — see
`SHAPES.md` §1. Formatter `.ctor` RVAs for this build: `LCPOOJEAMJA``0x3512E10`,
`FCAOHDFPEAP``0x34EE5F0`.
@@ -0,0 +1,183 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — node types, scene-id predicates, and the shared-scene traps
---
# The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a challenge in
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
**The server never evaluates these rules.** The client reads the tree, watches its own
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
So the tree is a _specification handed to the client_, and a malformed one fails silently —
the challenge just never completes. Nothing server-side will tell you.
Everything here was read off one captured live rotation, not a spec. Meanings marked
_(inferred)_ are read from how values line up with the strings the client renders; the rest
are pinned by the data.
## `Config` is an escaped JSON string
Not a nested object. In the file it looks like:
```json
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[...]}"
```
Author the tree as an object and stringify it into the field — don't hand-escape:
```sh
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
```
To read one back:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
```
## Node types
Each node carries a numeric type in `ct`. Two composite kinds appear:
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
result (AND).
- **Counter** (`ct: 1`) — `ctc` holds the child node to count, `t` is the target count.
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
the client echoes it back, so keep emitting it.
## Predicate leaves
Leaves carry `vs`, a list of accepted values matched as OR.
| `ct` | Shape | Meaning |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
## The two idioms
Every challenge in the captured rotation is one of these.
```jsonc
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 9, "vs": [true], "v": "won" },
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
]}
// "Complete 5 Charades games" — count matching sessions to a target
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
]}
]}
```
The quest challenges have **no `t`** (one qualifying session is the whole goal) and the
counted ones have **no `won` predicate** (finishing counts, winning is irrelevant). A "one
map only" challenge is the counted shape with a single-entry scene list.
## Scene ids, not room ids
`ct: 7` matches `UnitySceneId`, so one guid can name several rooms — a screens room, its VR
twin, and the standalone base room all share a scene. The captured "Complete 10 games in
^Paintball" lists six guids, which are the subrooms of _both_ `Paintball` and `PaintballVR`,
each of which is also a standalone base room (`River`, `Clearcut`, …). One list covers every
way in.
Resolve a guid against `SubRooms[].UnitySceneId` in `apps/rooms/static/ImportRooms.json`
(same data as `apps/rooms/migrations/0002_import_rooms.sql`). Run from the repo root:
```sh
cat > /tmp/scene.ts <<'EOF'
// path is resolved against the cwd, so run this from the repo root
const rooms = await Bun.file('apps/rooms/static/ImportRooms.json').json()
const want = new Set(process.argv.slice(2))
const byScene = new Map<string, string[]>()
for (const r of rooms as any[])
for (const s of r.SubRooms ?? [])
byScene.set(s.UnitySceneId, [...(byScene.get(s.UnitySceneId) ?? []), `${r.Name}/${s.Name}`])
for (const [id, names] of byScene) if (!want.size || want.has(id)) console.log(id, names.join(', '))
EOF
bun run /tmp/scene.ts 380d18b5-de9c-49f3-80f7-f4a95c1de161
# → 380d18b5-… Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home
```
With no arguments it dumps every scene, which is how you go the other way — from a room name
to the guid to put in `vs`.
### Shared scenes to watch for
These guids resolve to more than one room, so a challenge naming one also completes in the
others. Most are a deliberate screens/VR/base-room trio, but two are genuine surprises:
**`Soccer/Home` and `Stadium/Home` are the same scene**, so a soccer challenge also completes
in the Stadium, and `Dodgeball` shares its scene with the plain `Gym`.
| Scene id | Rooms |
| ----------- | ------------------------------------------------------------------ |
| `6d5eea4b…` | Soccer/Home, **Stadium/Home** |
| `3d474b26…` | Dodgeball/Home, **Gym/Home**, DodgeballVR/Home |
| `ae929543…` | Bowling/Home, BowlingAlley/Home |
| `f6f7256c…` | DiscGolfLake/Home, Lake/Home |
| `d9378c9f…` | DiscGolfPropulsion/Home, PropulsionTestRange/Home |
| `239e676c…` | LaserTag/Hangar, Hangar/Home |
| `9d6456ce…` | LaserTag/CyberJunkCity, LaserTagCyberJunk/Home, CyberJunkCity/Home |
| `e122fe98…` | Paintball/River, PaintballVR/River, River/Home |
| `a785267d…` | Paintball/Homestead, PaintballVR/Homestead, Homestead/Home |
| `ff4c6427…` | Paintball/Quarry, PaintballVR/Quarry, Quarry/Home |
| `380d18b5…` | Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home |
| `58763055…` | Paintball/Spillway, PaintballVR/Spillway, Spillway/Home |
| `65ddbb48…` | Paintball/Drive-in, PaintballVR/Drive-in, DriveIn/Home |
Regenerate this list with the script above and no arguments.
## Progress fields (`cc`, `c`) — client-side only
On `updateProgress` the client posts the same tree back with its own progress written into
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`**
(`"c":true`) marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't add `cc`/`c` to an authored tree, and don't try to read progress
out of one.
## Authoring a new challenge
1. Pick the idiom: one-shot (`ct: 0` root, add the `won` predicate if winning is required)
or counted (`ct: 1` root with `t`).
2. Resolve the scenes with the script above, and check the shared-scene table — decide
whether the extra rooms it lets in are acceptable.
3. Build the tree as an object, stringify it twice into `Config`.
4. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
and write the real goal in `Description``Name` is an internal slug that is not
authoritative (captured id `63` is named `Complete3SpillwayGames` but its `Config` and
description are Clearcut).
5. Leave `Complete: false`; `getCurrent` stamps it per caller.
6. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
and a new map id is what resets stored completions.
7. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
Sanity check the file parses and every tree parses:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
```
Then `bun turbo -F econ test``src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it.
## Related
- `apps/econ/README.md` — the rest of the weekly-challenge file (top level, `Gift`, progress)
- `.agents/daily-objectives/SKILL.md` — the other objective system, on `GET api/config/v2`
+58 -2
View File
@@ -1,9 +1,19 @@
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
# Base domain all service hosts are derived from, e.g. accounts.<domain>. Used by
# `just dev` too, so a locally-run worker hands out the same addresses it would deployed.
RECFLARE_DOMAIN=rec.example.com
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
# worker's directory name. Defaults to the directory name when unset.
# worker's directory name. Defaults to the directory name when unset. Use "@" to
# put a worker on the APEX of the domain rather than a subdomain.
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
#
# The combined `mono` worker (an alternative to deploying the services separately:
# it mounts most of them in one deployable and routes on the first path segment, so
# every address is https://<domain>/rooms, https://<domain>/auth, …) belongs on the
# apex, and won't hand out the right addresses anywhere else. It ships only when you
# ask for it — `just deploy-mono`, never `just deploy` — since it's an alternative to
# the split set, not part of it:
# RECFLARE_SUBDOMAINS='{"mono":"@"}'
# Id of the shared `recflare` D1 database (create it manually with
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
@@ -52,7 +62,53 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# 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
# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a
# banned one, and refuses a signup from either. Without that, an evader is back in the
# game with a new account in under a minute.
# ...`platform` matches a Steam/Meta identity the player PROVED — sharp, no false
# positives worth the name.
# ...`ip` matches the signup/last-login address — coarse. A household, dorm, campus or
# mobile carrier shares one address, so this arm bans the banned player's housemates
# along with them, and locks them out of signing up at all. Set BAN_EVASION_MATCH=platform
# to keep the sharp arm only, or off to make a ban apply to just the banned account.
# A ban ALWAYS applies to the account it was handed to, whatever this is set to.
# RECFLARE_BAN_EVASION_MATCH=ip,platform
# How many rooms one account may create (`rooms`) and how many clubs (`clubs`).
# Enforced on creation only — lowering either never touches what players already have,
# it just stops new ones. Set either to 0 to turn that cap off.
# ...ROOMS counts rooms the account created, minus their auto-provisioned dorm.
# ...CLUBS counts clubs the account created (subscription clubs don't count).
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
# room named MyHub instead, whether the client asked for it by id or by name, and whether
# 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
# live instance, which is already in whichever room it was created in.
# RECFLARE_ROOM_REDIRECTS=2=MyHub
# RecCenterTokens a new player is granted, the first time their balance is read (`econ`).
# 0 means players start broke. Applies only to players who haven't been granted yet —
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
#
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
# --scopes workers --remote
#
# Setting them both is what opens web signup; with either missing it stays closed. See
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
# caps above apply regardless.
+43
View File
@@ -0,0 +1,43 @@
name: Test
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Regression tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Install just
uses: extractions/setup-just@v3
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: just install
- name: Test
run: just test
+95
View File
@@ -55,6 +55,101 @@
- Explicit function return types are optional
</code-style>
<client-contract-notes>
Response shapes the Rec Room client depends on. These were found by watching the live
client, not by reading a spec: when one is wrong the client renders nothing or hangs
rather than erroring, so tests won't catch a regression. Don't "clean up" an
inconsistency here without checking the client first.
- Player image lists (`api`: `/api/images/v5|v4/player/:id`, `/api/images/v3/feed/player/:id`)
must use the `toImagesPlayer` projection — `Id``SavedImageId`, `Type`
`SavedImageType`, no `TaggedPlayerIds`. Serving the raw `SavedImage` renders blank
thumbnails.
- The room photo feed (`api`: `/api/images/v4/room/:roomId`) serves the raw `SavedImage`
and displays correctly. It is deliberately NOT projected — do not unify these two.
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
image names — a bare string array fails the client's parser ("expected '{'"). The list
is packed: removing an image shifts the rest up, never leaving a blank slot.
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
client's parser wants one — but the client renders only the FIRST entry and only ever
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
sits unreachable behind the old one and setting a load screen looks like it did
nothing. Keep the array shape for eventual multi-screen support.
- Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope.
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
`/subrooms/:sid/accessibility`, `/subrooms/:sid/publish_save`) answers
`{ success, error, value }` with the whole updated ROOM — the client re-renders the room
from `value`. Notably `value` is the room even for `clone`, whose product is a new
SUBROOM; only the room-level `POST /rooms/:id/clone` returns the thing it created.
- 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.
- 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
`subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way.
- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says
`AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves
`CurrentSave` alone, so players keep loading the last published version until the owner
posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=<id>`. DORMS always
publish: no publish step exists in the client for them. Saves live in the
`subroom_save` table with globally-unique ids (a bare id has to resolve —
`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.
- 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
latter lives on the SUBROOM). The room's public description is `PUT /rooms/:id/description`'s
alone; copying the save comment onto `room.Description` (as this once did) silently
replaces the room's description every time someone saves.
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
the owner whether to load the latest or the published version and resolves it from the
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
this server-side: it would put two people in one instance on different versions.
- A balance lives in a `(CurrencyType, Platform)` BUCKET and the client shows the SUM of the
buckets, so `Platform` is a balance's identity, not a label. This server uses exactly one
bucket per currency — `ALL_PLATFORMS`, -2 `NonPurchasedNotUsableInP2P` — and every surface
must name it: the balance DTO (`econ`: `GET /api/storefronts/v4/balance/:type`), the
`BalanceType` the storefront bodies echo, and the `Platform` on every `StorefrontBalance*`
socket frame. Two traps, which produced two "balance doubling" bugs that both looked like
the frames being additive when they are not:
- Each frame SETS the bucket it names to an absolute value — `Balance` is the RESULTING
TOTAL, never the change (`StorefrontBalancePurchase`'s `Delta`/`BalanceAddType` are
display-only; the client logs them and stores `Balance` outright). Send a change and the
balance becomes that change. Being absolute, a frame is idempotent: re-sending one, or
racing a `GET /balance`, cannot drift the total, so the player reading the HTTP response
for the same change gets a frame too.
- The bucket key on the wire is `Platform`. The client's property is named `BalanceType`
but carries a `[DataMember]` rename, and its decoder drops unknown members silently, so
a frame saying `BalanceType` lands in `Platform` 0 (`SteamPurchased`) and adds a phantom
balance to the real one — 10,000 tokens + a 250 reward read 20,250. Sending a real-but-
different platform does the same: `Platform: RecNet` on a buy showed 34,100 to a player
who spent 900 of 17,500, then 33,200 once the body's -900 reached the true bucket.
The payload shapes are recovered from the client's own decoder in
`apps/notify/src/notification-payloads.ts` — build frames against those interfaces (econ
does) so a renamed key fails the build instead of silently vanishing on the wire.
- 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
(Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`,
which accepts either form.
</client-contract-notes>
<critical-notes>
- TypeScript configs MUST use fully qualified paths: `@repo/typescript-config/base.json` not `./base.json`
- Do NOT add 'WebWorker' to TypeScript config - types are in worker-configuration.d.ts or @cloudflare/workers-types
+84 -6
View File
@@ -43,10 +43,12 @@ services but would require small code changes.
## Prerequisites
- node (modern)
- pnpm
- bun
- jq/awk/sed
**You must have all these requirements or RecFlare deployment will fail!**
- node 24 (https://nodejs.org)
- pnpm (install with `npm install -g pnpm`)
- bun (https://bun.sh)
- jq/awk/sed (on Windows try `winget jq` etc.)
- A Cloudflare account with a zone (domain) you control, for deploying.
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
@@ -65,6 +67,8 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
Create a new .env file from the template:
@@ -77,11 +81,11 @@ Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export
(Optional) - per-app subdomain overrides come from
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
if you wanted to merge two services together.
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
**Create the storage resources:**
The workers bind Cloudflare storage primitive. Create them once against your
The workers bind Cloudflare storage primitives. Create them once against your
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
nothing in version control needs editing. Authenticate wrangler first
@@ -104,6 +108,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
```
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
and only to authenticate itself to Meta when validating a headset login's nonce —
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
it. Create it too:
```bash
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
> 500 ("Meta platform verification is not configured") and nothing else is affected.
> Steam and password sign-ins are unaffected either way. Put the real value in later
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
@@ -169,6 +191,7 @@ edit the value, then re-deploy the worker that reads them.
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
Then deploy just the worker that reads it:
@@ -199,6 +222,61 @@ single address, so raise it (or set it to `0`) if real players report being lock
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
### Signing up on the website (Turnstile)
Players get an account by launching the game, which needs no setup. The website can create
one too — that path has no platform identity behind it, so it runs behind a
[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until
you configure one**. Two steps, both one-time:
1. Create the widget: Cloudflare dashboard → **Turnstile****Add widget**, mode
**Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against
real keys). It gives you a **site key** and a **secret key**.
2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch
that opens signup, and store values survive deploys:
```bash
wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
--scopes workers --remote
wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
--scopes workers --remote
```
Then `just deploy -F www`. The site key is public — the browser needs it to render the
widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is
configured in one place. The secret key never leaves the worker: `/api/signup` verifies the
token against Turnstile server-side before it calls `auth`.
Signup opens only when **both** resolve. With either missing, `/api/config` reports signup
closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs
you the signup form, never an unprotected one. That is also how you turn signup back off:
`wrangler secrets-store secret delete <store-id> --name TURNSTILE_SECRET_KEY --remote`,
then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one
until fresh isolates start). For local dev, seed the same two names into the local store
from `apps/www` — Turnstile's documented always-passes test keypair
(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a
widget:
```bash
cd apps/www
printf '1x00000000000000000000AA' |
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
printf '1x0000000000000000000000000000000AA' |
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
```
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
the only cap that can see a web signup.
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
`auth` would then record one shared `signupIp` for every web account and cap the whole
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
(the binding refuses to resolve otherwise), and web accounts created before this change
carry that shared address as their permanent `signupIp` — harmless, but they are not
counted against any real network.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+11
View File
@@ -71,6 +71,17 @@ preview:
deploy *args:
bun turbo deploy "$@"
# Deploy the combined `mono` worker: every service in ONE Worker, routed on the first
# path segment (https://<domain>/rooms). It's an alternative to the split deployment
# above — for debugging, or for running the whole server as a single service — so it has
# its own command and `just deploy` leaves it alone. Put it on the apex of your domain
# with RECFLARE_SUBDOMAINS='{"mono":"@"}'; see .env.example.
[group('2. local dev')]
[positional-arguments]
[no-cd]
deploy-mono *args:
bun turbo -F mono deploy:mono "$@"
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
[group('2. local dev')]
+3 -1
View File
@@ -2,6 +2,8 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
@@ -96,6 +98,6 @@ missing pieces. Again, another reason to come together on one project and
stop gatekeeping.
Unfortunately, they were all leaked code except for
[CannedNet](https://github.com/CannedNet/CannedNet) and jordanparki7's postman
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
collection of RecNet APIs which is gone for some reason. So I will not list the
leaks publicly.
+91 -29
View File
@@ -1,37 +1,99 @@
# accounts
Accounts Worker served on the `accounts` subdomain. A Hono app for accounts.
Database queries are stubbed for now — no real bindings yet.
Accounts Worker served on the `accounts` subdomain (`accounts.recflare.net`) — a Hono
app for account reads, profile mutations and lookups. Accounts live in the shared
`recflare` D1 database, whose `account` schema and migrations are owned by the `auth`
worker; this worker binds it read/write.
## Behavior
## Routes
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **DB-backed reads** return synthesized default accounts. Every column gets a
fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return
those defaults rather than 404ing on a missing row.
- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the
request and ack without persisting. `create` mints a random account id and
returns it wrapped in the RecNet result envelope `{ success, value }`.
| Method | Path | Auth | Description |
| ------ | ------------------------------ | ---- | ------------------------------------------------ |
| GET | `/` | | Health check |
| GET | `/account/me` | ✓ | The caller's own account (private self DTO) |
| GET | `/account/search?name=` | | Prefix-search accounts by username |
| GET | `/account/bulk?id=1&id=2,3` | | Look up many accounts by id |
| GET | `/account/:id` | | A single public account |
| GET | `/account/:id/bio` | | A player's bio |
| POST | `/account/create` | | Create an account → `{ success, value }` |
| GET | `/parentalcontrol/me` | ✓ | The caller's parental-control flags |
| GET | `/accountprivacysettings/:id` | | An account's privacy settings |
| PUT | `/account/me/displayname` | ✓ | Set display name |
| PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) |
| POST | `/account/me/email` | ✓ | Set email |
| POST | `/account/me/phone` | ✓ | Set phone number |
| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask |
| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) |
| PUT | `/account/me/bio` | ✓ | Set bio |
| PUT | `/account/me/profileimage` | ✓ | Set avatar object key |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
## Endpoints
Auth-gated routes validate the Bearer JWT issued by the `auth` worker and return an
empty-body 401 when it's missing or invalid.
- `GET /` — health check
- `GET /account/me` — authed self account (`SelfAccount`)
- `GET /account/bulk?id=1&id=2` — accounts for the requested ids
- `GET /account/:id` — single account
- `GET /account/:id/bio` — player bio
- `POST /account/create` — create an account → `{ success, value }`
- `GET /parentalcontrol/me` — authed parental-control flags
- `PUT /account/me/displayname` — authed, body `displayName`
- `PUT /account/me/username` — authed, body `username`
- `PUT /account/me/bio` — authed, body `bio`
- `PUT /account/me/profileimage` — authed, body `imageName`
## API documentation
## TODO before production
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`.
- Wire a DB binding (D1/DO) for `Accounts`, `CachedLogins`, `PlayerBios`,
`Rooms`/`SubRooms` (the dorm room created on signup).
- Make reads 404 on missing rows once real data exists.
- Persist the `PUT /account/me/*` mutations.
- Move the JWT secret to a shared secret binding (shared with `auth`).
**The spec is descriptive, not enforced.** Nothing validates requests against it — same
rationale as the `auth` worker: this serves a protocol reverse-engineered from the Rec
Room client, the handlers are lenient (form fields are read as
`typeof value === 'string' ? value : ''`), and reads fall back to a synthesized default
account rather than 404. Read a "required" field as _the client always sends it_, not
_the server rejects it if absent_.
A test asserts that every route the worker serves appears in the spec, so adding a
route without documenting it fails rather than silently shipping an incomplete spec.
## Account shapes
Two DTOs, both camelCase:
- **Public** (`toAccountDto`) — returned for any account. Excludes private fields.
- **Self** (`toSelfAccountDto`, the `/account/me` shape) — the public DTO plus
owner-only `email`, `birthday` and `availableUsernameChanges`.
Two client-deserializer quirks are load-bearing and deliberate:
- `juniorState` / `parentAccountId` are **omitted entirely** when unset — emitting
`null` makes the client's enum parser throw. `email` / `birthday` aren't enums, so
they're kept as `null`.
- `GET /accountprivacysettings/:id` never returns a bare `{}` — that fails the client's
deserializer ("Deserialization returned null"), so the id is echoed back with recent
history reported visible. Nothing stores per-player privacy yet.
## Missing rows fall back to defaults
Account reads (`/account/me`, `/account/:id`, `/account/bulk`) never 404 on an unknown
id — they synthesize a default account (`defaultAccount`) so every requested id is
present in the response. `bulk` in particular guarantees one entry per requested id.
## Notifications
Profile mutations persist to the account row and then push through the shared
notifications hub (a single global Durable Object owned by the `notify` worker): the
owner receives `SelfAccountUpdate` + `AccountUpdate`, and every connected client
receives an `AccountUpdate` broadcast. Hub failures are logged and swallowed — the
write has already committed, so a hub hiccup must not fail the request.
This matters most for the mutations whose HTTP response carries no account body
(`personalpronouns`, `identityflags`): the client only learns the new value from the
pushed update, and since those fields are in the _public_ DTO, every other client needs
the broadcast too. `email` and `phone` are private, so they persist without a push.
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | ------------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database; `account` schema owned by `auth` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
This worker has no migrations of its own — the `account` table is created and migrated
by `auth` (`apps/auth/migrations/`).
## Known gaps
- `POST /account/create` parses `platformId` but doesn't yet persist it, and doesn't
create the dorm Room/SubRoom a new account should get.
+6 -1
View File
@@ -18,8 +18,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+547 -161
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -10,9 +11,41 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import {
logger,
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
AccountDto,
BioRequest,
BioResponse,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
EmailRequest,
form,
HealthResponse,
IdentityFlagsRequest,
json,
ParentalControl,
PhoneRequest,
PrivacySettings,
ProfileImageRequest,
PronounsRequest,
SelfAccountDto,
SuccessResponse,
UsernameRequest,
UsernameResult,
} from './openapi'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -21,9 +54,10 @@ import type { App } from './context'
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
* owned by the `auth` worker). Accounts not in the table fall back to a
* synthesized default (every column has a fallback anyway). Profile mutations
* still accept-and-ack (marked `TODO`).
* persist to the account row and push an AccountUpdate through the notifications
* hub (see `pushAccountUpdate`).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/**
@@ -47,6 +81,11 @@ const DEFAULT_USERNAME_CHANGES = 1
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
* and `value` is an empty string.
*
* The envelope-at-200 is the reference's (`RecNet`) convention — a refusal is a
* successful call that answers "no", and the player-facing sentence rides in `error`.
* `POST /account/create` does the same. This was briefly a 400 so a caller could branch
* on the status; it isn't, because that's not what the real service does.
*/
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
return c.json({ success: error === '', error, value })
@@ -80,14 +119,20 @@ function toAccountDto(account: Account) {
/**
* Project a stored account into the private self DTO (the /account/me shape) —
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
* OMITTED when null (emitting `null` makes the client's enum parser throw);
* `email`/`birthday` are kept as null (not enums, so null is fine).
* OMITTED when null (emitting `null` makes the client's enum parser throw).
*
* An unset `email` is `""`, never null — same as `bio`. Two reasons: the client reads
* it as a string, and this DTO also rides the `SelfAccountUpdate` hub frame, where the
* hub DROPS null values from `Msg` — so a null email doesn't arrive as null, it
* vanishes from the frame entirely.
*/
function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
email: account.email ?? '',
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
// everyone can be old.
birthday: '1904-01-01T00:00:00.000Z',
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
@@ -96,7 +141,7 @@ function toSelfAccountDto(account: Account) {
const HUB_INSTANCE = 'global'
/**
* Push the notifications that follow an account mutation, mirroring the C#/Go
* Push the notifications that follow an account mutation, mirroring the reference
* hub behavior: the owner receives `SelfAccountUpdate` and `AccountUpdate`, and
* every connected client receives an `AccountUpdate` broadcast. Hub failures are
* logged and swallowed — the account write has already committed, so a hub
@@ -106,9 +151,13 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const publicDto = toAccountDto(account)
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
await hub.broadcast('AccountUpdate', publicDto)
await hub.notifyPlayer(
account.accountId,
NotificationType.SubscriptionUpdateSelfProfile,
toSelfAccountDto(account)
)
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
} catch (err) {
logger.error('failed to push account update notifications', {
accountId: account.accountId,
@@ -117,6 +166,12 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
}
}
/** The empty-body 401 every auth-gated route returns; reused across their specs. */
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
const AUTHED = [{ bearerAuth: [] }]
const app = new Hono<App>()
.use(
'*',
@@ -128,207 +183,538 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get('/', (c) => c.json({ service: 'accounts', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Meta'],
summary: 'Health check',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'accounts', status: 'ok' })
)
// ---- Self account --------------------------------------------------------
.get('/account/me', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
return c.json(toSelfAccountDto(account))
})
.get(
'/account/me',
describeRoute({
tags: ['Self'],
summary: 'The callers own account',
description: [
'The private self DTO, including owner-only fields (email, remaining username',
'changes). An account with no stored row falls back to a synthesized default.',
].join(' '),
security: AUTHED,
responses: {
200: json(SelfAccountDto, 'The callers account'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
return c.json(toSelfAccountDto(account))
}
)
// ---- Search --------------------------------------------------------------
// Prefix-search accounts by username (`?name=`). Returns a bare array of public
// account DTOs, ordered alphabetically. Registered before `/account/:id` so the
// static `search` path wins over the param route.
.get('/account/search', async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
return c.json(accounts.map(toAccountDto))
})
.get(
'/account/search',
describeRoute({
tags: ['Lookup'],
summary: 'Prefix-search accounts by username',
description: 'Case-insensitive prefix match on username, ordered alphabetically.',
parameters: [
{
name: 'name',
in: 'query',
required: false,
description: 'Username prefix; empty matches nothing meaningful',
schema: { type: 'string' },
},
],
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
}),
async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
return c.json(accounts.map(toAccountDto))
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', 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))))
})
.get(
'/account/bulk',
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(' '),
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' } },
},
],
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}),
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))))
}
)
.get('/account/:id/bio', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Bio is stored on the account JSON (set via PUT /account/me/bio).
const account = await getAccount(c.env.DB, accountId)
return c.json({ accountId, bio: account?.bio ?? '' })
})
.get(
'/account/:id/bio',
describeRoute({
tags: ['Lookup'],
summary: 'A players bio',
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(BioResponse, 'The bio (empty string when unset)'),
400: { description: 'Non-numeric id (empty body)' },
},
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Bio is stored on the account JSON (set via PUT /account/me/bio).
const account = await getAccount(c.env.DB, accountId)
return c.json({ accountId, bio: account?.bio ?? '' })
}
)
.get('/account/:id', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Load the stored account, falling back to a synthesized default.
return c.json(
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
)
})
.get(
'/account/:id',
describeRoute({
tags: ['Lookup'],
summary: 'A single public account',
description: 'An id with no stored row falls back to a synthesized default account.',
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(AccountDto, 'The public account'),
400: { description: 'Non-numeric id (empty body)' },
},
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Load the stored account, falling back to a synthesized default.
return c.json(
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
)
}
)
// ---- Create --------------------------------------------------------------
.post('/account/create', async (c) => {
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
const platform = await formField(c, 'platform')
await formField(c, 'platformId')
.post(
'/account/create',
describeRoute({
tags: ['Self'],
summary: 'Create an account',
description: [
'Mints a new account with an auto-assigned random username (players dont choose',
'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.',
].join(' '),
requestBody: form(CreateAccountRequest, 'Platform fields'),
responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') },
}),
async (c) => {
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
const platform = await formField(c, 'platform')
await formField(c, 'platformId')
// Persist a new account with an auto-assigned random username (players
// don't choose one initially).
const platforms = Number.parseInt(platform, 10)
const account = await createAccount(c.env.DB, {
platforms: Number.isNaN(platforms) ? 0 : platforms,
})
// TODO: also create a dorm Room/SubRoom for the new account.
return c.json({ success: true, value: toAccountDto(account) })
})
// Persist a new account with an auto-assigned random username (players
// don't choose one initially).
const platforms = Number.parseInt(platform, 10)
const account = await createAccount(c.env.DB, {
platforms: Number.isNaN(platforms) ? 0 : platforms,
})
// TODO: also create a dorm Room/SubRoom for the new account.
return c.json({ success: true, value: toAccountDto(account) })
}
)
// ---- Parental control ----------------------------------------------------
.get('/parentalcontrol/me', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: false })
})
.get(
'/parentalcontrol/me',
describeRoute({
tags: ['Self'],
summary: 'The callers parental-control flags',
description: 'Nothing stores parental controls yet; purchases are always allowed.',
security: AUTHED,
responses: {
200: json(ParentalControl, 'Parental-control flags'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: false })
}
)
// Privacy settings for an account. A bare `{}` fails the client's deserializer
// ("Deserialization returned null") — it needs the fields, so echo the id back and
// report recent history as visible. Nothing stores per-player privacy yet.
.get('/accountprivacysettings/:id{[0-9]+}', (c) =>
c.json({
accountId: Number.parseInt(c.req.param('id'), 10),
isRecentHistoryVisible: true,
})
.get(
'/accountprivacysettings/:id{[0-9]+}',
describeRoute({
tags: ['Lookup'],
summary: 'An accounts privacy settings',
description: [
'Nothing stores per-player privacy yet; the id is echoed and recent history is',
'reported visible (a bare `{}` fails the clients deserializer).',
].join(' '),
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: { 200: json(PrivacySettings, 'Privacy settings') },
}),
(c) =>
c.json({
accountId: Number.parseInt(c.req.param('id'), 10),
isRecentHistoryVisible: true,
})
)
// ---- Profile mutations ---------------------------------------------------
// Set the player's display name (persisted on the account row).
.put('/account/me/displayname', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/displayname',
describeRoute({
tags: ['Profile'],
summary: 'Set display name',
description: 'Persisted and broadcast via an AccountUpdate notification.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// An EMPTY 400, which is what this route already answered for an empty name: it
// acks with a bare SuccessResponse and has never sent the client a body on
// failure, so enforcing the schema doesn't change what a refusal looks like.
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { displayName } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// Change the caller's username. Rejects a name already taken by another account,
// and requires the account to have username changes remaining. On success the
// new name is persisted and the remaining-changes counter is decremented.
.put('/account/me/username', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.put(
'/account/me/username',
describeRoute({
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
].join(' '),
security: AUTHED,
responses: {
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
// — the part that matters — can never spend one of the account's rationed changes.
// The message is relayed rather than zod's issue array: `nameRejection` writes the
// sentence the player reads, and nothing can render an array of issues.
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
// Standard Schema's flat issue list rather than a zod error object.
validator('form', UsernameRequest, (r, c: Context<App>) =>
r.success
? undefined
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const username = (await formField(c, 'username')).trim()
if (username === '') return usernameResult(c, 'You must enter a username.')
const { username } = c.req.valid('form')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
if (existing && existing.accountId !== id) {
return usernameResult(c, 'That username is already taken.')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
if (existing && existing.accountId !== id) {
return usernameResult(c, 'That username is already taken.')
}
// Then require a remaining change.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
if (remaining <= 0) {
return usernameResult(c, 'You have no username changes remaining.')
}
const updated = await updateAccount(c.env.DB, id, {
username,
availableUsernameChanges: remaining - 1,
})
await pushAccountUpdate(c, updated)
return usernameResult(c, '', toAccountDto(updated))
}
// Then require a remaining change.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
if (remaining <= 0) {
return usernameResult(c, 'You have no username changes remaining.')
}
const updated = await updateAccount(c.env.DB, id, {
username,
availableUsernameChanges: remaining - 1,
})
await pushAccountUpdate(c, updated)
return usernameResult(c, '', toAccountDto(updated))
})
)
// Set the player's email (persisted on the account row; surfaced by /account/me).
.post('/account/me/email', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const email = (await formField(c, 'email')).trim()
if (!email.includes('@')) return c.body(null, 400)
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
})
.post(
'/account/me/email',
describeRoute({
tags: ['Profile'],
summary: 'Set email',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a syntactically valid address (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
)
// Set the player's phone (persisted on the account row).
.post('/account/me/phone', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const phone = (await formField(c, 'phone')).trim()
if (phone === '') return c.body(null, 400)
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
})
.post(
'/account/me/phone',
describeRoute({
tags: ['Profile'],
summary: 'Set phone number',
description: 'Persisted on the account row. Not broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty phone (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { phone } = c.req.valid('form')
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
}
)
// Set the player's identityFlags bitmask (persisted; surfaced by /account/me).
// `identityFlags` is part of the public account DTO, so the update has to be pushed
// — see the note on personalpronouns below.
.put('/account/me/identityflags', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
if (Number.isNaN(identityFlags)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { identityFlags })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/identityflags',
describeRoute({
tags: ['Profile'],
summary: 'Set identity flags',
description: [
'`identityFlags` bitmask. In the public DTO, so the update is broadcast via',
'AccountUpdate.',
].join(' '),
security: AUTHED,
requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Non-numeric identityFlags (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
if (Number.isNaN(identityFlags)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { identityFlags })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// Set the player's personalPronouns (posted as `pronounFlags`; persisted).
// The response body carries no account, so the client only learns the new value from
// the `SelfAccountUpdate`/`AccountUpdate` the hub pushes — without it the player's own
// UI (and every other client, since personalPronouns is in the public DTO) keeps
// showing the old pronouns until something else refetches the account.
.put('/account/me/personalpronouns', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { personalPronouns })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/personalpronouns',
describeRoute({
tags: ['Profile'],
summary: 'Set personal pronouns',
description: [
'Posted as `pronounFlags`. The response carries no account, so the client learns',
'the new value only from the broadcast AccountUpdate.',
].join(' '),
security: AUTHED,
requestBody: form(PronounsRequest, 'The pronounFlags bitmask'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Non-numeric pronounFlags (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { personalPronouns })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
.put('/account/me/bio', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/bio',
describeRoute({
tags: ['Profile'],
summary: 'Set bio',
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Bio over 255 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// Refused rather than truncated: silently storing half a sentence reads as data loss.
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { bio } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
.put('/account/me/profileimage', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const imageName = await formField(c, 'imageName')
if (!imageName) return c.body(null, 400)
// Persist the new avatar key on the account row and fire the AccountUpdate
// websocket (the new profileImage rides along in the DTO payload).
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/profileimage',
describeRoute({
tags: ['Profile'],
summary: 'Set profile image',
description: 'Persists the avatar object key and broadcasts it in the AccountUpdate payload.',
security: AUTHED,
requestBody: form(ProfileImageRequest, 'The avatar object key'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty imageName (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const imageName = await formField(c, 'imageName')
if (!imageName) return c.body(null, 400)
// Persist the new avatar key on the account row and fire the AccountUpdate
// websocket (the new profileImage rides along in the DTO payload).
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare accounts',
version: '1.0.0',
description: [
'Account reads, profile mutations and lookups for recflare, a private-server',
'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`',
'D1 database, whose `account` schema is owned by the `auth` worker.',
].join('\n'),
},
servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+209
View File
@@ -0,0 +1,209 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
MAX_USERNAME_LENGTH,
nameRejection,
} from '@repo/domain'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the accounts worker.
*
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
* missing or malformed input falls through to a graceful path (or a synthesized default
* account) rather than a hard error. A schema that rejected what the client actually
* sends would break the game, not protect it.
*
* The EXCEPTION is the profile mutations a player types into a box — displayName,
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
* version of this note prescribed. Wiring one up means the schema both validates the
* request and generates the spec, so a limit can't be changed in one and not the other —
* which is precisely how the documented email limit came to disagree with the real one.
*
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
* validator registers the body itself, and declaring it twice would emit it twice.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Emit a zod schema as a form request body. `describeRoute`'s `requestBody` takes a
* plain OpenAPI schema (not a `resolver()`), so convert here. zod's `$schema` key and
* `additionalProperties: false` are dropped — these handlers read the fields they know
* and ignore the rest, so claiming a closed object would misreport them as stricter
* than they are. The client posts both urlencoded and multipart, hence the wildcard.
*/
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return {
description,
content: {
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject; cast at the
// boundary (the emitted value is valid OpenAPI 3.1).
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
'multipart/form-data': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
},
}
}
/**
* The public account DTO (`toAccountDto`) — the camelCase shape returned for any
* account, with private fields (email, birthday) excluded. Fields the client parses
* as enums are numbers here.
*/
export const AccountDto = z.object({
accountId: z.int(),
username: z.string(),
displayName: z.string(),
profileImage: z.string().describe('Avatar object key'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
identityFlags: z.int().describe('Identity flags bitmask'),
createdAt: z.iso.datetime(),
})
/**
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
* unset (emitting `null` makes the client's enum parser throw).
*/
export const SelfAccountDto = AccountDto.extend({
email: z
.string()
.describe(
'"" when unset — never null: the client reads it as a string, and the hub frame this ' +
'DTO also rides drops null values outright'
),
birthday: z.iso.datetime().describe('A fixed placeholder — birthdays are not stored'),
availableUsernameChanges: z.int().describe('Remaining username changes'),
})
/** Player bio, from `GET /account/:id/bio`. */
export const BioResponse = z.object({
accountId: z.int(),
bio: z.string().describe('"" when unset'),
})
/** A bare `{ success: true }` ack, returned by most profile mutations. */
export const SuccessResponse = z.object({ success: z.literal(true) })
/** The RecNet result envelope `{ success, value }` used by create + username change. */
export function envelope(value: z.ZodType) {
return z.object({
success: z.boolean(),
value,
error: z.string().optional().describe('Present (with success:false) on failure'),
})
}
/**
* The username-change envelope. Always HTTP 200 even on failure: `success:false` with
* a message in `error` and `value` an empty string; on success `value` is the updated
* public account.
*/
export const UsernameResult = envelope(z.union([AccountDto, z.literal('')])).describe(
'value is the updated account on success, "" on failure'
)
/** `POST /account/create` response. */
export const CreateAccountResult = envelope(AccountDto)
/** `GET /parentalcontrol/me` response. */
export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() })
/**
* `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's
* deserializer, so the id is echoed back and recent history reported visible; nothing
* stores per-player privacy yet.
*/
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
/** Root health check. */
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
// ---- Request bodies --------------------------------------------------------
/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */
export const CreateAccountRequest = z.object({
platform: z.string().optional().describe('PlatformType integer string; defaults to 0'),
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
})
/**
* Single-string form bodies, one per profile mutation.
*
* These are ENFORCED, not just described: each is handed to hono-openapi's `validator`,
* so the same schema both validates the request and generates the spec. Before this they
* were documentation only, and the real rule lived in the handler — which meant every
* limit had to be edited in two places and nothing caught them disagreeing.
*
* The rules themselves come from `@repo/domain` so `rooms` and `clubs` can't drift from
* `accounts`; `superRefine` is used where the message matters, because `nameRejection`
* writes the player-facing sentence and there's no reason to write it twice.
*/
/** Zod check that defers to the shared name rule, message and all. */
const nameCheck = (label: string, max: number) =>
z.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
email: z
.string()
.trim()
.refine(isValidEmail, 'That email address looks wrong.')
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
})
export const PhoneRequest = z.object({
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
// rule above would reject outright by eating the leading `+`.
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
})
export const IdentityFlagsRequest = z.object({
identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const PronounsRequest = z.object({
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const BioRequest = z.object({
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
})
@@ -161,6 +161,9 @@ describe('auth-gated endpoints', () => {
personalPronouns: 0,
identityFlags: 0,
availableUsernameChanges: 1,
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
})
// juniorState + parentAccountId must be omitted when null, not emitted as
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
@@ -412,4 +415,226 @@ describe('auth-gated endpoints', () => {
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /account/bulk',
'GET /account/me',
'GET /account/search',
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /parentalcontrol/me',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
'PUT /account/me/bio',
'PUT /account/me/displayname',
'PUT /account/me/identityflags',
'PUT /account/me/personalpronouns',
'PUT /account/me/profileimage',
'PUT /account/me/username',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
// hono-openapi registers a validated form body under `multipart/form-data` only, and
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
// multipart would tell an integrator to send the one thing nothing here sends.
test('GET /openapi.json documents both form content types on validated routes', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const spec = (await res.json()) as {
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
}
for (const [path, method] of [
['/account/me/email', 'post'],
['/account/me/username', 'put'],
['/account/me/displayname', 'put'],
['/account/me/bio', 'put'],
['/account/me/phone', 'post'],
] as const) {
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
expect(Object.keys(content).sort(), path).toEqual([
'application/x-www-form-urlencoded',
'multipart/form-data',
])
}
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
// Server-generated names go around this deliberately — the seeded "Rec Room" account
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
// check belongs at the request handler, not in the db helpers.
describe('name, email and bio validation', () => {
const authed = async (sub: string) => ({
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
})
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
const headers = await authed('8801')
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
// runs — but still the envelope at HTTP 200, like every other refusal here,
// because the hook puts it there.
expect(res.status, username).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success, username).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,
// and burning it on a typo would leave the player stuck with a name they never had.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
).json()) as { availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(1)
// 50 is the client's own cap, so a name that long has to be accepted.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'a'.repeat(50) }),
headers,
})
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
// regex only buys more edge cases to get wrong. It enforces the RFC's own
// 254-character maximum, which is why there's no separate length check.
test('POST /account/me/email requires a syntactically valid address', async () => {
const headers = await authed('8803')
const bad = [
'nope', // no @ at all — what this route used to be the only check for
'@example.com', // nothing to deliver to
'someone@', // no domain
'someone@example.', // empty last label
'two words@example.com', // whitespace
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
]
for (const email of bad) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(400)
}
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
// and an undeliverable address costs nothing here.
for (const email of [
'someone@example.com',
'first.last+tag@mail.example.co.uk',
'someone@localhost',
]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(200)
}
})
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
const headers = await authed('8804')
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(255) }),
headers,
})
expect(ok.status).toBe(200)
// Refused rather than truncated — storing half a sentence reads as data loss.
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(256) }),
headers,
})
expect(tooLong.status).toBe(400)
// The refusal changed nothing: the 255-character bio is still what's stored.
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
})
})
// Phone is deliberately NOT held to the name rule above: the client sends E.164
// (`+15552223333`), so a letters-and-digits check would reject every real number by
// eating the leading `+`. Pinned here because this route sits between two that DID just
// get stricter, and the obvious next "cleanup" is to make it match them.
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
...form({ phone: '+15552223333' }),
method: 'POST',
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
// endpoint to check it through.
const row = await env.DB.prepare(
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
).first<{ phone: string }>()
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
+23
View File
@@ -0,0 +1,23 @@
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
-- tables in this shared database), a report is genuinely columnar, so it gets a
-- normal relational table. Owned by the `api` worker; generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One row per submitted report; nothing updates or dedupes them, so the table is
-- an append-only log of what players sent. `reporter_player_id` comes from the
-- caller's bearer token, everything else from the form body.
CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
+21
View File
@@ -0,0 +1,21 @@
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
-- reports are what players submit, warnings are what a moderator hands down. Also
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
-- token (the endpoint is gated on the `moderator` role); everything else comes
-- from the form body. `display_reason` is what the warned player is shown,
-- `moderator_note` is internal.
CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
+25
View File
@@ -0,0 +1,25 @@
-- Player-event storage (scheduled events: a room, a window of time, and the
-- settings the event runs under). Like the image/invention/rooms/accounts tables
-- in this shared database, an event is a single JSON blob in the `data` column,
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
-- browse query filters finished events in SQL on that.
CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
@@ -0,0 +1,23 @@
-- Player-event RSVPs: one row per player per event, recording how they answered
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
-- one is genuinely columnar — like the relationship/report tables — so it's a
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
-- every response. A decline is recorded rather than deleted, so the client can show
-- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace).
--
-- An event's creator gets a Going row at create time — that's why a fresh event's
-- AttendeeCount is 1.
CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
@@ -0,0 +1,21 @@
-- Break the two visibility flags out of the invention JSON blob into queryable
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
-- the per-room list ask for "published and not hidden" — so they move together.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
--
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
-- would need rewriting existing rows), which is what we want anyway: the value stays
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
-- replaces did. This is a rename, not a behaviour change.
--
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
-- invention is published and not hidden), so an index on them would be read past rather
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
ALTER TABLE invention
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
ALTER TABLE invention
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
+19
View File
@@ -0,0 +1,19 @@
-- Turn a report into a ban. A report row already names the player it is against
-- (`reported_player_id`), so a moderator acting on one flips `banned` on that same row
-- rather than duplicating it into a second table — the ban then carries the report that
-- justified it (category, details, room, who filed it) with no join.
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `ban_expires` is an ISO-8601 UTC timestamp like `created_at`, and NULL means the ban
-- never expires. Kept as its own column rather than "banned until" alone so a lifted ban
-- (banned = 0) is distinguishable from an expired one, and so the row remains a report
-- once the ban is over. Rows stay append-only in every other respect.
--
-- Partial index: bans are rare next to reports, so indexing only the banned rows keeps
-- the lookup (done on every matchmake and every token grant) reading a handful of pages
-- instead of every report ever filed against that player. idx_report_reported stays —
-- it serves the "all reports against this player" moderation read, which is unfiltered.
ALTER TABLE report ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
ALTER TABLE report ADD COLUMN ban_expires TEXT;
CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1;
+25
View File
@@ -0,0 +1,25 @@
-- Player-event tags: the categories an event is filed under (`workshops`, `meetup`, …),
-- one row per tag per event. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- A separate table rather than a field on the event blob, for a reason that isn't
-- storage taste: the stored blob IS the event DTO every read serves verbatim, and the
-- event reads do NOT carry tags — they surface only behind
-- `GET /api/playerevents/v1/{id}?includeDetails=True`. Putting them in the blob would
-- leak a `Tags` key into every other read.
--
-- `tag` is stored lowercased and is the search key: `?query=%23workshops` (a `#`-prefixed
-- term) filters on this table, while a bare term still matches the name/description.
-- `type` is the client's tag-category int, echoed back as sent — its enum isn't reversed
-- yet, and nothing here interprets it.
--
-- The primary key is (event_id, tag): an event can't carry the same tag twice, and a tag
-- edit REPLACES the event's set rather than accumulating.
CREATE TABLE IF NOT EXISTS event_tag (
event_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag);
+20
View File
@@ -0,0 +1,20 @@
-- Reporting a player EVENT (`POST /api/playerevents/v1/report`) reuses the report
-- table rather than getting one of its own: it is the same submission with the same
-- fields (category, free-text details, the reporter from the token) and the same
-- moderation life — a moderator acting on it sets `banned` on the row exactly as they
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in
-- sync.
--
-- `event_id` names the reported event; NULL on every ordinary player report, which is
-- what tells the two kinds apart. The row's other columns are still filled in from the
-- event: `reported_player_id` is its CREATOR (the person a moderator would act
-- against — the column is NOT NULL, and "who is answerable for this event" is the only
-- honest answer), and `room_id` the room it runs in, read from the event table so the
-- client doesn't have to send either.
--
-- Partial index: event reports are a small minority of rows, so indexing only the ones
-- that name an event keeps "reports against this event" off a full scan without paying
-- for the NULLs.
ALTER TABLE report ADD COLUMN event_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+52 -1
View File
@@ -1,10 +1,12 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
@@ -38,6 +40,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -47,9 +57,50 @@ const app = new Hono<App>({ strict: false })
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', roomRoutes)
.route('/', imageRoutes)
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare api',
version: '1.0.0',
description: [
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'player events, reputation and the assorted sinks the client hits while loading.',
'Relationships, inventions, images and player events are D1-backed; several',
'endpoints are still stubs, noted per route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
'client calls that host and the copy here is a stub, which each route says.',
].join('\n'),
},
servers: [{ url: 'https://api.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+208
View File
@@ -0,0 +1,208 @@
/**
* Who a ban reaches — the ban itself, plus the accounts that share an identity with a
* banned one. This is the ban-EVASION half of moderation: a ban lives on a `report` row
* (see reports-db) and applies to one account, but a player whose account is banned can
* make another in seconds, so the block has to follow the things that are harder to
* change than an account: the platform identity they log in with, and the network they
* play from.
*
* Three arms, in descending order of how much they prove:
* - ACCOUNT — the caller's own account is banned. Certain.
* - PLATFORM — the caller shares a `platform_account` link (a Steam or Meta identity
* they PROVED to us; see the auth worker's platform-db) with a banned account. Sharp:
* linking only ever happens off a verified proof, so this really is the same person,
* modulo somebody handing over their Steam account.
* - IP — the caller shares a `signupIp`/`lastLoginIp` with a banned account. COARSE,
* and the one that will produce false positives: households, NAT, campus and mobile
* carrier networks put many unrelated players behind one address, so this arm bans a
* banned player's whole household along with them. It is the operator's call whether
* that trade is worth it — hence `BAN_EVASION_MATCH` (see `banEvasionMatch`), which
* narrows or disables the linked arms without touching the direct one.
*
* The direct arm can never be turned off. That is the point of the split: an operator
* dialling back evasion matching still enforces every ban they handed down.
*
* Reads three tables owned by three workers — `report` (api), `account` (auth, via the
* blob) and `platform_account` (auth) — which is why this is its own module rather than
* part of reports-db: it is the POLICY over those tables, not any one table's storage.
* It only ever reads them.
*
* The whole resolution is ONE statement. The alternative — fetch my ips, fetch my links,
* then query bans — is three round trips on a path that runs on every matchmake and every
* token grant. Driving from the (few) banned reports and looking each one's account up by
* its indexed id keeps the work proportional to the number of BANS, not to the number of
* accounts.
*/
import type { ReportRow } from './reports-db'
/** Which arm matched — what the block is actually resting on. */
export type BanVia = 'account' | 'platform' | 'ip'
/** A ban that reaches the caller, and how it reached them. */
export interface BanMatch {
/** The report row carrying the ban (its `reported_player_id` is who was banned). */
ban: ReportRow
via: BanVia
/**
* The banned account. Equal to the caller on a direct ban; on a linked arm it's the
* OTHER account they were matched to — the one worth naming in the operator's log.
*/
bannedAccountId: number
}
/** Which linked arms are enabled. The direct (account) arm is not optional. */
export interface BanMatchArms {
ip: boolean
platform: boolean
}
/** Both linked arms on — what an operator who sets nothing gets. */
export const DEFAULT_BAN_MATCH_ARMS: BanMatchArms = { ip: true, platform: true }
/**
* Read the `BAN_EVASION_MATCH` operator knob: a comma-separated list of the linked arms
* to enforce, out of `ip` and `platform`. Unset (the default) means BOTH — a ban follows
* the player. `off` (or `none`, or an empty list) leaves only the direct arm, so a ban
* applies to exactly the account it was handed to.
*
* Set it to `platform` on a server whose players share networks — student halls, one
* household, a country behind CGNAT — where the IP arm would lock out bystanders. The
* platform arm has no such failure mode: it matches a proven identity.
*
* Unrecognised names are ignored rather than fatal: this is read on a request path, and a
* typo must not take matchmaking or login down with it. `off` wins over anything else in
* the list, so `off,ip` is off.
*/
export function banEvasionMatch(value: string | undefined): BanMatchArms {
if (value === undefined) return DEFAULT_BAN_MATCH_ARMS
const names = value
.split(',')
.map((n) => n.trim().toLowerCase())
.filter((n) => n !== '')
if (names.length === 0 || names.includes('off') || names.includes('none')) {
return { ip: false, platform: false }
}
return { ip: names.includes('ip'), platform: names.includes('platform') }
}
/**
* The identity a request carries, for a caller who has no account yet — a `create_account`
* grant, which must be refused BEFORE it mints anything, or a banned player's next account
* exists (and has burned a signup) before the ban catches up with it.
*/
export interface BanIdentity {
/** The client IP the request came from, if the edge reported one. */
ip?: string | null
/** A VERIFIED platform identity. An unproven one must never be passed here. */
platform?: number | null
platformId?: string | null
}
/** Row shape of the resolution query — a report plus which arm matched it. */
type BanMatchRow = ReportRow & {
via_account: number
via_ip: number
via_platform: number
}
/**
* Every ban in force, tested against the caller's account and against the identity they
* present. `ips` and `ids` gather what the caller is known by: the account's stored IPs
* and platform links (when there is an account) plus the IP/identity this request itself
* carries (when there isn't one yet, or when it differs from what's stored).
*
* A NULL `?1` means "no account yet" — the `me` CTE is then empty and the account arm
* cannot match, leaving the two linked arms to answer for a signup.
*/
const RESOLVE_BAN_SQL = `
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
UNION SELECT ?3 WHERE ?3 IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
UNION SELECT ?4, ?5 WHERE ?5 IS NOT NULL
)
SELECT * FROM (
SELECT r.*,
(r.reported_player_id = ?1) AS via_account,
(?6 = 1 AND EXISTS (
SELECT 1 FROM account a, ips
WHERE a.account_id = r.reported_player_id
AND a.account_id <> COALESCE(?1, -1)
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
)) AS via_ip,
(?7 = 1 AND EXISTS (
SELECT 1 FROM platform_account p, ids
WHERE p.account_id = r.reported_player_id
AND p.account_id <> COALESCE(?1, -1)
AND p.platform = ids.platform
AND p.platform_id = ids.platform_id
)) AS via_platform
FROM report r
WHERE r.banned = 1 AND (r.ban_expires IS NULL OR r.ban_expires > ?2)
)
WHERE via_account = 1 OR via_ip = 1 OR via_platform = 1
ORDER BY via_account DESC, via_platform DESC, ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
/**
* The ban blocking this caller, or null when nothing does.
*
* Pass the `accountId` when there is one (every login after the first, and every
* matchmake) and the request's own `identity` when it adds something the account doesn't
* already carry — on a `create_account` grant there is no account at all, and that is
* exactly the request a ban evader makes.
*
* The strongest match is the one returned: a direct ban ahead of a platform match ahead
* of an IP one, then the longest-lasting ban of those. So the log line names the evidence
* an operator would want to see first, and a player whose own account is banned is never
* told it was their network.
*/
export async function resolveBan(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<BanMatch | null> {
const arms = options.arms ?? DEFAULT_BAN_MATCH_ARMS
const identity = options.identity ?? {}
const row = await db
.prepare(RESOLVE_BAN_SQL)
.bind(
accountId,
(options.now ?? new Date()).toISOString(),
identity.ip || null,
identity.platform ?? 0,
identity.platformId || null,
arms.ip ? 1 : 0,
arms.platform ? 1 : 0
)
.first<BanMatchRow>()
if (!row) return null
// `via_ip` is only stripped off the row here — it's the arm left when neither of the
// other two matched, so nothing reads it.
const { via_account, via_ip: _via_ip, via_platform, ...ban } = row
const via: BanVia = via_account === 1 ? 'account' : via_platform === 1 ? 'platform' : 'ip'
return { ban: ban as ReportRow, via, bannedAccountId: ban.reported_player_id }
}
/** Whether anything blocks this caller — the boolean form of `resolveBan`. */
export async function isPlayerBlocked(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
+3
View File
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here.
IMAGES: R2Bucket
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+844
View File
@@ -0,0 +1,844 @@
/**
* Player-event storage on the shared `recflare` D1 database. Each event is a single
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/invention/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
* clash with the other workers' migrations on the shared database).
*
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
* field set and casing here are exactly what the client parses. Timestamps are
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
*
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
* genuinely columnar (like the relationship/report tables), so it's a normal
* relational table rather than a JSON blob.
*/
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
* seed rows).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
`CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
`CREATE TABLE IF NOT EXISTS event_tag (
event_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
]
/**
* How a player answered an event invitation — the `Type` on
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
*
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
* declining is recorded rather than deleted so the client can show the player their own
* answer (and so changing your mind is an update, not an insert).
*/
export const EVENT_RESPONSE = {
going: 0,
interested: 1,
cantGo: 2,
} as const
/** The response types, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
/** Whether a number is one of the three response types. */
export function isEventResponseType(value: number): boolean {
return EVENT_RESPONSE_VALUES.includes(value)
}
/**
* One player's answer to one event.
*
* `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a
* rowid table and the implicit id is free. It's what the RSVP list serves as
* `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the
* composite key (same rowid), and nothing ever deletes an RSVP row.
*/
export interface EventAttendeeRow {
id: number
event_id: number
player_id: number
status: number
responded_at: string
}
/**
* One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase
* projection of an `event_attendee` row.
*
* `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY
* recorded, not of the player's first one: changing your mind updates the row in place
* (one row per player per event), and the client shows the answer that stands.
*/
export interface PlayerEventResponse {
PlayerEventResponseId: number
PlayerEventId: number
PlayerId: number
CreatedAt: string
Type: number
}
/** Project an RSVP row into the response the RSVP list serves. */
export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
return {
PlayerEventResponseId: row.id,
PlayerEventId: row.event_id,
PlayerId: row.player_id,
CreatedAt: row.responded_at,
Type: row.status,
}
}
/**
* One tag on an event — the categories the browse screen's filter chips name
* (`workshops`, `meetup`, …). `tag` is stored and matched lowercased; `type` is the
* client's tag-category int, echoed back as sent (its enum isn't reversed yet).
*
* Tags live in their own table, NOT on the event blob: the blob is the DTO every read
* serves verbatim, and tags surface only behind `includeDetails=True`.
*/
export interface EventTag {
tag: string
type: number
}
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
* the settings the event runs under. Served verbatim by every read endpoint.
*
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
* without pinning a subroom, needn't belong to a club, and has no banner until one is
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
* client sends them — their enums aren't reversed yet, so nothing here interprets
* them beyond the defaults below.
*/
export interface PlayerEvent {
PlayerEventId: number
CreatorPlayerId: number
ImageName: string | null
RoomId: number
SubRoomId: number | null
ClubId: number | null
Name: string
Description: string
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
StartTime: string
EndTime: string
AttendeeCount: number
State: number
Accessibility: number
IsMultiInstance: boolean
SupportMultiInstanceRoomChat: boolean
DefaultBroadcastPermissions: number
CanRequestBroadcastPermissions: number
}
interface EventRow {
data: string
}
/**
* The envelope the create/update writes answer with — the event nested under a status,
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
*
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
* that rides along with the write, and we store no event tags (see the tag-filter
* chips, which are static). The field stays present because the client's parser
* expects it.
*/
export interface PlayerEventResult {
Result: number
TagModifyResult: null
PlayerEvent: PlayerEvent
}
/** Wrap a stored event in the write envelope. */
export function toEventResult(event: PlayerEvent): PlayerEventResult {
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
}
/**
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
* them:
*
* - it is camelCase, where the record and every read endpoint are PascalCase;
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
* empty/null), and drops `State`;
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
* record stores them bare. That asymmetry is the reference server's: its notification
* frames carry the padded form and its event reads don't.
*/
export interface PlayerEventNotification {
tags: Array<{ tag: string; type: number }>
playerEventId: number
creatorPlayerId: number
roomId: number
subRoomId: number | null
clubId: number | null
name: string
description: string
imageName: string
startTime: string
endTime: string
attendeeCount: number
accessibility: number
isMultiInstance: boolean
supportMultiInstanceRoomChat: boolean
defaultBroadcastPermissions: number
canRequestBroadcastPermissions: number
broadcastingRoomInstanceId: number | null
}
/**
* The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like
* the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which the feed does not carry;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
* broadcasts an event yet, so it is always null).
*
* That's the shape observed on this endpoint; the by-id / bulk / search reads serve the
* stored record verbatim and keep `State`.
*/
export interface PlayerEventListing extends Omit<PlayerEvent, 'State'> {
BroadcastingRoomInstanceId: number | null
}
/** Project a stored event into the browse feed's listing. */
export function toEventListing(event: PlayerEvent): PlayerEventListing {
const { State: _State, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
}
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
function toTickPrecision(iso: string): string {
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
if (match === null) return iso
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
}
/**
* Project a stored event into its notification frame. `imageName` becomes an empty
* string rather than null when the event has no banner: the frame carries `""`, and a
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
*
* `tags` are passed in rather than read from the event: they live in their own table,
* and the callers that have them already looked them up.
*/
export function toEventNotification(
event: PlayerEvent,
tags: EventTag[] = []
): PlayerEventNotification {
return {
tags,
playerEventId: event.PlayerEventId,
creatorPlayerId: event.CreatorPlayerId,
roomId: event.RoomId,
subRoomId: event.SubRoomId,
clubId: event.ClubId,
name: event.Name,
description: event.Description,
imageName: event.ImageName ?? '',
startTime: toTickPrecision(event.StartTime),
endTime: toTickPrecision(event.EndTime),
attendeeCount: event.AttendeeCount,
accessibility: event.Accessibility,
isMultiInstance: event.IsMultiInstance,
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
broadcastingRoomInstanceId: null,
}
}
/**
* Normalize a timestamp to the form the client sends and reads back —
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
* milliseconds, which the samples never carry, so they're trimmed.
*/
function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/** An event's tags, alphabetical so a list read is stable. */
export async function getEventTags(db: D1Database, eventId: number): Promise<EventTag[]> {
const { results } = await db
.prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag')
.bind(eventId)
.all<EventTag>()
return results
}
/**
* Replace an event's tags with the given set — the tag edit that rides along with a
* create or update. A replace, not a merge: the client posts the whole set it wants,
* so an untagging is a post with the tag left out.
*/
export async function setEventTags(
db: D1Database,
eventId: number,
tags: EventTag[]
): Promise<void> {
const statements = [db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId)]
for (const { tag, type } of tags) {
statements.push(
db
.prepare(
`INSERT INTO event_tag (event_id, tag, type) VALUES (?1, ?2, ?3)
ON CONFLICT (event_id, tag) DO UPDATE SET type = ?3`
)
.bind(eventId, tag, type)
)
}
await db.batch(statements)
}
/**
* Fields a create or update supplies, camelCased. Every one is optional: create
* defaults what's missing, and update leaves anything absent at its stored value —
* which is why the nullable ids are `number | null` rather than merely absent, so a
* posted `"ClubId": null` can genuinely clear a club.
*/
export interface EventInput {
/** The whole tag set to store; absent leaves the event's tags alone. */
tags?: EventTag[]
imageName?: string | null
roomId?: number
subRoomId?: number | null
clubId?: number | null
name?: string
description?: string
startTime?: string
endTime?: string
state?: number
accessibility?: number
isMultiInstance?: boolean
supportMultiInstanceRoomChat?: boolean
defaultBroadcastPermissions?: number
canRequestBroadcastPermissions?: number
}
/** Read a value as an integer, or undefined when absent / not a number. */
function asInt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string') {
const n = Number.parseInt(value, 10)
if (!Number.isNaN(n)) return n
}
return undefined
}
/**
* Parse a posted event body into an {@link EventInput}.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
*/
/**
* Why a parsed event body can't be stored, or `null` when it's fine.
*
* Length only. An event name is a title, not an identifier — "Building a Better Room
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
* names carry would be wrong here. Absent fields are skipped: an update posts only what
* it changes, and create defaults a missing name rather than refusing it.
*
* The name is measured AFTER trimming, matching what create/update actually store.
*/
export function eventInputRejection(input: EventInput): string | null {
const name = input.name?.trim()
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
}
if (
input.description !== undefined &&
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
) {
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
}
return null
}
/**
* Read the `Tags` a create/update body carries, or undefined when it carries none (an
* update that says nothing about tags leaves them alone; `[]` genuinely clears them).
*
* Both forms in circulation are accepted — a bare string (`"workshops"`) and the
* `{ tag, type }` object the notification frame carries — since the browse chips are
* plain names while the client's own event model pairs each with a category int. Tags
* are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops`
* are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped.
*/
function parseEventTags(raw: unknown): EventTag[] | undefined {
if (!Array.isArray(raw)) return undefined
const byTag = new Map<string, EventTag>()
for (const entry of raw) {
const source = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<
string,
unknown
>
const name = typeof entry === 'string' ? entry : (source.tag ?? source.Tag)
if (typeof name !== 'string') continue
const tag = name.trim().replace(/^#/, '').toLowerCase()
if (tag === '') continue
byTag.set(tag, { tag, type: asInt(source.type ?? source.Type) ?? 0 })
}
return [...byTag.values()]
}
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
string,
unknown
>
const has = (key: string): boolean => Object.hasOwn(obj, key)
// A nullable id: absent leaves it alone, an explicit null clears it.
const nullableInt = (key: string): number | null | undefined => {
if (!has(key)) return undefined
return obj[key] === null ? null : asInt(obj[key])
}
const time = (key: string): string | undefined => {
const raw = obj[key]
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
const bool = (key: string): boolean | undefined => {
const raw = obj[key]
if (typeof raw === 'boolean') return raw
if (raw === 'true') return true
if (raw === 'false') return false
return undefined
}
// The banner name: same absent/null distinction as the nullable ids.
const nullableString = (key: string): string | null | undefined => {
if (!has(key)) return undefined
if (obj[key] === null) return null
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
}
return {
tags: parseEventTags(obj.Tags ?? obj.tags),
imageName: nullableString('ImageName'),
roomId: asInt(obj.RoomId),
subRoomId: nullableInt('SubRoomId'),
clubId: nullableInt('ClubId'),
name: typeof obj.Name === 'string' ? obj.Name : undefined,
description: typeof obj.Description === 'string' ? obj.Description : undefined,
startTime: time('StartTime'),
endTime: time('EndTime'),
state: asInt(obj.State),
accessibility: asInt(obj.Accessibility),
isMultiInstance: bool('IsMultiInstance'),
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
}
}
/** How long an event runs when the body names a start but no end. */
const DEFAULT_DURATION_MS = 60 * 60 * 1000
/**
* Insert a new event, returning the stored record.
*
* Lenient about what the body carries, like the other writes here: an event with no
* name or no time window is defaulted rather than rejected, because a rejection the
* client can't render is worse than a placeholder the creator can edit. `State` starts
* at 0 (scheduled). The creator comes from the bearer token, never the body.
*
* The creator is recorded as Going in `event_attendee`, which is what makes
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
* needs a row there for the number to stay right once other players respond.
*/
export async function createEvent(
db: D1Database,
creatorPlayerId: number,
input: EventInput
): Promise<PlayerEvent> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
.first<{ next: number }>()
const now = Date.now()
const startTime = input.startTime ?? eventTime(now)
const event: PlayerEvent = {
PlayerEventId: row?.next ?? 1,
CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0,
SubRoomId: input.subRoomId ?? null,
ClubId: input.clubId ?? null,
Name: input.name?.trim() || 'Untitled Event',
Description: input.description ?? '',
StartTime: startTime,
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
AttendeeCount: 1,
State: input.state ?? 0,
Accessibility: input.accessibility ?? 1,
IsMultiInstance: input.isMultiInstance ?? false,
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
}
await db.batch([
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
])
// Tags ride along with the write but live in their own table — they are not part of
// the stored blob, since that blob is the DTO every read serves verbatim.
if (input.tags !== undefined) await setEventTags(db, event.PlayerEventId, input.tags)
return event
}
/**
* Record a player's answer to an event, replacing whatever they said before — one row
* per player per event, so changing your mind is an update rather than a second RSVP.
* The event's `AttendeeCount` is recomputed from the table afterwards.
*
* Returns the updated event, or null when there's no such event. Anyone who can see an
* event may respond to it, the creator included (they're already Going from create, and
* nothing stops them declining their own event).
*/
export async function setEventResponse(
db: D1Database,
eventId: number,
playerId: number,
status: number
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
await db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
)
.bind(eventId, playerId, status, eventTime(Date.now()))
.run()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return updated
}
/**
* Add invited players to an event as Going — 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.
*
* `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.
*
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
* still returns the event.
*/
export async function inviteToEvent(
db: D1Database,
eventId: number,
playerIds: number[]
): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
if (playerIds.length === 0) return { event, added: [] }
const at = eventTime(Date.now())
const inserts = await db.batch<EventAttendeeRow>(
playerIds.map((playerId) =>
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 NOTHING
RETURNING rowid AS id, *`
)
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
)
)
const added = inserts.flatMap((r) => r.results)
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return { event: updated, added }
}
/** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
const row = await db
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
.bind(eventId, EVENT_RESPONSE.going)
.first<{ going: number }>()
return row?.going ?? 0
}
/** One player's answer to one event, or null when they haven't responded. */
export async function getEventResponse(
db: D1Database,
eventId: number,
playerId: number
): Promise<EventAttendeeRow | null> {
return db
.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
.bind(eventId, playerId)
.first<EventAttendeeRow>()
}
/**
* Everyone who answered an event, in the order they responded — the guest list behind
* `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's
* own Going row shares its second with a fast first RSVP) break on the player id, so
* the order is stable.
*/
export async function getEventAttendees(
db: D1Database,
eventId: number
): Promise<EventAttendeeRow[]> {
const { results } = await db
.prepare(
`SELECT rowid AS id, * FROM event_attendee
WHERE event_id = ?1 ORDER BY responded_at, player_id`
)
.bind(eventId)
.all<EventAttendeeRow>()
return results
}
/** Overwrite an event's stored blob in place. */
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
await db
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(event), event.PlayerEventId)
.run()
}
/**
* Apply an edit to an event. Only the fields the body carried change; everything else
* keeps its stored value, so a partial post can't blank out the rest of the event.
* The id, the creator and the attendee count are not editable — ownership doesn't
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
* there's no such row.
*/
export async function updateEvent(
db: D1Database,
eventId: number,
input: EventInput
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
const updated: PlayerEvent = {
...event,
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
RoomId: input.roomId ?? event.RoomId,
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
Name: input.name?.trim() || event.Name,
Description: input.description ?? event.Description,
StartTime: input.startTime ?? event.StartTime,
EndTime: input.endTime ?? event.EndTime,
State: input.state ?? event.State,
Accessibility: input.accessibility ?? event.Accessibility,
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
SupportMultiInstanceRoomChat:
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions:
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions:
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
}
await writeEvent(db, updated)
// A body that says nothing about tags leaves them alone, like every other field
// here; an explicit `[]` clears them.
if (input.tags !== undefined) await setEventTags(db, eventId, input.tags)
return updated
}
/** One event by id, or null when there's no such row. */
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
const row = await db
.prepare('SELECT data FROM event WHERE id = ?1')
.bind(eventId)
.first<EventRow>()
return row ? (JSON.parse(row.data) as PlayerEvent) : null
}
/**
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
* (the client renders them in the order it requested), skipping ids with no row rather
* than leaving a hole. Duplicated ids resolve to the same event.
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
.bind(...ids)
.all<EventRow>()
const byId = new Map<number, PlayerEvent>()
for (const r of results) {
const event = JSON.parse(r.data) as PlayerEvent
byId.set(event.PlayerEventId, event)
}
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
}
/**
* The events a player created — their "my events" list, soonest first. Uses the
* creator_player_id index; the per-player set is small, so ordering is done in memory.
*/
export async function getEventsByCreator(
db: D1Database,
creatorPlayerId: number
): Promise<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
.bind(creatorPlayerId)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
* rather than every event.
*/
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
if (clubIds.length === 0) return []
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
.bind(...clubIds)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events happening right now — started and not yet finished. Backs the "happening
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
* columns, so the whole filter stays in SQL.
*/
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
const at = eventTime(now)
const { results } = await db
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
.bind(at)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/** Soonest start first; ties broken by id so paging is stable. */
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Event search — the browse query on the player-events screen. Term by term, an empty
* query browsing everything upcoming; paginated via skip/take, soonest first.
*
* A term is matched one of two ways, and the `#` decides which:
*
* - `#workshops` is a TAG term — it matches only an event tagged `workshops`, and never
* the word appearing in a name or description. That's what the browse screen's filter
* chips send.
* - `workshops` is a TEXT term, matched case-insensitively against the name and the
* description, as before.
*
* Every term has to match, and the two kinds combine: `#workshops trigonometry` is the
* workshops-tagged events whose text also mentions trigonometry.
*
* Events that have already finished are excluded: this backs a browse screen, where a
* name match on something that ended last month is noise. The per-event history a
* creator wants comes from `getEventsByCreator`, which keeps them.
*/
export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number
): Promise<PlayerEvent[]> {
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 `#`
// is dropped rather than treated as a tag nothing can carry.
const tags = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
const textTerms = terms.filter((t) => !t.startsWith('#'))
// end_time is a generated column of an ISO-8601 UTC string, so it compares
// lexicographically — that filter stays in SQL, and so does the tag one: an event
// has to carry EVERY tag asked for, which is the count of matching tag rows.
const wanted = tags.filter(Boolean)
const sql =
wanted.length === 0
? 'SELECT data FROM event WHERE end_time >= ?1'
: `SELECT data FROM event WHERE end_time >= ?1 AND (
SELECT COUNT(DISTINCT tag) FROM event_tag
WHERE event_tag.event_id = event.id
AND tag IN (${wanted.map((_, i) => `?${i + 2}`).join(', ')})
) = ${wanted.length}`
const { results } = await db
.prepare(sql)
.bind(eventTime(Date.now()), ...wanted)
.all<EventRow>()
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
for (const term of textTerms) {
events = events.filter(
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
)
}
return events.sort(bySoonest).slice(skip, skip + take)
}
+14 -5
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
* `null` when the request carries no valid token, which callers treat as a 401; an
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
*/
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
@@ -28,14 +38,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
.filter((n) => !Number.isNaN(n))
}
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
/** Read integer ids from repeated `id` query params. The 2023 client passes these to
* the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
export function queryIds(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
)
}
+187 -36
View File
@@ -1,8 +1,8 @@
/**
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/rooms/accounts tables use.
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
* the same JSON-blob pattern the image/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
* applied under its own `migrations_table`). The invention's data file itself is
@@ -12,19 +12,29 @@
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
* the nested `CurrentVersion` that carries the blob name and per-version costs —
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
* of a JSON `true` is 1, so the column is 1/0.
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
@@ -130,6 +140,37 @@ function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
}
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
function toBase64(bytes: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
}
/**
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
* upload time, so this is normally a HEAD with no body transfer; a blob stored
* before that (or by anything else) is downloaded and digested instead.
*
* Null when the blob isn't in the bucket — a metadata-only save names a file that
* was never uploaded, and a hash of nothing would be worse than the absent hash the
* field already allows for.
*/
export async function inventionBlobHash(
bucket: R2Bucket,
blobName: string
): Promise<string | null> {
const key = `invention/${inventionBlobName(blobName)}`
const head = await bucket.head(key)
if (head === null) return null
const recorded = head.checksums.sha256
if (recorded !== undefined) return toBase64(recorded)
const object = await bucket.get(key)
return object === null
? null
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
}
/**
* Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no
@@ -163,6 +204,7 @@ export interface NewInvention {
*/
export async function createInvention(
db: D1Database,
bucket: R2Bucket,
input: NewInvention
): Promise<SavedInvention> {
// Sequential id: one past the current max (the table starts empty).
@@ -171,6 +213,7 @@ export async function createInvention(
.first<{ next: number }>()
const inventionId = row?.next ?? 1
const now = new Date().toISOString()
const blobName = inventionBlobName(input.inventionDataFilename)
const invention: SavedInvention = {
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
@@ -183,8 +226,8 @@ export async function createInvention(
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
VersionNumber: 1,
BlobName: inventionBlobName(input.inventionDataFilename),
BlobHash: null,
BlobName: blobName,
BlobHash: await inventionBlobHash(bucket, blobName),
InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0,
ChipsCost: input.chipsCost ?? 0,
@@ -232,6 +275,74 @@ export async function getInventionsByCreator(
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
}
/**
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
* everything they BOUGHT. Ownership of a bought invention lives in the
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
* disjoint in practice and merged by id anyway.
*
* Bought inventions are returned whatever their state: unpublished or hidden since the
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
])
const bought = await getInventionsByIds(db, ownedIds)
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
}
/**
* Whether a player owns EVERY invention in a list — the `v1/fulllineageowner` check,
* which the client runs when saving an invention BUILT OUT OF other inventions: it is
* asking whether this player may use each piece. An invention is the player's if they
* created it (`CreatorPlayerId`) or acquired it (a row in `inventory_invention`); an id
* with no invention row is not owned, so a deleted or made-up id makes the whole answer
* false.
*
* Ownership is the whole test — price and `GeneralPermission` deliberately don't enter
* into it. A free invention still has to be picked up before it can be used, and econ's
* buyInvention writes the same inventory row for a 0-token acquisition as for a paid
* one, so "acquired" already covers "free". Reading permission here as a second way to
* qualify would let a player build on an invention they never took.
*
* The lineage is whatever the CLIENT asks about: it sends the invention plus every
* invention nested inside it as repeated `id`s, so this checks exactly the ids given
* and does not walk `ReferencedInventions` itself. Walking it here would answer a
* different question than the one asked — the client knows which pieces the thing it
* is holding is actually made of, and stale references on an old record don't.
*
* An empty list is owned: no invention in it is unowned. The client never asks that,
* but false would read as "you don't own something" with nothing to name.
*/
export async function ownsAllInventions(
db: D1Database,
playerId: number,
inventionIds: number[]
): Promise<boolean> {
if (inventionIds.length === 0) return true
// The client repeats an id when the same invention is nested more than once.
const unique = [...new Set(inventionIds)]
const [inventions, ownedIds] = await Promise.all([
getInventionsByIds(db, unique),
getOwnedInventionIds(db, playerId),
])
const bought = new Set(ownedIds)
const creators = new Map(inventions.map((i) => [i.InventionId, i.CreatorPlayerId]))
return unique.every((id) => creators.get(id) === playerId || (creators.has(id) && bought.has(id)))
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
@@ -271,50 +382,73 @@ export async function searchInventions(
* ones via the indexed `is_featured` column.
*/
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
// All three are generated columns off the JSON blob, so the filter stays in SQL.
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0
WHERE is_published = 1
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
/** Length of the "today" window — a trailing day, not the calendar one. */
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
function startOfWindow(): string {
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
* The "top today" feed — the inventions other players picked up in the last 24 hours,
* most first.
*
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
* purchase time, grouped by invention, rather than from the lifetime counters on the
* invention itself: those never reset, so "top today" used to mean "top ever" and the
* shelf only changed when something overtook a total built up over months.
*
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
* itself at midnight UTC and slowly refill through the small hours — it always covers a
* full day's worth of activity. It is still genuinely a window: an invention nobody has
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
* nothing while nothing is curated.
*
* An acquired invention that has since been unpublished or hidden drops out: this is a
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
* that filtering, so a hidden invention doesn't leave a hole in a page.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
if (counts.length === 0) return []
// getInventionsByIds answers in the order it is asked, so the ranking survives the
// load; ids with no invention row left (deleted) simply drop out.
const ranked = await getInventionsByIds(
db,
counts.map((c) => c.inventionId)
)
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
}
/**
* The featured feed — published inventions flagged `IsFeatured`, newest first.
* Selected on the indexed `is_featured` column rather than by parsing every public
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
* than handing the client an empty shelf; once inventions are curated it serves them.
* invention.
*
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
* standing in the top feed. It used to fall back, from when no invention could be
* featured at all, but a fallback makes the shelf lie — the client labels these as
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
* has picked anything.
*/
export async function getFeaturedInventions(
db: D1Database,
@@ -322,7 +456,6 @@ export async function getFeaturedInventions(
take: number
): Promise<SavedInvention[]> {
const featured = await publicInventions(db, true)
if (featured.length === 0) return getTopInventions(db, skip, take)
return featured
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
@@ -546,8 +679,8 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0`
AND is_published = 1
AND hide_from_player = 0`
)
.bind(roomId)
.all<InventionRow>()
@@ -568,20 +701,38 @@ export async function getInventionsByRoom(
*/
export async function getInventionVersion(
db: D1Database,
bucket: R2Bucket,
inventionId: number,
versionNumber: number
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
if (invention.CurrentVersionNumber !== versionNumber) return null
// A version saved before its blob finished uploading (or before we hashed on
// save at all) carries no hash. Hash it now and keep the result, so the other
// invention endpoints serve it too and this stays a one-time cost per blob.
// ModifiedAt is deliberately left alone: reading a version is not an edit.
if (invention.CurrentVersion.BlobHash === null) {
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
if (hash !== null) {
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
await storeInvention(db, invention)
}
}
return invention.CurrentVersion
}
/** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
}
/** Write a record back as it stands — for changes that aren't edits (see above). */
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId)
.bind(JSON.stringify(invention), invention.InventionId)
.run()
}
+761
View File
@@ -0,0 +1,761 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the api worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
}
/** A string path parameter. */
export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'string' } }
}
/** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
}
/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
/** The `skip`/`take` pair every paginated feed accepts. */
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
return [
intQuery('skip', 'How many entries to skip (default 0)'),
intQuery('take', `How many entries to return (default ${defaultTake})`),
]
}
// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static config blobs (the game configs, the charades word
// list) or empty-list stubs. Modelling every field adds noise without value, so these
// use deliberately loose schemas.
/** An opaque JSON object (a static config blob, a stub, …). */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */
export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
export const BareBoolean = z.boolean()
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()
/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })
/**
* The `{ success, error }` envelope the report / warning writes and the message send
* answer with — `error` is an empty string on success, never null, and the rejected
* branches use the same shape so there is only one thing to parse.
*/
export const SuccessErrorEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty string when the call succeeded'),
})
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(),
StatSigKey: z.string(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(),
})
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
export const AzureSpeechConfig = z.object({
Key: z.string(),
Region: z.string(),
Enabled: z.boolean(),
})
/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */
export const BacktraceConfig = z.object({
ReportBudget: z.int(),
FilterType: z.int(),
SampleRate: z.int(),
LogLineCount: z.int(),
CaptureNativeCrashes: z.int(),
AMRThresholdMS: z.int(),
MessageCount: z.int(),
MessageRegex: z.string(),
VersionRegex: z.string(),
})
/**
* `GET /api/config/v2` — the big client config blob (a static asset), with
* `ShareBaseUrl` derived from the deploy-time base domain.
*/
export const ApiConfigV2 = JsonObject.describe(
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
)
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
export const VersionCheck = z.object({
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
UpdateNotificationStage: z.int(),
IsVersionIslanded: z.boolean(),
IsCrossPlayDisabled: z.boolean(),
})
// ---- Social ----------------------------------------------------------------
/**
* The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the
* OTHER player; the type and flags are taken from the caller's own side of the row, so
* the two players in a pair see different projections of it.
*/
export const RelationshipDto = z.object({
PlayerID: z.int().describe('The other player in the pair'),
RelationshipType: z
.int()
.describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'),
Favorited: z.int().describe('0/1 — the callers own flag'),
Ignored: z.int().describe('0/1 — the callers own flag'),
Muted: z.int().describe('0/1 — the callers own flag'),
})
/**
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
* taken from the bearer token.
*/
export const SendMessageRequest = z.object({
ToPlayerId: z.string().describe('Account id of the recipient'),
Type: z
.string()
.optional()
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/**
* `POST /api/messages/v1/sendMultiple` JSON body — the same message fanned out to
* several recipients. Unlike the form-encoded single send, this one is real JSON, so
* `Type` arrives as a number and `ToPlayerIds` as an array of numbers. The sender is
* still taken from the bearer token, not the body.
*/
export const SendMultipleMessagesRequest = z.object({
ToPlayerIds: z.array(z.int()).describe('Account ids of the recipients'),
Type: z
.int()
.optional()
.describe('The Message-model type, e.g. `20`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
/**
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
* A trimmed account card, not a relationship: no relationship type or flags.
*/
export const MutualFriendDto = z.object({
AccountId: z.int(),
Username: z.string(),
DisplayName: z.string(),
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})
// ---- Progression -----------------------------------------------------------
/**
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
* may hand out cheers at all.
*/
export const ReputationDto = z.object({
AccountId: z.int(),
IsCheerful: z.boolean(),
Noteriety: z.int(),
SelectedCheer: z.int().describe('0 = none selected'),
CheerCredit: z.int(),
CheerGeneral: z.int(),
CheerHelpful: z.int(),
CheerCreative: z.int(),
CheerGreatHost: z.int(),
CheerSportsman: z.int(),
SubscriberCount: z.int(),
SubscribedCount: z.int(),
})
/** A player's level/XP (`/api/players/v1/progression/:id`). */
export const ProgressionDto = z.object({
PlayerId: z.int(),
Level: z.int(),
XP: z.int(),
})
/** The `Ids` form body the bulk POST endpoints take. */
export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})
// ---- Inventions ------------------------------------------------------------
/** One version of an invention — carries the blob name the client downloads. */
export const InventionVersionDto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage workers bucket'),
BlobHash: z
.string()
.nullable()
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
AICost: z.int(),
})
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
export const InventionTagDto = z.object({
Tag: z.string(),
Type: z.int().describe('0 = custom, 2 = auto'),
})
/** A stored invention record (the reference's `RRInvention`). */
export const InventionDto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
CreatorPlayerId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
CurrentVersionNumber: z.int(),
CurrentVersion: InventionVersionDto,
Accessibility: z.int(),
IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'),
IsFeatured: z.boolean(),
ModifiedAt: z.string(),
CreatedAt: z.string(),
FirstPublishedAt: z.string().nullable(),
CreationRoomId: z.int(),
NumPlayersHaveUsedInRoom: z.int(),
NumDownloads: z.int(),
CheerCount: z.int(),
CreatorPermission: z.int(),
GeneralPermission: z.int().describe('What other players may do with it once published'),
IsAGInvention: z.boolean(),
IsCertifiedInvention: z.boolean(),
Price: z.int(),
AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()),
Tags: z
.array(InventionTagDto)
.optional()
.describe('Unset on save — the real RRInvention carries no Tags field'),
})
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
export const InventionSaveResult = z.object({
Status: z.int().describe('0 = success'),
Invention: InventionDto,
InventionVersion: InventionVersionDto,
})
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({
PinnedFilters: z.array(z.string()),
PopularFilters: z.array(z.string()),
TrendingFilters: z
.array(z.string())
.nullable()
.describe('Null — needs recent-activity data we dont keep'),
})
/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */
export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
export const InventionPersonalDetails = z.object({
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
})
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
InventionId: z.int(),
AutoTags: z
.array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
export const SetTagsResponse = z.object({
Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
Price: z.int().describe('Must be >= 0'),
})
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
export const SaveInventionRequest = z.object({
inventionDataFilename: z
.string()
.describe('The blob uploaded through the storage worker; the one required field'),
name: z
.string()
.optional()
.describe('324 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(),
instantiationCost: z.int().optional(),
lightsCost: z.int().optional(),
chipsCost: z.int().optional(),
cloudVariablesCost: z.int().optional(),
aiCost: z.int().optional(),
creationRoomId: z.int().optional(),
referencedInventions: z.array(z.int()).optional(),
})
// ---- Avatar / custom avatar items ------------------------------------------
/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */
export const GeneratedGift = z.object({
Id: z.int().describe('Always 0 — gifts generated here are not persisted'),
FromPlayerId: z.int(),
ConsumableItemDesc: z.string(),
AvatarItemDesc: z.string(),
FriendlyName: z.string(),
AvatarItemType: z.int(),
EquipmentPrefabName: z.string(),
EquipmentModificationGuid: z.string(),
CurrencyType: z.int(),
Currency: z.int().describe('A random token amount'),
Xp: z.int(),
Level: z.int(),
Platform: z.int(),
PlatformsToSpawnOn: z.int(),
BalanceType: z.int(),
GiftContext: z.int(),
GiftRarity: z.int(),
Message: z.string(),
})
/** `POST /api/avatar/v2/gifts/generate` form body. */
export const GenerateGiftRequest = z.object({
GiftContext: z.string().optional().describe('Where the gift was earned'),
Message: z.string().optional(),
Xp: z.string().optional(),
})
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
TotalResults: z.int(),
})
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
// ---- Gameplay --------------------------------------------------------------
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
export const SanitizeRequest = z.object({ Value: z.string() })
/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
KeepsakeRoomLimit: z.int(),
SocialXpBoostEnabled: z.boolean(),
})
/**
* `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set
* rather than the bare list the stubs around it serve. Empty until a catalog exists.
*/
export const KeepsakeCategories = z.object({
Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'),
TotalResults: z.int().describe('How many results `Results` carries'),
})
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
* echoed as the client sends them; their enums aren't reversed yet.
*/
export const PlayerEventDto = z.object({
PlayerEventId: z.int(),
CreatorPlayerId: z.int(),
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
RoomId: z.int(),
SubRoomId: z.int().nullable().describe('Null when the event doesnt pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isnt a clubs'),
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
IsMultiInstance: z.boolean(),
SupportMultiInstanceRoomChat: z.boolean(),
DefaultBroadcastPermissions: z.int(),
CanRequestBroadcastPermissions: z.int(),
})
/**
* `GET /api/playerevents/v1/:eventId?includeDetails=True` — the record plus the one
* field the flag adds: the LOWERCASE `tags`, in an otherwise PascalCase record. Always
* empty, since no event tags are stored; the key is absent altogether when the flag
* isn't passed. The entry shape is the one the notification projection declares.
*/
export const PlayerEventDetailsDto = PlayerEventDto.extend({
tags: z
.array(z.object({ tag: z.string(), type: z.int() }))
.optional()
.describe('Present only with `includeDetails=True`, and always empty'),
})
/**
* `GET /api/playerevents/v1` — the browse feed's listing. The same record minus
* `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an
* event yet). That's the shape observed on this endpoint; the other reads serve the
* stored record verbatim, so don't unify the two.
*/
export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({
BroadcastingRoomInstanceId: z
.int()
.nullable()
.describe('Always null — no event broadcasts to a room instance yet'),
})
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z
.null()
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
PlayerEvent: PlayerEventDto,
})
/**
* The JSON body of an event create / update. Every field is optional: create defaults
* what's missing, update leaves anything absent at its stored value. The fields may be
* posted at the top level or nested under `PlayerEvent` — the client posts back the
* same envelope it read — and both forms are accepted. `PlayerEventId`,
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
*/
export const PlayerEventRequest = PlayerEventDto.partial().extend({
PlayerEvent: z
.unknown()
.optional()
.describe('The events fields, if nested rather than posted at the top level'),
})
/**
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
* the guest list serves it.
*/
export const PlayerEventResponseDto = z.object({
PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'),
PlayerEventId: z.int(),
PlayerId: z.int(),
CreatedAt: z
.string()
.describe(
'When the answer that stands was given — a changed answer updates the row, so this ' +
'moves with it rather than recording the players first response'
),
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
PlayerEventId: z.int(),
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/**
* `POST /api/playerevents/v1/report` JSON body — a report against an event. JSON, note,
* where the player report next to it is form-encoded. The reporter is NOT in the body:
* it's the bearer token's player.
*/
export const PlayerEventReportRequest = z.object({
PlayerEventId: z.int().describe('The event being reported'),
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI, e.g. `101`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
PlayerEventId: z.int(),
InvitedPlayerIds: z
.array(z.int())
.describe('Ids to invite; duplicates and the caller are ignored'),
})
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
Responses: JsonArray.describe(
'Events the caller RSVPd to — always empty; RSVPs are stored, but this fields ' +
'entry shape has not been observed yet'
),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
ContinuationToken: z.string().describe('Empty = no next page'),
Events: JsonArray,
})
// ---- Moderation ------------------------------------------------------------
/**
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
* which is a real category; `Message` is null, not an empty string — the client
* distinguishes "no message" from a blank one.
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
Duration: z.int(),
GameSessionId: z.int(),
IsBan: z.boolean(),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable(),
PlayerIdReporter: z.int().nullable(),
TimeoutStartedAt: z.string().nullable(),
})
/**
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
* reporter is NOT in the body — it's taken from the bearer token.
*/
export const CreateReportRequest = z.object({
PlayerIdReported: z.string().describe('Account id of the player being reported'),
ReportCategory: z
.string()
.optional()
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
HeightReporter: z
.string()
.optional()
.describe('Reporters player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported players height in metres'),
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
RoomInstanceType: z
.string()
.optional()
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
* moderator is NOT in the body — it's taken from the bearer token.
*/
export const CreateWarningRequest = z.object({
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
ReportCategory: z
.string()
.optional()
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
DisplayReason: z
.string()
.optional()
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
newDeviceId: z.string().optional(),
platform: z.string().optional(),
})
// ---- Rooms -----------------------------------------------------------------
/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */
export const QuickPlayResponse = z.object({
RoomName: z.string().nullable(),
ActionCode: z.string().nullable(),
TargetPlayerId: z.int().nullable(),
})
/** `POST /api/rooms/v1/verifyRole` form body. */
export const VerifyRoleRequest = z.object({
roomId: z.string(),
role: z.string().describe('The minimum role level required'),
context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'),
})
// ---- Images ----------------------------------------------------------------
/**
* A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`)
* serves this shape raw, while the player lists serve the `ImagesPlayer` projection
* below — deliberately different, see the client-contract notes in CLAUDE.md.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
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
* MUST serve this: the raw SavedImage renders blank thumbnails.
*/
export const ImagesPlayerDto = z.object({
SavedImageId: z.int(),
SavedImageType: z.int(),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
CheerCount: z.int(),
CommentCount: z.int(),
CreatedAt: z.string(),
Description: z.string().nullable(),
ImageName: z.string(),
PlayerEventId: z.int().nullable(),
PlayerId: z.int(),
RoomId: z.int().nullable(),
})
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
SavedImageId: z.int(),
ImageName: z.string(),
Username: z.string(),
RoomName: z.string().nullable(),
RoomId: z.int().nullable(),
SavedImageType: z.int(),
PlayerEventId: z.int().nullable(),
Accessibility: z.int(),
PlayerIds: z.array(z.int()),
})
/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */
export const SlideshowResponse = z.object({
Images: z.array(SlideshowImageDto),
ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'),
})
/** `POST /api/images/v4/uploadsaved` multipart body. */
export const UploadImageRequest = z.object({
image: z.string().describe('The image file (`file` is accepted too)'),
imgMeta: z
.string()
.optional()
.describe(
'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }'
),
})
/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */
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. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })
/** `POST /api/images/v1/cheer` JSON body. */
export const CheerImageRequest = z.object({
SavedImageId: z.int(),
Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
})
/** The bare `{ success: true }` ack the image writes answer with. */
export const SuccessResponse = z.object({ success: z.boolean() })
/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */
export const CheeredEntry = z.object({
SavedImageId: z.int(),
IsCheered: z.boolean(),
})
+201
View File
@@ -0,0 +1,201 @@
/**
* Player-report storage on the shared `recflare` D1 database.
*
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
* accounts / image / invention), a report is genuinely columnar, so it gets a
* normal relational table. Rows are append-only in the sense that nothing rewrites
* 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 and 0011_report_event.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share
* the database).
*
* A reported player EVENT lands here too, rather than in a table of its own: same
* fields, same moderation life. Such a row carries `event_id`, and its
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
*/
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT,
event_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)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
]
/** A stored report row (snake_case columns, one row per submission). */
export interface ReportRow {
id: number
reporter_player_id: number
reported_player_id: number
report_category: number
details: string | null
/** Player height in metres, as the client measured it at report time. */
height_reporter: number | null
height_reported: number | null
room_id: number | null
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
room_instance_type: string | null
created_at: string
/** 1 when a moderator turned this report into a ban of `reported_player_id`. */
banned: number
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
ban_expires: string | null
/**
* The player event this report is against, or NULL for an ordinary player report —
* which is what tells the two kinds apart. See `POST /api/playerevents/v1/report`:
* `reported_player_id` and `room_id` are filled in from the event itself.
*/
event_id: number | null
}
/**
* A report as submitted — everything but the reporter (which comes from the bearer
* token) and the timestamp. Only the reported player is required; the client omits
* fields it has no value for (a report raised outside a room carries no `RoomId`),
* so the rest are optional and stored as NULL when absent.
*/
export interface NewReport {
reporterPlayerId: number
reportedPlayerId: number
reportCategory?: number
details?: string | null
heightReporter?: number | null
heightReported?: number | null
roomId?: number | null
roomInstanceType?: string | null
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
eventId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
const row = await db
.prepare(
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
RETURNING *`
)
.bind(
input.reporterPlayerId,
input.reportedPlayerId,
input.reportCategory ?? 0,
input.details ?? null,
input.heightReporter ?? null,
input.heightReported ?? null,
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString(),
input.eventId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every report filed against a player, newest first. Backs a future moderation view. */
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<ReportRow>()
return results
}
/**
* The ban currently in force against a player, or null when they aren't banned.
*
* "In force" is narrower than `banned = 1`: a row whose `ban_expires` has passed is a
* ban that has SERVED ITS TIME, and the player is let back in without anyone having to
* go and clear the flag — the row stays as the record that it happened. A permanent ban
* carries no expiry at all (NULL), which is why that arm is checked separately rather
* than by comparing against some far-future date.
*
* When several bans are in force, the longest-lasting one wins: permanent first (NULL
* sorts ahead because `ban_expires IS NOT NULL` is 0 for it), then the latest expiry. So
* a fresh short ban can never shorten a standing one.
*/
export async function getActiveBan(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<ReportRow | null> {
return db
.prepare(
`SELECT * FROM report
WHERE reported_player_id = ?1 AND banned = 1
AND (ban_expires IS NULL OR ban_expires > ?2)
ORDER BY ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
)
.bind(playerId, now.toISOString())
.first<ReportRow>()
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
*/
export async function isPlayerBanned(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<boolean> {
return (await getActiveBan(db, playerId, now)) !== null
}
/**
* Turn a report into a ban of the player it was filed against — the moderator action the
* `banned` column exists for. `banExpires` is an ISO-8601 UTC instant, or null for a
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* 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).
*/
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null } = {}
): Promise<ReportRow | null> {
const banned = options.banned ?? true
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)
.first<ReportRow>()
}
+713 -216
View File
@@ -1,17 +1,25 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getMyInventions,
getTopInventions,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
@@ -20,6 +28,34 @@ import {
toSaveResult,
updateInvention,
} from '../inventions-db'
import {
AUTHED,
BareBoolean,
CustomAvatarItemsPage,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionDetails,
InventionDto,
InventionPersonalDetails,
InventionSaveResult,
InventionVersionDto,
json,
JsonArray,
jsonBody,
pageParams,
SaveInventionRequest,
SetTagsRequest,
SetTagsResponse,
stringQuery,
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdatePriceRequest,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
@@ -47,203 +83,503 @@ async function creatorsInvention(
return { invention }
}
/**
* The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each
* value may itself be a comma-separated list; anything non-numeric is dropped.
*/
function inventionIdQuery(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id)) ?? []
)
}
// ---- Avatar gifts ----------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
// gift-box consume live in the `econ` worker, which the client calls on the econ host
// — not here. Only the gift `generate` action remains on this worker.
export const avatarRoutes = new Hono<App>({ strict: false })
.post('/api/avatar/v2/gifts/generate', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/api/avatar/v2/gifts/generate',
describeRoute({
tags: ['Avatar'],
summary: 'Generate a gift box',
description:
'Mint the gift box a player earned (levelling up, a room reward). With no ' +
'EarnableRewards catalog wired up this always falls back to a token gift of a ' +
'random amount, and the box is not persisted — its `Id` is 0 and it cannot be ' +
'opened through the `econ` workers consume endpoint.',
security: AUTHED,
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
responses: {
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
})
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
}
)
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet; we enable them. Flip to `false` to disable the
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
// success/value envelope; the other two return a bare JSON boolean.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) =>
c.json({ success: true, value: null })
.get(
'/api/customAvatarItems/v1/isCreationAllowedForAccount',
describeRoute({
tags: ['Avatar'],
summary: 'May this account create custom items?',
description:
'A feature gate with no backing implementation — we answer yes. Note this one ' +
'wraps its answer in the `{ success, value }` envelope while the two gates below ' +
'return a bare boolean.',
responses: { 200: json(SuccessValueEnvelope, 'Allowed') },
}),
(c) => c.json({ success: true, value: null })
)
.get(
'/api/customAvatarItems/v1/isCreationEnabled',
describeRoute({
tags: ['Avatar'],
summary: 'Is custom-item creation enabled?',
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
responses: { 200: json(BareBoolean, 'A bare `true`') },
}),
(c) => c.json(true)
)
.get(
'/api/customAvatarItems/v1/isRenderingEnabled',
describeRoute({
tags: ['Avatar'],
summary: 'Is custom-item rendering enabled?',
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
responses: { 200: json(BareBoolean, 'A bare `true`') },
}),
(c) => c.json(true)
)
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// The featured custom-avatar-item feed. No curated items yet → an empty list.
.get('/api/customAvatarItems/v1/featured', (c) => c.json([]))
.get(
'/api/customAvatarItems/v1/featured',
describeRoute({
tags: ['Avatar'],
summary: 'Featured custom avatar items',
description: 'The curated feed. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
.get('/api/customAvatarItems/v1/hot', (c) => c.json([]))
.get(
'/api/customAvatarItems/v1/hot',
describeRoute({
tags: ['Avatar'],
summary: 'Trending custom avatar items',
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
c.json({ Results: [], TotalResults: 0 })
.get(
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
describeRoute({
tags: ['Avatar'],
summary: 'A creators custom avatar items',
description:
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
'empty page — in the same shape as the `econ` workers `customAvatarItems/v1/owned`.',
parameters: [idParam('accountId', 'Creator account id')],
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention.
.get('/api/inventions/v1', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const invention = await getInventionById(c.env.DB, inventionId)
return invention ? c.json(invention) : c.notFound()
})
.get(
'/api/inventions/v1',
describeRoute({
tags: ['Inventions'],
summary: 'One invention by id',
description: 'The stored `RRInvention`. Public — an unpublished invention is served too.',
parameters: [intQuery('inventionId', 'Invention id; required')],
responses: {
200: json(InventionDto, 'The invention'),
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const invention = await getInventionById(c.env.DB, inventionId)
return invention ? c.json(invention) : c.notFound()
}
)
// The tag filter chips on the invention browse screen. Derived from the tags in
// use on published inventions — most popular first, top few pinned. Public.
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
.get(
'/api/inventions/v1/tagfilters',
describeRoute({
tags: ['Inventions'],
summary: 'Invention browse filter chips',
description:
'The filter chips on the invention browse screen, derived from the tags actually in ' +
'use on published inventions — most popular first, the top few pinned. ' +
'`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' +
'client treats null as absent.',
responses: { 200: json(TagFilters, 'The chips in use') },
}),
async (c) => c.json(await getInventionTagFilters(c.env.DB))
)
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
// request is an empty list. Auth is optional and only widens what you see: an
// unpublished invention comes back only to its creator. Bare array.
.get('/api/inventions/v2/batch', async (c) => {
const ids = c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id))
if (ids === undefined || ids.length === 0) return c.json([])
.get(
'/api/inventions/v2/batch',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by id, in bulk',
description:
'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' +
'and an empty request is an empty list. Auth is optional and only widens what you ' +
'see: an unpublished invention comes back only to its creator.',
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
}),
async (c) => {
const ids = inventionIdQuery(c)
if (ids.length === 0) return c.json([])
const playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
return c.json(
inventions.filter(
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
const playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
return c.json(
inventions.filter(
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
)
)
)
})
}
)
// Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) —
// the invention plus everything nested inside it, as the client enumerates it. One
// bare `true`/`false` for the whole set, not a verdict per id. Auth-gated: the
// question is about the caller.
.get(
'/api/inventions/v1/fulllineageowner',
describeRoute({
tags: ['Inventions'],
summary: 'Does the caller own this whole lineage?',
description:
'Asked when saving an invention built out of other inventions: may this player use ' +
'every piece? The client sends the whole lineage as repeated `id`s, and this ' +
'answers a single bare `true`/`false` for the set — false as soon as one is not the ' +
'callers. An invention is theirs if they created it or acquired it; an id with no ' +
'invention behind it is not owned. Price and permission dont enter into it — a ' +
'free invention still has to be picked up, and that writes the same inventory row ' +
'a paid one does.\n\n' +
'Only the ids asked about are checked — this does not walk `ReferencedInventions` ' +
'to widen the lineage, since the client knows what the thing it is holding is ' +
'actually made of. No ids at all is `true`: nothing in an empty lineage is unowned.',
security: AUTHED,
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
responses: {
200: json(BareBoolean, 'Whether the caller owns every invention asked about'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return unauthorized(c)
return c.json(await ownsAllInventions(c.env.DB, playerId, inventionIdQuery(c)))
}
)
// A room's inventions (`?id=76`) — published inventions created in that room,
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/inventions/v1/room', async (c) => {
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
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 getInventionsByRoom(c.env.DB, roomId, skip, take))
})
.get(
'/api/inventions/v1/room',
describeRoute({
tags: ['Inventions'],
summary: 'A rooms inventions',
description: 'Published inventions created in that room, newest first.',
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
responses: {
200: json(InventionDto.array(), 'The rooms inventions'),
400: json(ErrorResponse, 'Missing or non-numeric id'),
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
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 getInventionsByRoom(c.env.DB, roomId, skip, take))
}
)
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// — just whether they're cheering it. We store no cheers (nothing can cheer an
// invention yet), so this is always false; it stays a 200 for signed-out callers
// too, since the client only reads the flag.
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
c.json({ IsCheering: false })
.get(
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own relation to an invention',
description:
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
'flag.',
parameters: [idParam('inventionId', 'Invention id')],
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
}),
(c) => c.json({ IsCheering: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public.
// Only the current version exists (nothing writes version history yet), so any
// other version number 404s rather than naming a blob that isn't there.
.get('/api/inventions/v1/version', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
// RRInventionVersion, which carries the blob name the client downloads and the
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
// version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get(
'/api/inventions/v1/version',
describeRoute({
tags: ['Inventions'],
summary: 'One version of an invention',
description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'),
],
responses: {
200: json(InventionVersionDto, 'The version'),
400: json(ErrorResponse, 'Missing inventionId or version'),
404: { description: 'No such invention, or not the current version' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
return version === null ? c.notFound() : c.json(version)
})
// Edit an invention's metadata. A GET that writes — that's what the client sends
// (`?inventionId=1&description=my+description`), with the fields to change as
// query params. Absent params keep their stored value; `permission` sets what
// other players may do with it (a name like `useonly` or the raw number). An
// empty `description` clears it, but an empty `name`/`imageName` is ignored
// rather than blanking the invention. Publishing and pricing are separate
// endpoints. Auth-gated, creator only; answers the save envelope.
.get('/api/inventions/v1/update', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
// Query params arrive as strings; only the ones actually present are applied.
const nonEmpty = (name: string): string | undefined => {
const v = c.req.query(name)?.trim()
return v === undefined || v === '' ? undefined : v
const version = await getInventionVersion(
c.env.DB,
c.env.CDN_ASSETS,
inventionId,
versionNumber
)
return version === null ? c.notFound() : c.json(version)
}
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
? undefined
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
})
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
// GET that writes in some places and as a bodyless POST in others (the permission
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
// neither reads a body. Absent params keep their stored value; `permission` sets
// what other players may do with it. An empty `description` clears it, but an empty
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
.on(
['GET', 'POST'],
'/api/inventions/v1/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an inventions metadata',
description:
'GET or POST — the client sends both, and the fields to change ride as query ' +
'params either way; no body is read. Absent params keep their stored value. An ' +
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
'than blanking the invention. A supplied name/description must satisfy the same ' +
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('name', '324 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery(
'permission',
'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' +
'`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' +
'or the raw number is accepted'
),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
// Query params arrive as strings; only the ones actually present are applied.
const nonEmpty = (name: string): string | undefined => {
const v = c.req.query(name)?.trim()
return v === undefined || v === '' ? undefined : v
}
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name,
// Present-but-empty clears the description, so this checks presence.
description,
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
? undefined
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
})
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
}
)
// Publish an invention — this is what puts it into search and the feeds. Sets the
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
// `price`. Auth-gated, creator only; answers the save envelope.
.get('/api/inventions/v3/publish', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
.get(
'/api/inventions/v3/publish',
describeRoute({
tags: ['Inventions'],
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.',
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'),
],
responses: {
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
return published === null ? c.notFound() : c.json(toSaveResult(published))
})
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
return published === null ? c.notFound() : c.json(toSaveResult(published))
}
)
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
// Auth-gated, creator only; answers the save envelope.
.post('/api/inventions/v1/updateprice', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/updateprice',
describeRoute({
tags: ['Inventions'],
summary: 'Set an inventions price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected.',
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'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
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)
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)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
}
)
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
@@ -251,69 +587,191 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
// objects are what `v1/details` serves.
.post('/api/inventions/v1/settags', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/settags',
describeRoute({
tags: ['Inventions'],
summary: 'Replace an inventions tags',
description:
'`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' +
'Every tag in either list must be at most 15 letters (az once lowercased); one ' +
'that isnt fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: {
200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
})
const autoTags = strings(body.AutoTags)
const customTags = strings(body.CustomTags)
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
// than being dropped — a silently missing tag looks to the creator like a tag that
// saved. Checked against the normalized form `setInventionTags` will store, so the
// rejection quotes the tag as it would have been stored, not as it was typed.
// Blanks are skipped, not rejected: the store already drops them, and the client
// pads its list with empties.
for (const raw of [...autoTags, ...customTags]) {
const tag = raw.trim().toLowerCase()
if (tag === '') continue
const rejection = inventionTagRejection(tag)
if (rejection !== null) {
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
}
}
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
autoTags,
customTags
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
}
)
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
// Untagged inventions report an empty list. 404s on unknown ids.
.get('/api/inventions/v1/details', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const tags = await getInventionTags(c.env.DB, inventionId)
return tags === null ? c.notFound() : c.json({ Tags: tags })
})
.get(
'/api/inventions/v1/details',
describeRoute({
tags: ['Inventions'],
summary: 'An inventions detail card',
description:
'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' +
'invention reports an empty list.',
parameters: [intQuery('inventionId', 'Invention id; required')],
responses: {
200: json(InventionDetails, 'The inventions tags'),
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const tags = await getInventionTags(c.env.DB, inventionId)
return tags === null ? c.notFound() : c.json({ Tags: tags })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
.get('/api/inventions/v1/toptoday', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getTopInventions(c.env.DB, skip, take))
})
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by how many players acquired them in the last 24 ' +
'hours, counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a window: an invention nobody has picked up ' +
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
'empty list. It trails the clock rather than resetting at midnight.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getTopInventions(c.env.DB, skip, take))
}
)
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
.get('/api/inventions/v1/featured', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
})
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
.get(
'/api/inventions/v1/featured',
describeRoute({
tags: ['Inventions'],
summary: 'The featured feed',
description:
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
'Serves an empty list while nothing is flagged rather than standing in the top ' +
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getFeaturedInventions(c.env.DB, 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.
.get('/api/inventions/v2/search', async (c) => {
const value = c.req.query('value') ?? ''
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 searchInventions(c.env.DB, value, skip, take))
})
.get(
'/api/inventions/v2/search',
describeRoute({
tags: ['Inventions'],
summary: 'Search / browse inventions',
description:
'Published inventions matching `value` (matched against name and description), ' +
'newest first. An absent `value` browses everything published — that is the ' +
'browse screens initial request.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
}),
async (c) => {
const value = c.req.query('value') ?? ''
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 searchInventions(c.env.DB, value, skip, take))
}
)
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
.get('/api/inventions/v2/mine', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getInventionsByCreator(c.env.DB, id))
})
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
// afterwards: unpublished or hidden since, the buyer paid for it.
// Auth-gated; returns a bare array (empty when the player has neither).
.get(
'/api/inventions/v2/mine',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own inventions',
description:
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The callers inventions'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getMyInventions(c.env.DB, id))
}
)
// Save an invention's metadata. The data file itself is uploaded separately
// through the `storage` worker and referenced here by `inventionDataFilename` —
@@ -321,36 +779,75 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
// invention carries its assigned inventionId).
.post('/api/inventions/v6/save', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/api/inventions/v6/save',
describeRoute({
tags: ['Inventions'],
summary: 'Save a new invention',
description:
'Records an inventions metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected; a supplied one must be 324 ' +
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
'characters (description).\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
})
return c.json(toSaveResult(invention))
}
const invention = await createInvention(c.env.DB, {
creatorPlayerId: id,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
})
return c.json(toSaveResult(invention))
})
)
+120 -37
View File
@@ -1,56 +1,139 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION } from '@repo/domain'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
import {
AmplitudeConfig,
ApiConfigV2,
AzureSpeechConfig,
BacktraceConfig,
json,
JsonObject,
VersionCheck,
} from '../openapi'
import type { App } from '../context'
// ---- Config / version ------------------------------------------------------
export const configRoutes = new Hono<App>({ strict: false })
.get('/api/config/v1/amplitude', (c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
.get(
'/api/config/v1/amplitude',
describeRoute({
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
'is off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}),
(c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
)
.get('/api/config/v1/azurespeech', (c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
.get(
'/api/config/v1/azurespeech',
describeRoute({
tags: ['Config'],
summary: 'Speech-to-text config',
description:
'Azure Speech credentials for the clients voice transcription. `Enabled` is false ' +
'here, so the key and region are never used.',
responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') },
}),
(c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get('/api/config/v1/backtrace', (c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^.*$",
VersionRegex: '.*',
})
.get(
'/api/config/v1/backtrace',
describeRoute({
tags: ['Config'],
summary: 'Crash reporter config',
description:
'Budget, sampling and log-capture settings for the clients Backtrace crash ' +
'reporter. Nothing on this server receives the reports.',
responses: { 200: json(BacktraceConfig, 'Crash reporter settings') },
}),
(c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex: '^.*$',
VersionRegex: '.*',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
// config is static.
.get('/api/config/v2', (c) =>
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
.get(
'/api/config/v2',
describeRoute({
tags: ['Config'],
summary: 'The main client config blob',
description:
'The large feature-switch / endpoint config the client reads at startup. Served ' +
'from a static asset, except `ShareBaseUrl`, which is templated from the ' +
'deploy-time base domain so share links point at this deployment.',
responses: { 200: json(ApiConfigV2, 'The client config') },
}),
(c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
)
.get('/api/versioncheck/v4', (c) =>
c.json({
VersionStatus: 0,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
.get(
'/api/versioncheck/v4',
describeRoute({
tags: ['Config'],
summary: 'Client version check',
description:
'Whether the client build is current. Compares the clients `?v=` build against ' +
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
'client is on a different build.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
.get(
'/api/gameconfigs/v1/all',
describeRoute({
tags: ['Config'],
summary: 'Per-game configuration',
description: 'An opaque static catalog of per-game settings, served verbatim.',
responses: { 200: json(JsonObject, 'The game config catalog') },
}),
(c) => c.json(gameConfigsV1All)
)
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
// Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get('/voice/config', (c) => c.json({}))
.get(
'/voice/config',
describeRoute({
tags: ['Config'],
summary: 'Voice chat config',
description:
'Fetched by the client while setting up voice. We have no reference shape for it, ' +
'so it stays an empty object until the client is observed needing a field.',
responses: { 200: json(JsonObject, 'An empty object') },
}),
(c) => c.json({})
)
+678
View File
@@ -0,0 +1,678 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
eventInputRejection,
getEventAttendees,
getEventById,
getEventResponse,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventTags,
getLiveEvents,
inviteToEvent,
isEventResponseType,
parseEventBody,
searchEvents,
setEventResponse,
toEventListing,
toEventNotification,
toEventResponse,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventBulkInviteRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventListingDto,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResponseDto,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventTag, PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
* creator — what makes the event appear on their own screen without a refetch.
*
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
* must not fail the create. Note the frame carries the camelCase
* {@link toEventNotification} projection, not the PascalCase record the response does.
*/
async function notifyEventCreated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event, tags) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 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.
*
* 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.
*
* 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
* omitting one surfaces as a NullReferenceException in the client rather than a missing
* field (see notification-payloads.ts). The event goes in the same camelCase
* {@link toEventNotification} projection the `PlayerEventCreated` frame uses, and the
* 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.
*/
async function notifyInvited(
c: Context<App>,
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),
})
}
}
}
/**
* Player events — scheduled events players and clubs host in a room.
*
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
* blob IS the DTO, so every read here serves it verbatim; only the create/update
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
*
* Watch the response shapes: the two club feeds deliberately differ (bare array for
* the multi-club form, paged envelope for the single-club one) and the client chokes
* if they're unified.
*/
export const eventRoutes = new Hono<App>({ strict: false })
// The player-events browse feed — everything upcoming or running, soonest first. Same
// query `/search` runs with no text, but its own projection: this feed drops `State`
// and carries a `BroadcastingRoomInstanceId`, so it goes through `toEventListing`.
.get(
'/api/playerevents/v1',
describeRoute({
tags: ['Events'],
summary: 'The player-events browse feed',
description:
'The default feed on the player-events screen: every event that has not finished ' +
'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' +
'array.\n\n' +
'Each entry is the browse LISTING, not the stored record the by-id, bulk and ' +
'search reads serve: it drops `State` and carries ' +
'`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' +
'That is the shape observed on this endpoint; keep the two projections apart.',
parameters: pageParams(50),
responses: { 200: json(PlayerEventListingDto.array(), 'The events that have not ended') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
const events = await searchEvents(c.env.DB, '', skip, take)
return c.json(events.map(toEventListing))
}
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd to. `Created` is served ' +
'from the event table, soonest first.\n\n' +
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isnt known ' +
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
'or the event plus the answer, which is the useful thing to render. Serving the ' +
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
'response is observed.',
security: AUTHED,
responses: {
200: json(PlayerEventsAll, 'The callers created events, and an empty RSVP list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
}
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
// recent-activity data we don't keep, and the client renders no trending row for null.
.get(
'/api/playerevents/v1/tagfilters',
describeRoute({
tags: ['Events'],
summary: 'Player-event filter chips',
description:
'The filter chips on the player-events browse screen — the event categories the ' +
'client offers. Static: the same set regardless of what is stored. ' +
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
'and the client renders no trending row for null.',
security: AUTHED,
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
}
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses.
.get(
'/api/playerevents/v1/clubs',
describeRoute({
tags: ['Events'],
summary: 'Player events across several clubs',
description:
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
'means an empty shelf, not every event.',
parameters: [intQuery('id', 'Repeatable club id')],
responses: { 200: json(PlayerEventDto.array(), 'The clubs events') },
}),
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
)
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get(
'/api/playerevents/v1/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events for one club',
description:
'The same feed for a single club — and this form DOES wrap the events with a ' +
'paging cursor, matching the reference. The cursor is always empty: a clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs events, in a single page') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const events = await getEventsByClubs(c.env.DB, [clubId])
return c.json({ ContinuationToken: '', Events: events })
}
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
'/api/playerevents/v1/searchlive',
describeRoute({
tags: ['Events'],
summary: 'Live player events',
description:
'The "happening now" row on the player-events browse screen: events that have ' +
'started and not yet ended, soonest first. A bare array.',
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
}),
async (c) => c.json(await getLiveEvents(c.env.DB))
)
// Event search — the browse query. Text is matched term by term against name and
// description; finished events are left out (this backs a browse screen).
.get(
'/api/playerevents/v1/search',
describeRoute({
tags: ['Events'],
summary: 'Search player events',
description:
'The browse query on the player-events screen, term by term; an empty query ' +
'browses everything upcoming. A `#` decides how a term is matched: `#workshops` is ' +
'a TAG term, matching only events tagged `workshops` and never the word in a name ' +
'or description, which is what the filter chips send; a bare `workshops` is TEXT, ' +
'matched case-insensitively against the name and description. Every term must ' +
'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 ' +
'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'
),
...pageParams(50),
],
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
}
)
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
// holds. Answers in the order asked for; ids with no event are skipped.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id',
description:
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
'in the order the ids were asked for — the client renders them in request order — ' +
'and skips ids with no event rather than leaving a hole, so the result may be ' +
'shorter than the request. A bare array.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
)
// RSVP. One row per player per event, so responding again replaces the previous
// answer rather than stacking up. Note this is the v1 path while create/update are
// v2 — that's how the client calls them.
.post(
'/api/playerevents/v1/respond',
describeRoute({
tags: ['Events'],
summary: 'Answer a player event',
description:
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
'2 Cant go. Responding again replaces the previous answer; there is one row per ' +
'player per event, and a decline is recorded rather than deleted so the client can ' +
'show a player what they said.\n\n' +
'Only Going counts toward the events `AttendeeCount`, which is recomputed from ' +
'the RSVP table on every response. Anyone may respond, the creator included — ' +
'they are already Going from create, and nothing stops them declining their own ' +
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 02, is a 400; an ' +
'unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
const eventId = Number(body.PlayerEventId)
const type = Number(body.Type)
// Both are rejected rather than defaulted: an unrecognized answer stored as
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
}
)
// Report an event. Stored in the `report` table the player reports use — same fields,
// same moderation life — with `event_id` set. See migrations/0011_report_event.sql.
.post(
'/api/playerevents/v1/report',
describeRoute({
tags: ['Events', 'Moderation'],
summary: 'Report a player event',
description:
'Files a report against an event. Stored as a row in the same `report` table a ' +
'player report goes to (`POST /api/PlayerReporting/v3/create`) — it is the same ' +
'submission with the same moderation life, and a moderator converts either into a ' +
'ban the same way. What marks it as an event report is `event_id`; the rows ' +
'`reported_player_id` is the events CREATOR (who a moderator would act against) ' +
'and its `room_id` the room the event runs in, both read from the event rather ' +
'than sent by the client.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. Note this ' +
'body is JSON, where the player reports is form-encoded. `ReportCategory` is ' +
'stored verbatim — the enum is not mapped here. Nothing dedupes the rows: ' +
'reporting the same event twice files two reports.\n\n' +
'Answers the same `{ success, error }` envelope as the player report, `error` ' +
'being an empty string rather than null, on the rejected branches too so there is ' +
'only one shape to parse.',
security: AUTHED,
requestBody: jsonBody(PlayerEventReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `PlayerEventId` in the body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such event'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const eventId = Number(body.PlayerEventId)
if (!Number.isInteger(eventId)) {
return c.json({ success: false, error: 'PlayerEventId is required' }, 400)
}
// The event supplies the two columns the client doesn't send. An unknown event is
// refused rather than filed against nobody: the row's reported player has to be
// someone, and a report naming an event that never existed isn't actionable.
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.json({ success: false, error: 'No such event' }, 404)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: event.CreatorPlayerId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
roomId: event.RoomId > 0 ? event.RoomId : null,
eventId,
})
return c.json({ success: true, error: '' })
}
)
// 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.
.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' +
'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 ' +
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
'are duplicate ids.\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 ' +
'`{ Result, TagModifyResult, PlayerEvent }` envelope the other event writes do, ' +
'carrying the updated attendee count.',
security: AUTHED,
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'The caller is not on the event (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; InvitedPlayerIds?: unknown }>()
.catch(() => ({}) as { PlayerEventId?: unknown; InvitedPlayerIds?: unknown })
const eventId = Number(body.PlayerEventId)
if (!Number.isInteger(eventId) || !Array.isArray(body.InvitedPlayerIds)) {
return c.body(null, 400)
}
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
// On the event themselves, one way or the other. The creator has a Going row from
// create, so the response lookup would usually cover them — but it's checked
// explicitly so a creator who deleted their own answer can still invite.
if (
event.CreatorPlayerId !== id &&
(await getEventResponse(c.env.DB, eventId, id)) === null
) {
return c.body(null, 403)
}
// Unusable entries are dropped rather than failing the invite: a client sending one
// bad id shouldn't lose the other nine invites.
const invited = [
...new Set(
body.InvitedPlayerIds.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v !== id)
),
]
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)
return c.json(toEventResult(result!.event))
}
)
// Create. The creator comes from the bearer token, never the body — posting someone
// else's `CreatorPlayerId` doesn't make it theirs.
.post(
'/api/playerevents/v2',
describeRoute({
tags: ['Events'],
summary: 'Create a player event',
description:
'Schedules a new event. The creator is taken from the bearer token, never the ' +
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
'an hour from now, rather than an error the client cant render.\n\n' +
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
'the bare event the read endpoints serve.\n\n' +
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
'the event in its camelCase notification projection. A hub failure is logged and ' +
'swallowed — the event is already stored by then.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
// The one thing this route isn't lenient about. Everything else here defaults a
// missing or unusable field, but a name or description past the stored length
// can't be defaulted into something sensible — and truncating a player's event
// description silently is worse than refusing it.
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event, input.tags ?? [])
return c.json(toEventResult(event))
}
)
// Update. Creator-only, and a partial body only changes what it carries.
.post(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Update a player event',
description:
'Edits an event the caller created. Only the fields the body carries change; ' +
'everything else keeps its stored value, so a partial post cant blank out the ' +
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
'clear it.\n\n' +
'The id, the creator and the attendee count are not editable: ownership doesnt ' +
'transfer and RSVPs arent set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
if (eventInputRejection(input) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!))
}
)
// An event's guest list — every RSVP row, whatever the answer.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
describeRoute({
tags: ['Events'],
summary: 'An events RSVPs',
description:
'Every answer given to an event, in the order they were given — declines and ' +
'maybes included, not just the Going rows `AttendeeCount` counts. One entry per ' +
'player: a player who changed their mind has one row carrying the answer that ' +
'stands, and `CreatedAt` moves with it.\n\n' +
'A bare array, and an unknown event is an empty one rather than a 404 — like the ' +
'other list reads here. An event always has at least its creators Going row.',
parameters: [idParam('eventId', 'Event id')],
responses: { 200: json(PlayerEventResponseDto.array(), 'The events RSVPs') },
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const attendees = await getEventAttendees(c.env.DB, eventId)
return c.json(attendees.map(toEventResponse))
}
)
// A single event. Registered last so the literal `/bulk` and `/search` paths above
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'One player event',
description:
'A single event by id, served as the bare record — no envelope, unlike the ' +
'create/update writes. 404 when there is no such event.\n\n' +
'`includeDetails=True` adds exactly one field, the lowercase `tags` — that is the ' +
'whole of what the flag does. It is always an empty array here: no event tags are ' +
'stored (see the tag-filter chips, which are static, and `TagModifyResult`, which ' +
'is always null). Without the flag the key is ABSENT rather than empty, since a ' +
'caller that didnt ask for details shouldnt be told the event has no tags.',
parameters: [
idParam('eventId', 'Event id'),
stringQuery('includeDetails', 'Pass `True` to add the `tags` array'),
],
responses: {
200: json(PlayerEventDetailsDto, 'The event, with `tags` when details were asked for'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
// The client sends `True`; accepted case-insensitively, and `1` alongside it.
const details = /^(true|1)$/i.test(c.req.query('includeDetails') ?? '')
if (!details) return c.json(event)
return c.json({ ...event, tags: await getEventTags(c.env.DB, eventId) })
}
)
+138 -38
View File
@@ -1,62 +1,162 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
import {
BareString,
idParam,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeCategories,
KeepsakeConfig,
SanitizeRequest,
stringParam,
} from '../openapi'
import type { App } from '../context'
// Text sanitization, keepsakes, objectives/events/rewards, and the misc
// analytics/subscription sinks the client hits during load.
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
// sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean.
.post('/api/sanitize/v1', async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
})
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
.post(
'/api/sanitize/v1',
describeRoute({
tags: ['Gameplay'],
summary: 'Sanitize a string',
description:
'Runs display names, room names and chat through the profanity filter. There is ' +
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
'(an empty string if the body has no `Value`).',
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
}),
async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
}
)
.post(
'/api/sanitize/v1/isPure',
describeRoute({
tags: ['Gameplay'],
summary: 'Whether a string is clean',
description: 'The yes/no form of the filter. Always `true` — nothing is filtered here.',
requestBody: jsonBody(SanitizeRequest, 'The text to check'),
responses: { 200: json(IsPureResponse, 'Always pure') },
}),
(c) => c.json({ IsPure: true })
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
.get('/api/activities/charades/v1/words/:activity', (c) => c.json(charadesWords))
.get(
'/api/activities/charades/v1/words/:activity',
describeRoute({
tags: ['Gameplay'],
summary: 'An activitys word bank',
description:
'The words the Charades activity draws from. The client asks by activity name ' +
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
'charades list — no other activity has data yet.',
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
responses: { 200: json(JsonArray, 'The word list') },
}),
(c) => c.json(charadesWords)
)
// Keepsakes (room mementos). Stubbed empty.
.get('/api/keepsakes/globalconfig', (c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
.get(
'/api/keepsakes/globalconfig',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake feature switches',
description:
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
'feature reports as enabled, but nothing stores keepsakes yet.',
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
}),
(c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
)
.get(
'/api/keepsakes/rooms/:roomId',
describeRoute({
tags: ['Gameplay'],
summary: 'A rooms keepsakes',
description:
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
'that is what the reference does, and the client treats a body here as data.',
parameters: [idParam('roomId', 'Room id')],
responses: { 204: { description: 'No keepsakes (empty body)' } },
}),
(c) => c.body(null, 204)
)
// A counted result set, NOT the bare list the stubs around it serve: the client parses
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
// page; the reference returns `results.Length`.
.get(
'/api/keepsakes/categories',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake categories',
description:
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
"The client parses this one as an object and fails on an array (\"expected '{', " +
"actual '['\"), taking the keepsake load down with it. `TotalResults` counts " +
'`Results` itself — there is no paging here.',
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
.get('/api/keepsakes/categories', (c) => c.json([]))
// ---- Objectives / events / rewards ---------------------------------------
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
// where the client calls them — they are not served here.
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
// The tag filter chips on the player-events browse screen. Derived from the tags in
// use across events — we store no events, so there are no chips to offer.
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
.get('/api/playerevents/v1/tagfilters', (c) =>
c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
.get(
'/api/communityboard/v2/current',
describeRoute({
tags: ['Gameplay'],
summary: 'The current community board',
description:
'The rotating community board on the home screen — featured player, featured room ' +
'group, announcement and image strips. Served verbatim from a static blob.',
responses: { 200: json(JsonObject, 'The community board') },
}),
(c) => c.json(communityBoard)
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
// player-event storage yet, so the feed is empty.
.get('/api/playerevents/v1/clubs', (c) => c.json([]))
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) =>
c.json({ ContinuationToken: '', Events: [] })
)
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
tags: ['Gameplay'],
summary: 'Announcements',
description: 'The announcement banners on the home screen. Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/announcements.json
// GameSight attribution/analytics event sink. Accept and ack without persisting.
.post('/api/gamesight/event', (c) => c.body(null, 200))
// ---- Subscription ---------------------------------------------------------
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
.post(
'/api/gamesight/event',
describeRoute({
tags: ['Gameplay'],
summary: 'Analytics event sink',
description:
'The clients GameSight attribution/analytics events. Accepted and dropped — ' +
'nothing is persisted. Answers 200 with an empty body.',
responses: { 200: { description: 'Accepted (empty body)' } },
}),
(c) => c.body(null, 200)
)
+373 -148
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
createImage,
@@ -11,8 +12,34 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
} from '../images-db'
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
CheeredEntry,
CheerImageRequest,
DeleteImageRequest,
ErrorResponse,
form,
idParam,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
pageParams,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import type { App } from '../context'
@@ -28,191 +55,389 @@ const typeFolder: Record<number, string> = {
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ strict: false })
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
.post('/api/images/v4/uploadsaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.get(
'/api/images/v2/named',
describeRoute({
tags: ['Images'],
summary: 'Named images',
description:
'The named-image catalog (UI art the client looks up by name). Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/namedimages.json
.post(
'/api/images/v4/uploadsaved',
describeRoute({
tags: ['Images'],
summary: 'Upload a saved image',
description:
'Stores a photo in the shared image bucket under a random key, foldered by image ' +
'type and upload date (e.g. `sharecamera/2026-06-15/…`) so the bucket stays ' +
'browsable. The returned `ImageName` is that key — the `img` worker serves the ' +
'object back by it, slashes and all.\n\n' +
'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 accounts ' +
'avatar, persisted on the account row.',
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'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
}
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
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'
// 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
// the bucket stays browsable over time. The `img` worker serves it back by that
// 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' },
})
// 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.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
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'
// 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
// the bucket stays browsable over time. The `img` worker serves it back by that
// 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' },
})
// 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.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
})
)
// 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('/api/images/v1/deletesaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.delete(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
summary: 'Delete one of the callers photos',
description:
'Looks the image up by name and refuses unless the caller took it, then removes ' +
'the metadata row (and its cheers) and the object from the bucket. The metadata ' +
'goes first; the R2 delete is idempotent, so a missing object is fine.',
security: AUTHED,
requestBody: jsonBody(DeleteImageRequest, 'The image to delete'),
responses: {
200: json(SuccessResponse, 'Deleted'),
400: json(ErrorResponse, 'No ImageName given'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers 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 } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 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)
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)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
return c.json({ success: true })
})
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.
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '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
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
})
.get(
'/api/images/v4/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A rooms photo feed',
description:
'The public images taken in that room.\n\n' +
'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' +
'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' +
'and load-bearing: both render correctly as they are, and unifying them breaks one ' +
'of them.',
parameters: [
idParam('roomId', 'Room id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
intQuery('filter', 'Narrow by SavedImageType; 0 = all'),
...pageParams(100),
],
responses: { 200: json(SavedImageDto.array(), 'The rooms photos') },
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '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
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
}
)
// 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.
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
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
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
})
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
.get(
'/api/images/v4/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos',
description:
'The public images that player has taken, newest first. Serves the clients ' +
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
'— the raw `SavedImage` renders blank thumbnails here.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
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)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
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
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
})
.get(
'/api/images/v5/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos, sortable',
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
parameters: [
idParam('playerId', 'Account id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
...pageParams(100),
],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
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)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photo feed — the public images they took plus ones they're tagged
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
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
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
// the same ImagesPlayer projection the player photo lists use.
.get(
'/api/images/v3/feed/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players 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.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players feed') },
}),
async (c) => {
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)
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 }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
.get('/api/images/v1/slideshow', async (c) => {
const Images = await getSlideshowImages(c.env.DB)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
})
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
// table — and the callers that rotate one photo at a time (the website's hero) don't
// want a long feed anyway.
.get(
'/api/images/v1/slideshow',
describeRoute({
tags: ['Images'],
summary: 'The global slideshow feed',
description:
'The most recent publicly-listable ShareCamera photos across all rooms, newest ' +
'first, each joined to its creators username and room name.\n\n' +
'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.',
parameters: [
intQuery(
'take',
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
),
],
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}),
async (c) => {
// Junk, zero and negative takes fall back to the default rather than 400ing or
// serving an empty stage — the caller is a homepage, and no photos reads as the
// server being down.
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
const Images = await getSlideshowImages(c.env.DB, take)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
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(image) : c.notFound()
})
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
'has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
}),
async (c) => {
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(image) : c.notFound()
}
)
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
.post('/api/images/v1/cheer', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
SavedImageId?: number
Cheer?: boolean
} | null
if (body && typeof body.SavedImageId === 'number') {
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
.post(
'/api/images/v1/cheer',
describeRoute({
tags: ['Images'],
summary: 'Cheer or un-cheer a photo',
description:
'Persists the callers cheer and resyncs the images `CheerCount`. A body naming no ' +
'`SavedImageId` is accepted and ignored — the ack is the same either way.',
security: AUTHED,
requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'),
responses: {
200: json(SuccessResponse, 'Recorded'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
SavedImageId?: number
Cheer?: boolean
} | null
if (body && typeof body.SavedImageId === 'number') {
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
}
return c.json({ success: true })
}
return c.json({ success: true })
})
)
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
// `{ SavedImageId, IsCheered }` per requested id, in order.
.get('/api/images/v5/cheered/bulk', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
})
.get(
'/api/images/v5/cheered/bulk',
describeRoute({
tags: ['Images'],
summary: 'Which photos the caller has cheered',
description:
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
'fills in the cheer buttons on a photo grid from this.',
security: AUTHED,
parameters: [
intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'),
],
responses: {
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
)
}
)
+38 -6
View File
@@ -1,14 +1,46 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, unauthorized } from '../http'
import { AUTHED, json, JsonArray, UNAUTHORIZED_RESPONSE } from '../openapi'
import type { App } from '../context'
// ---- Inventory -------------------------------------------------------------
// The equipment/consumables the client actually reads are served by the `econ` worker,
// on the econ host. These are the same paths on this host, kept as stubs because some
// client builds probe them here first.
export const inventoryRoutes = new Hono<App>({ strict: false })
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
.get('/api/consumables/v2/getUnlocked', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
})
.get(
'/api/equipment/v2/getUnlocked',
describeRoute({
tags: ['Inventory'],
summary: 'Unlocked equipment',
description:
'A stub on this host — the real inventory lives in the `econ` worker, which serves ' +
'this same path with the players equipment. Always an empty list here, and ' +
'unlike the econ route it does not require a token.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/consumables/v2/getUnlocked',
describeRoute({
tags: ['Inventory'],
summary: 'Unlocked consumables',
description:
'A stub on this host — the real consumables live in the `econ` worker. Auth-gated ' +
'even so, then always an empty list.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
}
)
+248 -19
View File
@@ -1,28 +1,236 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
json,
JsonArray,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
* their admin surfaces on: a warning is a moderation action, but staff hold both.
*/
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
/**
* Read one field of a submitted form. The client posts these form-encoded, but the
* same names also arrive as a query string on some builds, so both are accepted.
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
const asFloat = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseFloat(v)
return Number.isNaN(n) ? null : n
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
// an empty string — the client distinguishes "no message" from a blank one.
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
// `Message` is null, not an empty string — the client distinguishes "no message"
// from a blank one.
.get(
'/api/PlayerReporting/v1/moderationBlockDetails',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer. Two details matter ' +
'to the client: ' +
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
'`Message` is null rather than an empty string — the client distinguishes “no ' +
'message” from a blank one.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
(c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
)
.get(
'/api/PlayerReporting/v1/voteToKickReasons',
describeRoute({
tags: ['Moderation'],
summary: 'Vote-to-kick reasons',
description:
'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' +
'is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/vtkreasons.json
.post(
'/api/PlayerReporting/v1/hile',
describeRoute({
tags: ['Moderation'],
summary: 'Report submission sink',
description:
'A player report. Nothing stores reports, so this accepts whatever it is sent and ' +
'answers a bare `false`.',
responses: { 200: json(BareBoolean, 'A bare JSON `false`') },
}),
(c) => c.json(false)
)
// The report the client actually submits. Auth-gated: the reporter is taken from
// the bearer token rather than the body, so a report can't be filed as someone else.
.post(
'/api/PlayerReporting/v3/create',
describeRoute({
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
'Answers the real services `{ success, error }` envelope, where `error` is an ' +
'empty string rather than null. The rejected branch uses the same envelope so ' +
'the client only ever parses one shape.',
security: AUTHED,
requestBody: form(CreateReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
if (reportedPlayerId === null) {
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
}
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
const roomId = asInt(formField(body, c, 'RoomId'))
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
details: formField(body, c, 'Details') ?? null,
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
heightReported: asFloat(formField(body, c, 'HeightReported')),
roomId: roomId !== null && roomId > 0 ? roomId : null,
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
})
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
'/api/playerwarnings',
describeRoute({
tags: ['Moderation'],
summary: 'Issue a player warning',
description:
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
'yet.\n\n' +
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
'per account by the operator, see the admin CLIs `grant-moderator` / ' +
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
'is the caller, NOT a body field.\n\n' +
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
'internal and never surfaced to them.\n\n' +
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
'an empty string rather than null — including on the rejected branches, so there ' +
'is only one shape to parse.',
security: AUTHED,
requestBody: form(CreateWarningRequest, 'The warning'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
if (warnedPlayerId === null) {
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
}
await createWarning(c.env.DB, {
moderatorPlayerId: moderatorId,
warnedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
displayReason: formField(body, c, 'DisplayReason') ?? null,
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
})
return c.json({ success: true, error: '' })
}
)
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
@@ -34,4 +242,25 @@ export const moderationRoutes = new Hono<App>({ strict: false })
// https://github.com/djdevin/recnet-plugin we disable the device ID check to enable
// account creation. Nothing in the logs, client just hangs, who knows what it is
// waiting for.
.post('/api/PlayerReporting/v1/deviceId', (c) => c.json([]));
.post(
'/api/PlayerReporting/v1/deviceId',
describeRoute({
tags: ['Moderation'],
summary: 'Device id rotation (known broken)',
description:
'The client reporting its device id, rotating from the one it thinks we hold to ' +
'the current one. It carries no bearer token and fires *before* account creation, ' +
'so there is no caller to attribute the id to and nothing to store it against — ' +
'we accept it and drop it.\n\n' +
'**Known broken.** No response shape found so far keeps the client happy: it ' +
'hangs during account creation with nothing in the logs. The real service answers ' +
'a `{ success, error }` envelope; we currently answer an empty array, which does ' +
'not help either. The workaround is to disable the device-id check client-side ' +
'(see [recnet-plugin](https://github.com/djdevin/recnet-plugin)).',
requestBody: form(DeviceIdRequest, 'The id rotation'),
responses: {
200: json(JsonArray, 'An empty array — see the note above; this is not the real shape'),
},
}),
(c) => c.json([])
)
+187 -26
View File
@@ -1,9 +1,53 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { parseFormIds, queryIds } from '../http'
import {
BulkIdsRequest,
form,
idParam,
intQuery,
json,
JsonArray,
ProgressionDto,
ReputationDto,
} from '../openapi'
import type { Context } from 'hono'
import type { Progression } from '@repo/domain'
import type { App } from '../context'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push the caller's own progression back at them over the socket, mirroring the reference's
* `HubSendProgressionUpdate` on this same read. Pushing from a GET looks odd, but it is how
* a client that just connected gets its level bar right: the frame is what the client acts
* on, the response body is only what it asked for. Best-effort — a hub failure leaves the
* body correct.
*/
async function pushProgression(c: Context<App>, progression: Progression): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
progression.PlayerId,
NotificationType.PlayerProgressionLevelUpdate,
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
)
} catch (err) {
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
accountId: progression.PlayerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Default reputation for an account — the fallback used with no DB. Nobody has
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
@@ -27,39 +71,156 @@ function defaultReputation(id: number) {
}
}
/**
* The repeated `id` query param the 2023 client uses on the bulk GET forms — each value
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
*/
const BULK_ID_QUERY = [
intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
]
/** The `Ids` form body the bulk POST forms take. */
const BULK_ID_BODY = form(BulkIdsRequest, 'The account ids to look up')
// ---- Reputation / progression ----------------------------------------------
export const progressionRoutes = new Hono<App>({ strict: false })
.get('/api/playerReputation/v1/:id', (c) =>
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
.get(
'/api/playerReputation/v1/:id',
describeRoute({
tags: ['Progression'],
summary: 'A players reputation',
description:
'The cheer counters shown on a players profile. No cheers are stored yet, so ' +
'every player gets the same all-zero record with full cheer credit.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ReputationDto, 'The players reputation') },
}),
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
tags: ['Progression'],
summary: 'A players level and XP',
description:
'The level and XP banked in `progression` (game rewards pay into it from the `econ` ' +
'worker); `XP` is the progress into the current level, not a lifetime total. A ' +
'player who has earned none has no row and reads back as level 1 with 0 XP. Also ' +
'pushes the same values as a `PlayerProgressionLevelUpdate` frame, as the reference ' +
'does — that is what moves the clients bar.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players progression') },
}),
async (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
const progression = await getProgression(c.env.DB, id)
await pushProgression(c, progression)
return c.json(progression)
}
)
.post(
'/api/playerReputation/v1/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk (v1)',
description:
'The older bulk form, superseded by v2. It answers an empty list rather than ' +
'synthesizing defaults — the client only uses v2.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get('/api/players/v1/progression/:id', (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
})
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
})
.post(
'/api/playerReputation/v2/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk',
description:
'One default reputation per requested id, in request order. Ids that name no ' +
'account still get a record — the client renders a profile card from it.',
requestBody: BULK_ID_BODY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
}
)
// The 2023 client calls this as a GET with repeated `id` query params.
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
.post('/api/players/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
.get(
'/api/playerReputation/v2/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk (GET form)',
description:
'What the 2023 client sends: the same bulk lookup with the ids as repeated query ' +
'params instead of a form body.',
parameters: BULK_ID_QUERY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
(c) => c.json(queryIds(c).map(defaultReputation))
)
.post(
'/api/players/v1/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (v1)',
description: 'No progression is stored yet, so this is an empty list.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
.post(
'/api/players/v2/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (v2)',
description: 'Identical to v1 — same ids in, same empty list out.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
// The 2023 client calls this as a GET with repeated `id` query params.
// Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) =>
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
.get(
'/api/players/v2/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (GET form)',
description:
'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
'ids that have earned nothing.',
parameters: BULK_ID_QUERY,
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
}),
async (c) => c.json(await getProgressions(c.env.DB, queryIds(c)))
)
.post(
'/api/v1/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (unversioned path)',
description:
'An older unversioned path some client builds still call. Same empty answer as ' +
'the versioned POST forms.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
.post('/api/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
+129 -50
View File
@@ -1,42 +1,101 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getRoomById } from '@repo/domain'
import { authedId } from '../http'
import {
AUTHED,
BareBoolean,
form,
json,
JsonArray,
QuickPlayResponse,
TagFilters,
VerifyRoleRequest,
} from '../openapi'
import type { App } from '../context'
// ---- Room keys / quick play / rooms ----------------------------------------
export const roomRoutes = new Hono<App>({ strict: false })
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
.get('/api/roomkeys/v1/room', (c) => c.json([]))
.get('/api/quickPlay/v1/getandclear', (c) =>
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
.get(
'/api/roomkeys/v1/mine',
describeRoute({
tags: ['Rooms'],
summary: 'The callers room keys',
description: 'Nothing issues room keys yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/roomkeys/v1/room',
describeRoute({
tags: ['Rooms'],
summary: 'A rooms keys',
description: 'Nothing issues room keys yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/quickPlay/v1/getandclear',
describeRoute({
tags: ['Rooms'],
summary: 'Take the pending quick-play action',
description:
'A read-and-clear of whatever quick-play action is queued for the caller (joining ' +
'a friend, an invite deep link). Nothing queues one yet, so all three fields are ' +
'null — which the client reads as “nothing to do”.',
responses: { 200: json(QuickPlayResponse, 'All null — no pending action') },
}),
(c) => c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
)
// Room search filters. The client deserializes this into an object (not an
// array) — shape from the 2025 reference.
.get('/api/rooms/v1/filters', (c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
})
.get(
'/api/rooms/v1/filters',
describeRoute({
tags: ['Rooms'],
summary: 'Room browse filter chips',
description:
'The filter chips on the room browse screen. Static, taken from the 2025 ' +
'reference. The client deserializes this as an OBJECT, not an array — and unlike ' +
'the invention/event filters, `TrendingFilters` here is a real list.',
responses: { 200: json(TagFilters, 'The filter chips') },
}),
(c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: [
'roleplay',
'nomp',
'rp',
'casual',
'fun',
'action',
'military',
'sports',
],
})
)
// Verify the caller holds at least `role` in a room. Params come from the form
@@ -44,29 +103,49 @@ export const roomRoutes = new Hono<App>({ strict: false })
// room creator always passes; otherwise the caller needs a Roles entry with
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
.post('/api/rooms/v1/verifyRole', async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
const form = body[name]
if (typeof form === 'string' && form !== '') return form
return c.req.query(name) ?? ''
.post(
'/api/rooms/v1/verifyRole',
describeRoute({
tags: ['Rooms'],
summary: 'Verify the callers role in a room',
description:
'Whether the caller holds at least `role` in the room — the gate the client checks ' +
'before letting someone into the Maker Pen. The rooms creator always passes; ' +
'anyone else needs a `Roles` entry at that level or higher.\n\n' +
'Answers a bare `true`/`false`, and every failure is `false` rather than an error ' +
'status: no token, an unknown room, and an insufficient role are indistinguishable ' +
'to the client. Params are read from the form body, falling back to the query ' +
'string. Room data is read from the shared rooms database (owned by the `rooms` ' +
'worker).',
security: AUTHED,
requestBody: form(VerifyRoleRequest, 'The room and the role level to check'),
responses: { 200: json(BareBoolean, 'Whether the caller holds the role') },
}),
async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
// (named `fromBody` rather than `form` — the openapi helper owns that name here)
const fromBody = body[name]
if (typeof fromBody === 'string' && fromBody !== '') return fromBody
return c.req.query(name) ?? ''
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
)
+551 -103
View File
@@ -1,33 +1,129 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { logger } from '@repo/hono-helpers'
import { authedId, unauthorized } from '../http'
import {
acceptFriendRequest,
addFriend,
getAccountsByIds,
getMutualFriendIds,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
} from '../relationships-db'
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, unauthorized } from '../http'
import {
AckResponse,
AUTHED,
ErrorResponse,
form,
intQuery,
json,
JsonArray,
jsonBody,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SendMultipleMessagesRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
} from '@repo/domain'
import type { App } from '../context'
import type { RelationshipFlag } from '../relationships-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
const RELATIONSHIP_CHANGED = 1
/**
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
*/
type Message = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the
* result to the client the way the Go server does: the resulting relationship rides a
* `RelationshipChanged` hub notification to the caller, and the HTTP body is just the
* `{ Success, Message }` ack. Hub failures are logged and swallowed — the DB write has
* already committed, so a hub hiccup must not fail the request.
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
* is no message store behind this, so the notification is the whole delivery.
*/
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
message.ToPlayerId,
NotificationType.MessageReceived,
message
)
return true
} catch (err) {
logger.error('failed to push MessageReceived notification', {
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
/**
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
* the request.
*/
async function notifyRelationship(
c: Context<App>,
playerId: number,
rel: RelationshipResponse
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.RelationshipChanged,
{ ...rel }
)
} catch (err) {
logger.error('failed to push RelationshipChanged notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Notify both players of a friend-graph change, each with the relationship projected from
* their own point of view — the target of a request sees `FriendRequestReceived` where the
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
*/
async function notifyBoth(
c: Context<App>,
playerId: number,
otherId: number,
change: RelationshipChange
): Promise<void> {
if (!change.changed) return
await notifyRelationship(c, playerId, change.self)
await notifyRelationship(c, otherId, change.other)
}
/**
* 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
* resulting relationship rides the notification and the HTTP body is just the
* `{ Success, Message }` ack.
*/
async function applyFlag(
c: Context<App>,
@@ -37,18 +133,7 @@ async function applyFlag(
value: boolean
): Promise<Response> {
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
RELATIONSHIP_CHANGED,
{ ...rel }
)
} catch (err) {
logger.error('failed to push RelationshipChanged notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
await notifyRelationship(c, playerId, rel)
return c.json({ Success: true, Message: '' })
}
@@ -78,55 +163,356 @@ async function targetPlayerId(c: Context<App>): Promise<number | null> {
return null
}
/**
* How every relationship mutation names its target. The handler is liberal — it also
* accepts `PlayerId`/`playerId`/`Id` from a JSON or form body — but the client sends the
* query param, so that's what the spec documents.
*/
const TARGET_PARAMS = [
intQuery('id', 'The other player. The client uses this form.'),
intQuery('playerId', 'Accepted as an alias for `id`'),
]
/**
* A `describeRoute` spec for one of the four friend-graph mutations. These change state
* both players can see, so each also pushes a RelationshipChanged notification to both
* sides; the HTTP body is the caller's own projection.
*/
function friendMutation(summary: string, description: string) {
return describeRoute({
tags: ['Social'],
summary,
description,
security: AUTHED,
parameters: TARGET_PARAMS,
responses: {
200: json(RelationshipDto, 'The relationship, from the callers point of view'),
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
401: UNAUTHORIZED_RESPONSE,
},
})
}
/**
* A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their
* inverses). The write lands on the caller's own side of the row, so only the caller is
* notified — and the resulting relationship rides that notification, not the response,
* which is just the ack.
*/
function flagToggle(summary: string, description: string) {
return describeRoute({
tags: ['Social'],
summary,
description,
security: AUTHED,
parameters: TARGET_PARAMS,
responses: {
200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'),
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
401: UNAUTHORIZED_RESPONSE,
},
})
}
// ---- Social ----------------------------------------------------------------
export const socialRoutes = new Hono<App>({ strict: false })
// The authed player's relationships, projected from their point of view — a bare
// array of RelationshipResponse. Auth-gated.
.get('/api/relationships/v2/get', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
})
.get(
'/api/relationships/v2/get',
describeRoute({
tags: ['Social'],
summary: 'The callers relationships',
description:
'Every relationship the signed-in player has, projected from their point of view — ' +
'a bare array. `None` rows are included: that is how an unfriending, or an ' +
'ignore/mute of someone you were never friends with, is recorded, and they still ' +
'carry the callers favorited/ignored/muted flags.',
security: AUTHED,
responses: {
200: json(RelationshipDto.array(), 'The callers relationships'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
}
)
// The friends the caller and another player have in common. Unlike the other
// relationship routes this answers account cards, not relationships — it's what the
// client shows on someone else's profile.
.get(
'/api/relationships/mutualfriends',
describeRoute({
tags: ['Social'],
summary: 'Friends in common with another player',
description:
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
'pending requests on either side are ignored.\n\n' +
'Answers an empty array rather than an error for the degenerate cases: no target ' +
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
'intersection.\n\n' +
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
'null, when the account has no image.',
security: AUTHED,
parameters: [intQuery('id', 'The other player')],
responses: {
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const raw = c.req.query('id')
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
// themselves. An empty list, not an error — this feeds a profile panel.
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
return c.json(
accounts
.map((a) => ({
AccountId: a.accountId,
Username: a.username,
DisplayName: a.displayName,
ProfileImage: a.profileImage ?? '',
}))
// getAccountsByIds doesn't promise an order; keep the ascending one.
.sort((a, b) => a.AccountId - b.AccountId)
)
}
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. Nothing is stored: the message IS the notification, pushed to the
// recipient's hub connection (and queued by the hub if they're offline).
.post(
'/api/messages/v2/send',
describeRoute({
tags: ['Social'],
summary: 'Send a message to another player',
description:
'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' +
'the same frame the Coach broadcast sends (see the `notify` workers ' +
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
'empty.\n\n' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' +
'silently dropped message.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const fromPlayerId = await authedId(c)
if (fromPlayerId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
if (Number.isNaN(toPlayerId)) {
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const delivered = await pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
Data: str(body.Data) ?? '',
})
if (!delivered) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// The bulk form of the send above: one message, several recipients. Posted as JSON
// (`{"ToPlayerIds":[205],"Type":20,"Data":""}`), not the form encoding the single
// send uses, so `Type` arrives as a number here.
.post(
'/api/messages/v1/sendMultiple',
describeRoute({
tags: ['Social'],
summary: 'Send one message to several players',
description:
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
'sender rule: the callers bearer token, never a body field. Same non-store: the ' +
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
'The body is JSON rather than the single sends form encoding, so `Type` is a ' +
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
'string, commonly empty. Repeated ids are delivered once.\n\n' +
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
'behind it a swallowed error would be a silently dropped message.',
security: AUTHED,
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable id in `ToPlayerIds`'),
401: UNAUTHORIZED_RESPONSE,
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const fromPlayerId = await authedId(c)
if (fromPlayerId === null) return unauthorized(c)
const body = (await c.req.json<Record<string, unknown>>().catch(() => ({}))) as Record<
string,
unknown
>
// Ids may arrive as numbers or as numeric strings; drop anything that isn't an
// id and de-duplicate, so a repeated id doesn't deliver the message twice.
const toPlayerIds = [
...new Set(
(Array.isArray(body.ToPlayerIds) ? body.ToPlayerIds : [])
.map((v) => (typeof v === 'number' ? v : Number.parseInt(String(v), 10)))
.filter((n) => Number.isInteger(n) && n > 0)
),
]
if (toPlayerIds.length === 0) {
return c.json({ success: false, error: 'ToPlayerIds is required' }, 400)
}
const type = typeof body.Type === 'number' ? body.Type : Number(body.Type) || 0
const data = typeof body.Data === 'string' ? body.Data : ''
// Every recipient is attempted even if an earlier one fails — the reachable
// players get their message either way.
const results = await Promise.all(
toPlayerIds.map((toPlayerId) =>
pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
)
if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// Send a friend request to another player (the target arrives as `?id=`). The
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
// caller's point of view.
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await sendFriendRequest(c.env.DB, id, target))
})
//
// The four friend-graph mutations below change state both players can see, so each
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
// notifies nobody.
.on(
['GET', 'POST'],
'/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.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
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)
return c.json(change.self)
}
)
// Accept a pending friend request from another player (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await acceptFriendRequest(c.env.DB, id, target))
})
.on(
['GET', 'POST'],
'/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.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
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)
return c.json(change.self)
}
)
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
await removeFriend(c.env.DB, id, target)
return c.json({ success: true })
})
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
.on(
['GET', 'POST'],
'/api/relationships/v2/removefriend',
friendMutation(
'Unfriend, or cancel/decline a request',
'All three are the same operation. The row is kept as a `None` relationship so the ' +
'per-side favorited/ignored/muted flags survive.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await removeFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/addfriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await addFriend(c.env.DB, id, target))
})
.on(
['GET', 'POST'],
'/api/relationships/v2/addfriend',
friendMutation(
'Befriend directly',
'Become friends with no pending-request step. Already being friends is a no-op.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await addFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Ignore / mute another player, and their inverses unignore / unmute (target
// arrives as `PlayerId` in the POST body). These set a per-player flag on the
@@ -135,54 +521,116 @@ export const socialRoutes = new Hono<App>({ strict: false })
// friended. The un- variants just clear the same flag. Auth-gated. The resulting
// relationship is delivered via a RelationshipChanged hub notification (see
// applyFlag); the HTTP body is just the { Success, Message } ack.
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unignore', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', false)
})
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unmute', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', false)
})
.on(
['GET', 'POST'],
'/api/relationships/v1/ignore',
flagToggle(
'Ignore a player',
'Sets the callers `ignored` flag. Ignoring someone you have no relationship with ' +
'creates a bare (`None`) row to hold the flag.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unignore',
flagToggle('Stop ignoring a player', 'Clears the callers `ignored` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', false)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/mute',
flagToggle(
'Mute a player',
'Sets the callers `muted` flag. Like ignore, this works on a player you have no ' +
'relationship with.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unmute',
flagToggle('Unmute a player', 'Clears the callers `muted` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', false)
}
)
// Favorite / unfavorite another player (the client calls these as a GET with the
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
// lands on the *caller's* side of the row, and favoriting someone you have no
// relationship with creates a bare (None) row. Auth-gated. Result rides a
// RelationshipChanged notification; the body is the { Success, Message } ack.
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', false)
})
.on(
['GET', 'POST'],
'/api/relationships/v1/favorite',
flagToggle(
'Favorite a player',
'Sets the callers `favorited` flag — what pins a player to the top of their friends ' +
'list. Works on a player you have no relationship with.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unfavorite',
flagToggle('Unfavorite a player', 'Clears the callers `favorited` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', false)
}
)
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
.get(
'/api/messages/v2/get',
describeRoute({
tags: ['Social'],
summary: 'Direct messages',
description: 'There is no message store yet, so this is always an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/messages/v1/favoriteFriendOnlineStatus',
describeRoute({
tags: ['Social'],
summary: 'Online status of favorited friends',
description:
'Presence for the callers favorited friends. Presence lives in the `match` ' +
'worker and is not joined in here yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
+2271 -75
View File
@@ -2,14 +2,49 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
addXp,
applyLevelUps,
createImage,
GAME_VERSION,
getImageByName,
grantInvention,
IMAGE_SCHEMA_DDL,
INVENTORY_INVENTION_SCHEMA_DDL,
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import '../../api.app'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
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 RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import {
banFromReport,
createReport,
getActiveBan,
getReportsAgainst,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
@@ -42,16 +77,13 @@ const TEST_ROOMS = [
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (matching the auth worker's migration) — uploadsaved records
// profile thumbnails on the account row. Seed the account the test token (sub
@@ -70,13 +102,29 @@ beforeAll(async () => {
.run()
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of IMAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// 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()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -90,10 +138,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -106,6 +157,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
@@ -135,11 +192,16 @@ describe('public endpoints', () => {
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
})
test('GET /api/versioncheck/v4', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`)
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
@@ -179,17 +241,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
// No player-event storage → no tags in use → no chips. Trending is null.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
PinnedFilters: [],
PopularFilters: [],
TrendingFilters: null,
})
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
@@ -199,19 +250,6 @@ describe('public endpoints', () => {
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
// The client deserializes this as a bare array — an envelope here fails with
// "expected:'[', actual:'{'". No player-event storage yet → empty.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The single-club form does wrap its events with a paging cursor.
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
})
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
@@ -244,7 +282,7 @@ describe('public endpoints', () => {
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await res.json()).toEqual([])
})
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
@@ -274,6 +312,89 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
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
// 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5.
expect(await addXp(env.DB, 4242, 25)).toEqual({
progression: { PlayerId: 4242, Level: 3, XP: 5 },
levelsGained: 2,
})
// The next 25 lands on 5: 10 to reach level 4, then 20 to reach 5, leaving nothing.
await addXp(env.DB, 4242, 25)
const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`)
expect(await single.json()).toEqual({ PlayerId: 4242, Level: 5, XP: 0 })
// A player who has earned nothing has no row, and still gets a record — the bulk form
// renders a card per id, so a missing one must not shorten the list.
const bulk = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243`
)
expect(await bulk.json()).toEqual([
{ PlayerId: 4242, Level: 5, XP: 0 },
{ PlayerId: 4243, Level: 1, XP: 0 },
])
})
test('the level ladder the server uses is the one the client is served', async () => {
// The client draws its bar against `LevelProgressionMaps` from this config; the server
// levels by LEVEL_REQUIRED_XP. If they drift, the bar fills to a different mark than
// the level-up fires at.
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
expect(res.status).toBe(200)
const config = (await res.json()) as {
LevelProgressionMaps: Array<{ Level: number; RequiredXp: number; GiftRarity: number }>
}
expect(config.LevelProgressionMaps.map((m) => m.RequiredXp)).toEqual([...LEVEL_REQUIRED_XP])
// The config's own `GiftRarity` is deliberately NOT asserted against `LEVEL_REWARDS`:
// it is a coarse per-band tier (flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap)
// and we grant from the published per-level table instead, which disagrees in places —
// level 15 is 2-Star there and 20 here. Only the XP costs have to match.
expect(config.LevelProgressionMaps.map((m) => m.GiftRarity)).toHaveLength(LEVEL_REWARDS.length)
// Indexed by level, so entry N is what a level-N player spends to reach N+1.
expect(config.LevelProgressionMaps.map((m) => m.Level)).toEqual(
LEVEL_REQUIRED_XP.map((_, level) => level)
)
})
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,
// 5★ = 50). These are the levels an off-by-one in the table would move.
expect(LEVEL_REWARDS[0]).toBe(0) // nobody reaches level 0
expect([1, 3, 5, 6, 7, 9].map((level) => LEVEL_REWARDS[level])).toEqual([
-1, -1, -1, -1, -1, -1,
])
expect([2, 4, 8, 10, 21].map((level) => LEVEL_REWARDS[level])).toEqual([10, 10, 10, 10, 10])
expect([22, 30].map((level) => LEVEL_REWARDS[level])).toEqual([20, 20])
expect([31, 35, 40, 49].map((level) => LEVEL_REWARDS[level])).toEqual([30, 30, 30, 30])
expect(LEVEL_REWARDS[50]).toBe(50) // the only 5-Star in the progression
expect(LEVEL_REWARDS).toHaveLength(51)
})
test('the ladder matches the published XP curve', async () => {
// Rec Room's own level-curve chart, read at its gridlines: cumulative XP to finish each
// level. The per-level costs are easy to edit one at a time and hard to eyeball as a
// curve, so the milestones are what actually pin the shape.
const cumulative = LEVEL_REQUIRED_XP.reduce<number[]>((totals, cost, level) => {
totals[level] = level === 0 ? 0 : (totals[level - 1] ?? 0) + cost
return totals
}, [])
expect(cumulative[10]).toBe(170)
expect(cumulative[20]).toBe(620)
expect(cumulative[30]).toBe(1770)
expect(cumulative[40]).toBe(5370)
expect(cumulative[50]).toBe(16170)
})
test('levelling stops at the top of the ladder', async () => {
// Nothing above MAX_LEVEL to buy, so a huge grant banks XP and stays put.
expect(applyLevelUps(MAX_LEVEL, 100_000)).toEqual({ level: MAX_LEVEL, xp: 100_000 })
// …and a grant that doesn't cover the current level's cost just accrues.
expect(applyLevelUps(1, 9)).toEqual({ level: 1, xp: 9 })
})
test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST',
@@ -336,12 +457,14 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
})
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => {
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
expect(room.status).toBe(204)
// A result set, not a list: the client parses this one as an object and an array
// fails it outright ("expected '{', actual '['").
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
expect(cats.status).toBe(200)
expect(await cats.json()).toEqual([])
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /voice/config returns an object', async () => {
@@ -409,7 +532,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
@@ -441,6 +564,49 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -472,6 +638,51 @@ describe('public endpoints', () => {
})
})
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 324 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
@@ -551,6 +762,39 @@ describe('public endpoints', () => {
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
@@ -698,6 +942,52 @@ describe('public endpoints', () => {
expect(await batch('')).toEqual([])
})
test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => {
const save = async (sub: string, name: string): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const owns = async (query: string, sub: string): Promise<unknown> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fulllineageowner?${query}`,
{ headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return await res.json()
}
// 7301 makes two; 7302 makes one and buys one of 7301's.
const own = await save('7301', 'Lineage Root')
const nested = await save('7301', 'Lineage Nested')
const others = await save('7302', 'Someone Elses')
await grantInvention(env.DB, 7302, nested.InventionId)
// The creator owns their own lineage; one invention that isn't theirs sinks it.
expect(await owns(`id=${own.InventionId}&id=${nested.InventionId}`, '7301')).toBe(true)
expect(
await owns(`id=${own.InventionId}&id=${nested.InventionId}&id=${others.InventionId}`, '7301')
).toBe(false)
// Bought counts as owned, and comma-separated ids parse like the batch endpoint.
expect(await owns(`id=${nested.InventionId},${others.InventionId}`, '7302')).toBe(true)
expect(await owns(`id=${own.InventionId}`, '7302')).toBe(false)
// An id with no invention behind it is not owned, whoever asks.
expect(await owns(`id=${own.InventionId}&id=999999`, '7301')).toBe(false)
// No ids at all: nothing in an empty lineage is unowned.
expect(await owns('', '7301')).toBe(true)
})
test('GET /api/inventions/v1/fulllineageowner 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fulllineageowner?id=1`)
expect(res.status).toBe(401)
})
test('GET /api/inventions/v1/room lists a rooms published inventions', async () => {
// Two inventions created in room 76, one of them still a draft.
const create = async (name: string, room: number): Promise<SavedInvention> => {
@@ -750,6 +1040,12 @@ describe('public endpoints', () => {
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -761,7 +1057,8 @@ describe('public endpoints', () => {
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads.
// The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
@@ -770,6 +1067,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42,
})
@@ -792,6 +1090,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -833,6 +1169,16 @@ describe('public endpoints', () => {
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
@@ -853,6 +1199,42 @@ describe('public endpoints', () => {
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v1/update takes the permission pickers query params', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('3232')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Posted Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const post = async (query: string, sub = '3232'): Promise<Response> =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
{ method: 'POST', headers: await bearer(sub) }
)
// The picker posts the permission by CamelCase name, with no body at all.
const permission = async (name: string): Promise<number> => {
const res = await post(`permission=${name}`)
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention.GeneralPermission
}
expect(await permission('UseOnly')).toBe(20)
expect(await permission('EditAndSave')).toBe(40)
expect(await permission('Publish')).toBe(60)
// Setting the permission is not publishing — that stays v3/publish's job.
const still = await post('permission=Publish')
expect(((await still.json()) as InventionSaveResult).Invention.IsPublished).toBe(false)
// Same gate as the GET: creator only, and a token is required.
expect((await post('permission=UseOnly', '9999')).status).toBe(403)
const anonPost = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&permission=Publish`,
{ method: 'POST' }
)
expect(anonPost.status).toBe(401)
})
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -944,12 +1326,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
@@ -987,19 +1372,42 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
// the feed really does forget, rather than accumulating every acquisition ever.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
// Featured: only the flagged, visible inventions — newest first.
// Top: most acquisitions in the window first. Download counts no longer rank anything —
// 202 has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
expect(await ids(featuredPage)).toEqual([202])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
@@ -1031,6 +1439,257 @@ describe('auth-gated endpoints', () => {
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/PlayerReporting/v3/create records the report', async () => {
const res = await submit(
{
PlayerIdReported: '205',
ReportCategory: '100',
Details: 'ya know',
HeightReporter: '1.64',
HeightReported: '1.65',
RoomId: '58',
RoomInstanceType: 'Public',
},
await bearer()
)
expect(res.status).toBe(200)
// `error` is an empty string, not null — the real service's envelope.
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getReportsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The reporter is the token's subject, not a body field.
reporter_player_id: 42,
reported_player_id: 205,
report_category: 100,
details: 'ya know',
height_reporter: 1.64,
height_reported: 1.65,
room_id: 58,
room_instance_type: 'Public',
})
expect(row?.created_at).toBeTruthy()
})
// Everything but the reported player is optional — a report raised outside a room
// carries no RoomId, and 0 means "no room" rather than room zero.
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
expect(res.status).toBe(200)
const [row] = await getReportsAgainst(env.DB, 206)
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 206,
report_category: 0,
details: null,
height_reporter: null,
height_reported: null,
room_id: null,
room_instance_type: null,
})
})
// Append-only: a second report against the same player is a second row.
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
const rows = await getReportsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
})
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
const res = await submit({ PlayerIdReported: '205' })
expect(res.status).toBe(401)
})
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
const res = await submit({ Details: 'ya know' }, await bearer())
expect(res.status).toBe(400)
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
// A report is filed unbanned; a moderator converting it into a ban is what the
// `banned` / `ban_expires` columns are for. `match` and `auth` read exactly this.
test('a report is filed unbanned', async () => {
await submit({ PlayerIdReported: '210' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 210)
expect(row).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 210)).toBe(false)
})
test('banFromReport bans the reported player, permanently by default', async () => {
await submit({ PlayerIdReported: '211', Details: 'the evidence' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 211)
const banned = await banFromReport(env.DB, row!.id)
expect(banned).toMatchObject({ banned: 1, ban_expires: null })
// The report the ban was made from is still attached to it — the point of
// banning on the row rather than in a table of its own.
expect(banned?.details).toBe('the evidence')
expect(await isPlayerBanned(env.DB, 211)).toBe(true)
// It bans the REPORTED player, not the reporter who filed it.
expect(await isPlayerBanned(env.DB, 42)).toBe(false)
})
// A timed ban lifts itself: nothing clears the flag, the expiry just passes.
test('a ban with a past expiry is no longer in force', async () => {
await submit({ PlayerIdReported: '212' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 212)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 212)).toBe(false)
// Still on the row, as the record that it happened.
expect((await getReportsAgainst(env.DB, 212))[0]).toMatchObject({ banned: 1 })
// And in force while it lasted.
expect(await isPlayerBanned(env.DB, 212, new Date('2019-06-01T00:00:00.000Z'))).toBe(true)
})
test('a ban with a future expiry is in force', async () => {
await submit({ PlayerIdReported: '213' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 213)
const expires = new Date(Date.now() + 86_400_000).toISOString()
await banFromReport(env.DB, row!.id, { banExpires: expires })
expect(await isPlayerBanned(env.DB, 213)).toBe(true)
expect((await getActiveBan(env.DB, 213))?.ban_expires).toBe(expires)
})
// Two bans in force: the longest-lasting one is the one reported, so a fresh short
// ban can't shorten a standing permanent one.
test('getActiveBan prefers the permanent ban', async () => {
await submit({ PlayerIdReported: '214', Details: 'timed' }, await bearer())
await submit({ PlayerIdReported: '214', Details: 'permanent' }, await bearer())
const rows = await getReportsAgainst(env.DB, 214)
const timed = rows.find((r) => r.details === 'timed')!
const permanent = rows.find((r) => r.details === 'permanent')!
await banFromReport(env.DB, timed.id, {
banExpires: new Date(Date.now() + 3_600_000).toISOString(),
})
await banFromReport(env.DB, permanent.id)
expect(await getActiveBan(env.DB, 214)).toMatchObject({ details: 'permanent' })
})
test('banFromReport with banned:false lifts the ban and clears the expiry', async () => {
await submit({ PlayerIdReported: '215' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 215)
await banFromReport(env.DB, row!.id, { banExpires: '2999-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 215)).toBe(true)
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 215)).toBe(false)
})
// No such report — the caller can tell that from having banned nobody.
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
@@ -1138,9 +1797,33 @@ describe('images', () => {
})
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
// 120 public ShareCamera photos — more than both the default and the cap.
for (let i = 0; i < 120; i++) {
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
}
const feed = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
expect(res.status).toBe(200)
return ((await res.json()) as { Images: unknown[] }).Images.length
}
expect(await feed('')).toBe(10)
expect(await feed('?take=25')).toBe(25)
expect(await feed('?take=500')).toBe(100)
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
expect(await feed('?take=0')).toBe(10)
expect(await feed('?take=-5')).toBe(10)
expect(await feed('?take=lots')).toBe(10)
})
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 700 })
// Its own player id: 700's photos are asserted on exactly in the player-list test.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
// No token → 401.
@@ -1465,23 +2148,47 @@ describe('images', () => {
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
// SavedImageId/SavedImageType, and TaggedPlayerIds isn't part of it.
type ImagesPlayer = { SavedImageId: number; SavedImageType: number; ImageName: string }
// v4/player → only photos 700 *took*, public, newest first.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`)
).json()) as SavedImage[]
expect(mine.map((i) => i.Id)).toEqual([202, 201])
).json()) as ImagesPlayer[]
expect(mine.map((i) => i.SavedImageId)).toEqual([202, 201])
expect(mine[0]).toEqual({
Accessibility: 1,
AccessibilityLocked: false,
CheerCount: 0,
CommentCount: 0,
CreatedAt: '2026-04-01T00:00:00.000Z',
Description: null,
ImageName: 'p202.jpg',
PlayerEventId: null,
PlayerId: 700,
RoomId: null,
SavedImageId: 202,
SavedImageType: 1,
})
// take paginates.
const one = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700?take=1`)
).json()) as SavedImage[]
expect(one.map((i) => i.Id)).toEqual([202])
).json()) as ImagesPlayer[]
expect(one.map((i) => i.SavedImageId)).toEqual([202])
// v5/player is the same list with a sort option (0 = newest first).
const sorted = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v5/player/700?sort=0`)
).json()) as ImagesPlayer[]
expect(sorted.map((i) => i.SavedImageId)).toEqual([202, 201])
// v3/feed/player → photos taken *or* tagged in, newest first (204 is newest).
const feed = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700?take=100`)
).json()) as SavedImage[]
expect(feed.map((i) => i.Id)).toEqual([204, 202, 201])
).json()) as ImagesPlayer[]
expect(feed.map((i) => i.SavedImageId)).toEqual([204, 202, 201])
// A player with no photos → empty array on both.
expect(
@@ -1517,6 +2224,24 @@ describe('relationships', () => {
// the relationship detail rides a RelationshipChanged hub notification instead.
const ACK = { Success: true, Message: '' }
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Notification = {
playerId: number
notificationType: number
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
/** Drop everything the hub stub has recorded so far. */
async function resetNotifications() {
await hub().fetch('http://do/all', { method: 'DELETE' })
}
/** Every notification pushed since the last reset, in order. */
async function sentNotifications(): Promise<Notification[]> {
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
}
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
// the parsed ack body.
async function ackFlag(path: string, sub: string, playerId: number) {
@@ -1529,9 +2254,8 @@ describe('relationships', () => {
).json()) as { Success: boolean; Message: string }
}
// A player's own-side flags read straight from the relationship row. The None row a
// flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the
// flag effect is verified here instead of through the (now ack-only) response.
// A player's own-side flags read straight from the relationship row — the flag
// endpoints return only an ack, so the effect is verified against the row itself.
async function ownFlags(playerId: number, otherId: number) {
const row = (await env.DB.prepare(
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
@@ -1597,10 +2321,27 @@ describe('relationships', () => {
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
// 500 removes → neither side has a relationship.
// 500 removes → both sides drop to None. The row is kept (that's where the
// per-side flags live), so v2/get still reports the pair, now as None (0).
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([])
expect(await relationships('501')).toEqual([])
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('removefriend keeps the callers ignore flag', async () => {
// 760 befriends 761 then ignores them; dropping the friendship must not
// un-ignore them (the flag lives on the row the removal downgrades to None).
await mutate('/api/relationships/v2/addfriend', '760', 761)
await ackFlag('/api/relationships/v1/ignore', '760', 761)
await mutate('/api/relationships/v2/removefriend', '760', 761)
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
expect(await relationships('760')).toEqual([
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
])
})
test('addfriend makes them friends directly', async () => {
@@ -1689,8 +2430,10 @@ describe('relationships', () => {
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
// A None row is not reported as a relationship by v2/get.
expect(await relationships('730')).toEqual([])
// The bare None row is reported by v2/get — it carries the flag.
expect(await relationships('730')).toEqual([
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
])
})
test('a self-targeted favorite is rejected', async () => {
@@ -1711,4 +2454,1457 @@ describe('relationships', () => {
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
})
test('sendfriendrequest notifies both players with their own projection', async () => {
await resetNotifications()
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([
{
playerId: 770,
notificationType: 1,
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
},
{
playerId: 771,
notificationType: 1,
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
})
test('accepting notifies both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
expect(sent).toEqual(
expect.arrayContaining([
expect.objectContaining({
playerId: 780,
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
}),
expect.objectContaining({
playerId: 781,
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
}),
])
)
})
test('removefriend notifies both players with None', async () => {
await mutate('/api/relationships/v2/addfriend', '790', 791)
await resetNotifications()
await mutate('/api/relationships/v2/removefriend', '790', 791)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
})
test('a no-op friend request notifies nobody', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
await resetNotifications()
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
// Likewise accepting something that isn't pending (810 has no request to accept).
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
})
test('crossing requests notify both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
await resetNotifications()
// 821's request crosses 820's → an immediate friendship, both sides told.
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)
})
})
describe('messages', () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
playerId: number
notificationType: number
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
await hub().fetch('http://do/all', { method: 'DELETE' })
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
}
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([
{
// Delivered to the recipient, not the sender.
playerId: 2,
notificationType: MESSAGE_RECEIVED,
// FromPlayerId is the token's subject, not a body field.
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
},
])
})
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
Data: '',
})
})
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
const res = await send({ Type: '10' }, await bearer('42'))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v2/send is auth-gated', async () => {
const res = await send({ ToPlayerId: '2' })
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
// The bulk form takes a JSON body, not the form encoding the single send uses.
const sendMultiple = async (body: unknown, headers?: Record<string, string>) => {
await hub().fetch('http://do/all', { method: 'DELETE' })
return exports.default.fetch(`${ORIGIN}/api/messages/v1/sendMultiple`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
}
test('POST /api/messages/v1/sendMultiple pushes one frame per recipient', async () => {
const res = await sendMultiple(
{ ToPlayerIds: [205, 206], Type: 20, Data: 'hi' },
await bearer('42')
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Each frame is addressed to its own recipient; the sender is the token's subject.
expect(await pushed()).toEqual([
{
playerId: 205,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
},
{
playerId: 206,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
},
])
})
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
const res = await sendMultiple({ ToPlayerIds: [205, 205] }, await bearer('42'))
expect(res.status).toBe(200)
const sent = await pushed()
expect(sent).toHaveLength(1)
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
})
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
for (const body of [{ Type: 20 }, { ToPlayerIds: [] }, { ToPlayerIds: ['nope', 0] }]) {
const res = await sendMultiple(body, await bearer('42'))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerIds is required' })
expect(await pushed()).toEqual([])
}
})
test('POST /api/messages/v1/sendMultiple is auth-gated', async () => {
const res = await sendMultiple({ ToPlayerIds: [205] })
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
})
describe('mutual friends', () => {
// High, distinct ids so the friendships seeded here don't collide with the
// relationship tests above.
const CALLER = 800
const OTHER = 801
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
headers: await bearer(sub),
})
beforeAll(async () => {
const rel = (a: number, b: number, type = 3) =>
env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
).bind(a, b, type)
// 804 has no profileImage key at all — the projection must still answer a
// string. 806 is deliberately given no account row.
const account = (id: number, extra: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
)
await env.DB.batch([
account(CALLER, { profileImage: 'p800.jpg' }),
account(OTHER, { profileImage: 'p801.jpg' }),
account(802, { profileImage: 'p802.jpg' }),
account(803, { profileImage: 'p803.jpg' }),
account(804, {}),
// Seeded 804-first so the ascending order of the answer is the code's doing,
// not the insertion order's.
rel(CALLER, 804),
rel(802, CALLER), // friendship recorded from the other direction
rel(CALLER, 803),
rel(CALLER, 806),
rel(OTHER, 804), // shared → in the answer
rel(OTHER, 802), // shared → in the answer
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
rel(OTHER, 806), // shared, but 806 has no account row → dropped
])
})
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
const res = await mutuals(`?id=${OTHER}`)
expect(res.status).toBe(200)
const cards = (await res.json()) as Card[]
// 803 is only a pending request on OTHER's side, and 806 has no account row.
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
expect(cards[0]).toEqual({
AccountId: 802,
Username: 'P802',
DisplayName: 'Player 802',
ProfileImage: 'p802.jpg',
})
// No stored image → an empty string, never null/undefined.
expect(cards[1]?.ProfileImage).toBe('')
})
// The degenerate cases answer an empty list rather than an error — this feeds a
// profile panel, which would otherwise have nothing to render.
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
const res = await mutuals(query)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual([])
}
})
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
// 800, so 800 is what they have in common.
test('GET /api/relationships/mutualfriends works between two other players', async () => {
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
})
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
// 809 has no relationships at all.
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
expect(cards).toEqual([])
})
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`)
expect(res.status).toBe(401)
})
})
describe('player events', () => {
const HOUR = 60 * 60 * 1000
/**
* Seconds precision, no milliseconds — the form the client sends and reads back.
*
* Anchored to one instant fixed when this suite is defined, NOT to `Date.now()` per
* call: the same offset is evaluated once to build a fixture and again to assert what
* came back, and a re-read clock makes those two strings differ by a second whenever
* the pair straddles a second boundary. Offsets are whole hours, so pinning the anchor
* leaves the upcoming/live/finished distinction the browse queries make intact.
*/
const NOW = Date.now()
const at = (offsetMs: number): string =>
new Date(NOW + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
const post = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
const res = await post('/api/playerevents/v2', body, sub)
expect(res.status).toBe(200)
return ((await res.json()) as PlayerEventResult).PlayerEvent
}
const get = async (path: string, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
// The fixture set every test below reads. Times are relative to the run so the
// upcoming/live/finished distinction the browse queries make is real.
let upcoming: PlayerEvent
let clubEvent: PlayerEvent
let liveEvent: PlayerEvent
let pastEvent: PlayerEvent
beforeAll(async () => {
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
upcoming = await create({
PlayerEvent: {
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
},
})
// …and this one at the top level, the other form in circulation.
clubEvent = await create({
RoomId: 23570830,
ClubId: 7,
Name: 'DUNGEONS Escape ROOM',
Description: 'Try and escape the DUNGEONS with upto 4 players!',
StartTime: at(3 * HOUR),
EndTime: at(4 * HOUR),
CanRequestBroadcastPermissions: 2147483647,
})
liveEvent = await create(
{ RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) },
'43'
)
pastEvent = await create({
RoomId: 3,
Name: 'Trigonometry Retrospective',
StartTime: at(-3 * HOUR),
EndTime: at(-2 * HOUR),
})
})
test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => {
expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401)
const res = await get('/api/playerevents/v1/tagfilters', '42')
expect(res.status).toBe(200)
// Static — the categories the client offers, not derived from stored events.
// Trending is null even in the reference: it needs recent-activity data.
expect(await res.json()).toEqual({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
})
test('POST /api/playerevents/v2 creates an event, auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
method: 'POST',
body: '{}',
})
expect(res.status).toBe(401)
// The stored record carries exactly the client's field set — nothing more.
expect(upcoming).toEqual({
PlayerEventId: upcoming.PlayerEventId,
CreatorPlayerId: 42,
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// Timestamps come back at seconds precision, as the client sends them.
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
})
// The one thing the event writes are strict about. Everything else here defaults a
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
// description past the stored length can't be defaulted into anything sensible, and
// truncating a player's description silently is worse than refusing the write.
//
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
// rule that guards usernames and room names would be wrong here.
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
400
)
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
200
)
const withDescription = (description: string) =>
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
expect((await withDescription('d'.repeat(513))).status).toBe(400)
expect((await withDescription('d'.repeat(512))).status).toBe(200)
// Counted in code points, so an emoji costs one character rather than two.
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
// Spaces and punctuation stay fine — this is a title, not an identifier.
expect(
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
).toBe(200)
// The update path enforces the same limits, and a refusal leaves the event alone.
const event = await create({ Name: 'EditMe', RoomId: 3 })
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
Name: 'n'.repeat(65),
})
expect(tooLong.status).toBe(400)
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
})
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Always null: no event tags are stored, but the field has to be present.
expect(body.TagModifyResult).toBeNull()
expect(body.PlayerEvent.Name).toBe('Enveloped')
})
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
const event = await create({
RoomId: 58,
Name: 'Open Mic',
Description: 'come hang',
StartTime: at(HOUR),
EndTime: at(3 * HOUR),
})
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
const last = (await res.json()) as {
playerId: number
notificationType: number
data: Record<string, unknown>
}
expect(last.playerId).toBe(42) // the creator
expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated
// camelCase, unlike the PascalCase record the response carries; `tags` and
// `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped.
// The real hub strips the null values from the frame before it goes on the wire.
expect(last.data).toEqual({
tags: [],
playerEventId: event.PlayerEventId,
creatorPlayerId: 42,
roomId: 58,
subRoomId: null,
clubId: null,
name: 'Open Mic',
description: 'come hang',
imageName: '', // empty string, not the record's null
startTime: `${event.StartTime.slice(0, -1)}.0000000Z`,
endTime: `${event.EndTime.slice(0, -1)}.0000000Z`,
attendeeCount: 1,
accessibility: 1,
isMultiInstance: false,
supportMultiInstanceRoomChat: false,
defaultBroadcastPermissions: 0,
canRequestBroadcastPermissions: 0,
broadcastingRoomInstanceId: null,
})
// Tick precision on the frame; the stored record keeps its bare form.
expect(event.StartTime).toMatch(/:\d{2}Z$/)
})
test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => {
const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 })
expect(event.CreatorPlayerId).toBe(42)
})
test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => {
const event = await create({})
expect(event).toMatchObject({
Name: 'Untitled Event',
Description: '',
RoomId: 0,
SubRoomId: null,
ClubId: null,
ImageName: null,
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// A start with no end runs for an hour.
expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR)
})
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
expect(res.status).toBe(200)
// No envelope here — unlike the writes.
expect(await res.json()).toEqual(upcoming)
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
})
test('GET /api/playerevents/v1/:eventId?includeDetails=True adds only `tags`', async () => {
const path = `/api/playerevents/v1/${upcoming.PlayerEventId}`
// The flag's whole effect: the lowercase `tags`, empty (no event tags are stored).
expect(await (await get(`${path}?includeDetails=True`)).json()).toEqual({
...upcoming,
tags: [],
})
// Accepted case-insensitively — the client sends `True`.
expect(await (await get(`${path}?includeDetails=true`)).json()).toEqual({
...upcoming,
tags: [],
})
// Anything else is the bare record, with no `tags` key at all.
expect(await (await get(`${path}?includeDetails=False`)).json()).toEqual(upcoming)
expect(await (await get(path)).json()).toEqual(upcoming)
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
const res = await get(
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
)
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Request order, not id order — and the missing id leaves no hole.
expect(events.map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
})
test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
// Every term has to match, across name OR description.
expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// …matched case-insensitively, and against the description too.
expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// `pastEvent` matches on name but has already ended, so the browse query drops it.
const trig = await search('?query=trigonometry')
expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId])
expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId)
// Soonest first, and take/skip page through that order.
const all = await search('')
const starts = all.map((e) => e.StartTime)
expect([...starts].sort()).toEqual(starts)
expect(await search('?take=1')).toEqual([all[0]])
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
const res = await get('/api/playerevents/v1')
expect(res.status).toBe(200)
const feed = (await res.json()) as Array<PlayerEvent & { BroadcastingRoomInstanceId: null }>
// Upcoming and live, soonest first; what has already ended is left out.
const ids = feed.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
expect(ids).toContain(liveEvent.PlayerEventId)
expect(ids).not.toContain(pastEvent.PlayerEventId)
const starts = feed.map((e) => e.StartTime)
expect([...starts].sort()).toEqual(starts)
// The listing projection — no `State`, and a null broadcasting instance — not the
// stored record the by-id read serves.
const entry = feed.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
expect(entry).toEqual({ ...upcoming, State: undefined, BroadcastingRoomInstanceId: null })
expect(Object.hasOwn(entry, 'State')).toBe(false)
// Paged like the other feeds.
expect(await (await get('/api/playerevents/v1?take=1')).json()).toEqual([feed[0]])
expect(await (await get('/api/playerevents/v1?skip=1&take=1')).json()).toEqual([feed[1]])
})
test('GET /api/playerevents/v1/search matches `#tag` terms against tags, not text', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
// Two tagged events, one of which only MENTIONS the word in its description.
const tagged = await create({
RoomId: 3,
Name: 'Sawdust Session',
StartTime: at(HOUR),
// Both forms in circulation: a bare name and the `{ tag, type }` pair.
Tags: ['#Workshops', { tag: 'meetup', type: 2 }],
})
const textOnly = await create({
RoomId: 3,
Name: 'Talking About Workshops',
Description: 'we discuss workshops, untagged',
StartTime: at(HOUR),
})
// `#workshops` is the tag alone — the untagged event that says "workshops" twice
// doesn't match.
const byTag = await search('?query=%23workshops&sort=StartTime')
expect(byTag.map((e) => e.PlayerEventId)).toEqual([tagged.PlayerEventId])
// …and the bare word is the mirror image: a text search, which finds the event that
// says "workshops" and NOT the one merely tagged with it.
const byText = await search('?query=workshops')
expect(byText.map((e) => e.PlayerEventId)).toEqual([textOnly.PlayerEventId])
// Tag terms combine with text terms, and with each other (every one must match).
expect((await search('?query=%23workshops+sawdust')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
expect(await search('?query=%23workshops+%23meetup')).toHaveLength(1)
expect(await search('?query=%23workshops+%23celebration')).toEqual([])
expect(await search('?query=%23nosuchtag')).toEqual([])
// The tags are what `includeDetails` serves — lowercased, `#` stripped, and the
// type kept (defaulting to 0 for the bare-string form).
const details = (await (
await get(`/api/playerevents/v1/${tagged.PlayerEventId}?includeDetails=True`)
).json()) as { tags: Array<{ tag: string; type: number }> }
expect(details.tags).toEqual([
{ tag: 'meetup', type: 2 },
{ tag: 'workshops', type: 0 },
])
// …and they are NOT on the plain record, which every other read serves verbatim.
expect(
await (await get(`/api/playerevents/v1/${tagged.PlayerEventId}`)).json()
).not.toHaveProperty('tags')
// An update REPLACES the set; a body that says nothing about tags leaves it alone.
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: ['celebration'] })
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
expect(await search('?query=%23workshops')).toEqual([])
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Name: 'Sawdust Session II' })
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
// An explicit empty list does clear them.
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: [] })
expect(await search('?query=%23celebration')).toEqual([])
})
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
const res = await get('/api/playerevents/v1/searchlive')
expect(res.status).toBe(200)
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
expect(ids).toContain(liveEvent.PlayerEventId)
// Started in an hour / finished already — neither is live.
expect(ids).not.toContain(upcoming.PlayerEventId)
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
expect(many.status).toBe(200)
const events = (await many.json()) as PlayerEvent[]
expect(events.map((e) => e.PlayerEventId)).toEqual([
liveEvent.PlayerEventId, // started an hour ago — soonest first
clubEvent.PlayerEventId,
])
// The single-club form does wrap its events with a paging cursor.
const one = await get('/api/playerevents/v1/club/7')
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
// A club with no events, and the no-ids case.
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
ContinuationToken: '',
Events: [],
})
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
})
test('GET /api/playerevents/v1/all lists the callers own events, auth-gated', async () => {
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
Created: PlayerEvent[]
Responses: unknown[]
}
const ids = mine.Created.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
// 43 created that one, not 42.
expect(ids).not.toContain(liveEvent.PlayerEventId)
// Finished events stay in the creator's own list — only the browse queries drop them.
expect(ids).toContain(pastEvent.PlayerEventId)
// Nothing records an RSVP yet.
expect(mine.Responses).toEqual([])
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
Created: PlayerEvent[]
}
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// The creator is Going from create, which is where the initial 1 comes from.
expect(event.AttendeeCount).toBe(1)
expect(await countGoing(env.DB, id)).toBe(1)
// 43 says Going → 2 attendees, and the envelope carries the updated event.
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.AttendeeCount).toBe(2)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
event_id: id,
player_id: 43,
status: 0,
})
// Changing the answer REPLACES it — one row per player, not a second RSVP.
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
// Interested is a maybe — recorded, but not counted.
await respond({ PlayerEventId: id, Type: 1 }, '43')
expect(await countGoing(env.DB, id)).toBe(1)
// And the count sticks on the stored event, not just the response.
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
expect(fetched.AttendeeCount).toBe(1)
})
test('GET /api/playerevents/v1/:eventId/responses lists every RSVP, one per player', async () => {
const event = await create({ RoomId: 3, Name: 'Guest List', StartTime: at(HOUR) })
const id = event.PlayerEventId
const responses = async (): Promise<
Array<{
PlayerEventResponseId: number
PlayerEventId: number
PlayerId: number
CreatedAt: string
Type: number
}>
> => (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as never
// The creator's own Going row, from create.
const initial = await responses()
expect(initial).toEqual([
{
PlayerEventResponseId: expect.any(Number),
PlayerEventId: id,
PlayerId: 42,
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
Type: 0,
},
])
// Declines and maybes are listed too — not just what AttendeeCount counts.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43')
const withDecline = await responses()
expect(withDecline.map((r) => [r.PlayerId, r.Type])).toEqual([
[42, 0],
[43, 2],
])
// Changing an answer updates the row in place: same id, new Type — never a second
// entry for the player.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
const changed = await responses()
expect(changed).toHaveLength(2)
expect(changed[1]!.PlayerEventResponseId).toBe(withDecline[1]!.PlayerEventResponseId)
expect(changed[1]!.Type).toBe(1)
// An unknown event is an empty list, not a 404 — like the other list reads.
const unknown = await get('/api/playerevents/v1/999999/responses')
expect(unknown.status).toBe(200)
expect(await unknown.json()).toEqual([])
})
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
const event = await create({ RoomId: 3, Name: 'Guarded' })
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
})
).status
).toBe(401)
// An unrecognized Type is rejected rather than defaulted — stored as Going it
// would silently inflate the count.
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
400
)
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
).toBe(404)
})
test('POST /api/playerevents/v1/report files a report row against the event', async () => {
const event = await create({ RoomId: 58, Name: 'Reportable', StartTime: at(HOUR) }, '43')
const res = await post(
'/api/playerevents/v1/report',
{ ReportCategory: 101, PlayerEventId: event.PlayerEventId, Details: 'bad event' },
'42'
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an event report by `event_id` —
// with the reported player and the room filled in FROM the event, not the body.
const row = await env.DB.prepare('SELECT * FROM report WHERE event_id = ?1')
.bind(event.PlayerEventId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 43, // the event's creator
report_category: 101,
details: 'bad event',
room_id: 58,
event_id: event.PlayerEventId,
banned: 0, // filed unbanned, like any report
})
// A body with no usable event id, and one naming an event that doesn't exist —
// both answer the same envelope shape as the success branch.
expect(await (await post('/api/playerevents/v1/report', { Details: 'x' })).json()).toEqual({
success: false,
error: 'PlayerEventId is required',
})
const unknown = await post('/api/playerevents/v1/report', { PlayerEventId: 999999 })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such event' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/report`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId }),
})
).status
).toBe(401)
})
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// 43 declines BEFORE being invited — the invite must not flip that back.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43')
const res = await post(
'/api/playerevents/v1/bulkInvite',
// 42 is the caller (already on the event) and 187 is repeated — both are skipped.
{ PlayerEventId: id, InvitedPlayerIds: [187, 2, 187, 42, 43] },
'42'
)
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)
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
PlayerId: number
Type: number
}>
expect(
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
).toEqual([
[2, 0],
[42, 0],
[43, 2],
[187, 0],
])
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
const again = await post(
'/api/playerevents/v1/bulkInvite',
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
'42'
)
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
// 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)
})
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Invite Frames', StartTime: at(HOUR) })
const id = event.PlayerEventId
// 43 answers first, so the invite leaves them alone — and must not notify them.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2, 43] })
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, Record<string, unknown>>
}>
// One frame, to the one player who gained a row. 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(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.
expect(sent[0]!.data.PlayerEvent).toMatchObject({
playerEventId: id,
name: 'Invite Frames',
attendeeCount: 2,
})
expect(sent[0]!.data.PlayerEventResponse).toEqual({
PlayerEventResponseId: expect.any(Number),
PlayerEventId: id,
PlayerId: 2,
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
Type: 0,
})
})
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Gate', StartTime: at(HOUR) })
const id = event.PlayerEventId
const invite = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/bulkInvite', body, sub)
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulkInvite`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: id, InvitedPlayerIds: [2] }),
})
).status
).toBe(401)
// 44 has no response row on this event — not theirs to invite to.
expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(403)
// …until they respond, which puts them on it.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '44')
expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(200)
expect((await invite({ PlayerEventId: 999999, InvitedPlayerIds: [2] })).status).toBe(404)
expect((await invite({ InvitedPlayerIds: [2] })).status).toBe(400)
expect((await invite({ PlayerEventId: id })).status).toBe(400)
expect((await invite({})).status).toBe(400)
})
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
const event = await create({
RoomId: 5,
SubRoomId: 6,
ClubId: 9,
Name: 'Original',
Description: 'Original description',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
expect(
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
).toBe(401)
// 43 didn't create it.
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
const res = await post(path, { Name: 'Renamed' })
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
)
})
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
// Nested form again, and an explicit null — absent leaves the value alone,
// null genuinely clears it.
PlayerEvent: { ClubId: null, ImageName: null },
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
PlayerEventId: 424242,
CreatorPlayerId: 43,
AttendeeCount: 500,
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
})
describe('openapi', () => {
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` routes (the relationship mutations, invention update)
// contribute both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /api/images/v1/deletesaved',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
'GET /api/announcement/v1/get',
'GET /api/communityboard/v2/current',
'GET /api/config/v1/amplitude',
'GET /api/config/v1/azurespeech',
'GET /api/config/v1/backtrace',
'GET /api/config/v2',
'GET /api/consumables/v2/getUnlocked',
'GET /api/customAvatarItems/v1/featured',
'GET /api/customAvatarItems/v1/hot',
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
'GET /api/images/v1/slideshow',
'GET /api/images/v2/named',
'GET /api/images/v3/feed/player/{playerId}',
'GET /api/images/v4/player/{playerId}',
'GET /api/images/v4/room/{roomId}',
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
'GET /api/inventions/v1/room',
'GET /api/inventions/v1/tagfilters',
'GET /api/inventions/v1/toptoday',
'GET /api/inventions/v1/update',
'GET /api/inventions/v1/version',
'GET /api/inventions/v2/batch',
'GET /api/inventions/v2/mine',
'GET /api/inventions/v2/search',
'GET /api/inventions/v3/publish',
'GET /api/keepsakes/categories',
'GET /api/keepsakes/globalconfig',
'GET /api/keepsakes/rooms/{roomId}',
'GET /api/messages/v1/favoriteFriendOnlineStatus',
'GET /api/messages/v2/get',
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
'GET /api/relationships/v1/unfavorite',
'GET /api/relationships/v1/unignore',
'GET /api/relationships/v1/unmute',
'GET /api/relationships/v2/acceptfriendrequest',
'GET /api/relationships/v2/addfriend',
'GET /api/relationships/v2/get',
'GET /api/relationships/v2/removefriend',
'GET /api/relationships/v2/sendfriendrequest',
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/v4',
'GET /voice/config',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
'POST /api/relationships/v1/unfavorite',
'POST /api/relationships/v1/unignore',
'POST /api/relationships/v1/unmute',
'POST /api/relationships/v2/acceptfriendrequest',
'POST /api/relationships/v2/addfriend',
'POST /api/relationships/v2/removefriend',
'POST /api/relationships/v2/sendfriendrequest',
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
])
// Every operation carries a summary — an undescribed one renders as a bare path.
for (const [path, ops] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(ops)) {
expect(op.summary, `${method.toUpperCase()} ${path} has no summary`).toBeTruthy()
}
}
})
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d schema
// used in a response emits a $ref this hono-openapi + zod v4 setup does not always
// hoist, leaving a dangling reference that breaks the docs UI.
test('the spec has no $refs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
expect(raw.match(/\$ref/g)).toBeNull()
})
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
// otherwise show as the example value for every integer field (-9007199254740991).
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
// wired up.
test('integer fields carry a placeholder example', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
const integers = raw.match(/"type":"integer"/g) ?? []
expect(integers.length).toBeGreaterThan(0)
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})
// A ban follows the player, not just the account row it was written on: an evader makes
// a new account in seconds, so the block also reaches accounts sharing a PROVEN platform
// identity or an IP with a banned one. See bans-db.ts — and note the IP arm is the coarse
// one, which is why `BAN_EVASION_MATCH` can narrow or disable both linked arms.
describe('ban evasion', () => {
/** Seed an account with the IPs it signed up / last logged in from. */
const account = async (id: number, ips: { signupIp?: string; lastLoginIp?: string } = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Evader${id}`, ...ips }))
.run()
}
/** Link a proven platform identity to an account, as a verified login does. */
const link = async (id: number, platform: number, platformId: string) => {
await env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(id, platform, platformId, new Date().toISOString())
.run()
}
/** File a report against `playerId` and convert it into a ban. */
const ban = async (playerId: number, banExpires: string | null = null) => {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
await banFromReport(env.DB, row.id, { banExpires })
}
test('a banned account is matched directly', async () => {
await account(7001)
await ban(7001)
expect(await resolveBan(env.DB, 7001)).toMatchObject({ via: 'account', bannedAccountId: 7001 })
})
test('an unrelated account is not matched', async () => {
await account(7002, { signupIp: '198.51.100.9' })
await link(7002, 0, 'steam-clean')
expect(await resolveBan(env.DB, 7002)).toBeNull()
})
test('an account sharing a signup IP with a banned account is matched', async () => {
await account(7010, { signupIp: '203.0.113.7' })
await ban(7010)
await account(7011, { signupIp: '203.0.113.7' })
const match = await resolveBan(env.DB, 7011)
expect(match).toMatchObject({ via: 'ip', bannedAccountId: 7010 })
})
// The IPs are compared as SETS: the new account's last-login IP against the banned
// account's signup IP counts, which is the shape evasion actually takes (sign up
// somewhere else, come back to the same connection).
test('a last-login IP matching a banned signup IP is matched', async () => {
await account(7012, { signupIp: '203.0.113.20' })
await ban(7012)
await account(7013, { signupIp: '198.51.100.1', lastLoginIp: '203.0.113.20' })
expect(await resolveBan(env.DB, 7013)).toMatchObject({ via: 'ip', bannedAccountId: 7012 })
})
test('an account sharing a platform identity with a banned account is matched', async () => {
await account(7020)
await link(7020, 0, 'steam-76561')
await ban(7020)
await account(7021)
await link(7021, 0, 'steam-76561')
expect(await resolveBan(env.DB, 7021)).toMatchObject({ via: 'platform', bannedAccountId: 7020 })
})
// The same id on a DIFFERENT platform is a different person — ids are namespaced per
// platform, so the arm matches the pair, not the bare id.
test('the same platform id on another platform is not matched', async () => {
await account(7022)
await link(7022, 0, 'id-collision')
await ban(7022)
await account(7023)
await link(7023, 1, 'id-collision')
expect(await resolveBan(env.DB, 7023)).toBeNull()
})
// Two accounts that merely both lack an IP have nothing in common — "unknown" must
// never match "unknown", or every IP-less account would be banned by the first one.
test('accounts with no IP at all are not matched to each other', async () => {
await account(7030)
await ban(7030)
await account(7031)
expect(await resolveBan(env.DB, 7031)).toBeNull()
// Nor does an empty-string IP, which is what a login outside the CF edge stores.
await account(7032, { signupIp: '', lastLoginIp: '' })
expect(await resolveBan(env.DB, 7032)).toBeNull()
})
test('an expired ban reaches nobody, linked or not', async () => {
await account(7040, { signupIp: '203.0.113.40' })
await link(7040, 0, 'steam-expired')
await ban(7040, '2020-01-01T00:00:00.000Z')
await account(7041, { signupIp: '203.0.113.40' })
await link(7041, 0, 'steam-expired')
expect(await resolveBan(env.DB, 7040)).toBeNull()
expect(await resolveBan(env.DB, 7041)).toBeNull()
})
// The strongest evidence is reported: a player whose own account is banned is told
// that, not that their network was.
test('a direct ban outranks a linked one', async () => {
await account(7050, { signupIp: '203.0.113.50' })
await ban(7050)
await account(7051, { signupIp: '203.0.113.50' })
await ban(7051)
expect(await resolveBan(env.DB, 7051)).toMatchObject({ via: 'account', bannedAccountId: 7051 })
})
test('a platform match outranks an IP one', async () => {
await account(7060, { signupIp: '203.0.113.60' })
await ban(7060)
await account(7061)
await link(7061, 0, 'steam-both')
await ban(7061)
// 7062 shares an IP with 7060 and a platform identity with 7061.
await account(7062, { signupIp: '203.0.113.60' })
await link(7062, 0, 'steam-both')
expect(await resolveBan(env.DB, 7062)).toMatchObject({ via: 'platform', bannedAccountId: 7061 })
})
// A signup has no account yet — the identity the request carries is all there is to
// go on, and refusing it there is what stops the next account being created at all.
test('an identity with no account is matched on its IP and platform id', async () => {
await account(7070, { signupIp: '203.0.113.70' })
await link(7070, 0, 'steam-signup')
await ban(7070)
expect(await resolveBan(env.DB, null, { identity: { ip: '203.0.113.70' } })).toMatchObject({
via: 'ip',
bannedAccountId: 7070,
})
expect(
await resolveBan(env.DB, null, { identity: { platform: 0, platformId: 'steam-signup' } })
).toMatchObject({ via: 'platform', bannedAccountId: 7070 })
// An identity that matches nothing is not blocked.
expect(
await resolveBan(env.DB, null, {
identity: { ip: '198.51.100.200', platform: 0, platformId: 'steam-unknown' },
})
).toBeNull()
// And an identity carrying nothing at all can't be matched to anyone.
expect(await resolveBan(env.DB, null, { identity: {} })).toBeNull()
})
// The arms an operator can turn off — and the one they cannot.
test('BAN_EVASION_MATCH arms narrow the linked matching only', async () => {
await account(7080, { signupIp: '203.0.113.80' })
await link(7080, 0, 'steam-arms')
await ban(7080)
await account(7081, { signupIp: '203.0.113.80' }) // shares the IP only
await account(7082)
await link(7082, 0, 'steam-arms') // shares the identity only
const arms = (value: string | undefined) => ({ arms: banEvasionMatch(value) })
// Default: both arms reach.
expect(await resolveBan(env.DB, 7081, arms(undefined))).toMatchObject({ via: 'ip' })
expect(await resolveBan(env.DB, 7082, arms(undefined))).toMatchObject({ via: 'platform' })
// Platform only: the household bystander is let through, the evader isn't.
expect(await resolveBan(env.DB, 7081, arms('platform'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('platform'))).toMatchObject({ via: 'platform' })
// Off: neither linked arm reaches...
expect(await resolveBan(env.DB, 7081, arms('off'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('off'))).toBeNull()
// ...but the ban itself still applies to the account it was handed to.
expect(await resolveBan(env.DB, 7080, arms('off'))).toMatchObject({ via: 'account' })
})
test('banEvasionMatch reads the knob', () => {
expect(banEvasionMatch(undefined)).toEqual({ ip: true, platform: true })
expect(banEvasionMatch('ip,platform')).toEqual({ ip: true, platform: true })
expect(banEvasionMatch(' PLATFORM ')).toEqual({ ip: false, platform: true })
expect(banEvasionMatch('ip')).toEqual({ ip: true, platform: false })
expect(banEvasionMatch('off')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('none')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('')).toEqual({ ip: false, platform: false })
// `off` wins over anything else in the list, and a typo is ignored rather than
// fatal — this is read on the matchmake path.
expect(banEvasionMatch('off,ip')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ipv6')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ip,typo')).toEqual({ ip: true, platform: false })
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Moderator-issued player warnings on the shared `recflare` D1 database.
*
* The counterpart to the `report` table (see reports-db.ts): a report is what a
* player submits, a warning is what a moderator hands down. Same shape of storage —
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
* rows yet.
*
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
]
/** A stored warning row (snake_case columns, one row per warning issued). */
export interface WarningRow {
id: number
/** The moderator who issued it, from their bearer token. */
moderator_player_id: number
warned_player_id: number
report_category: number
/** What the warned player is shown, e.g. `Sexual gestures`. */
display_reason: string | null
/** Internal note — never surfaced to the warned player. */
moderator_note: string | null
created_at: string
}
/**
* A warning as issued — everything but the moderator (which comes from the bearer
* token) and the timestamp. Only the warned player is required; the rest are
* optional and stored as NULL when absent.
*/
export interface NewWarning {
moderatorPlayerId: number
warnedPlayerId: number
reportCategory?: number
displayReason?: string | null
moderatorNote?: string | null
}
/** Record an issued warning, returning the stored row (with its assigned id). */
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
const row = await db
.prepare(
`INSERT INTO warning (
moderator_player_id, warned_player_id, report_category,
display_reason, moderator_note, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.moderatorPlayerId,
input.warnedPlayerId,
input.reportCategory ?? 0,
input.displayReason ?? null,
input.moderatorNote ?? null,
new Date().toISOString()
)
.first<WarningRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every warning issued against a player, newest first. Backs a future moderation view. */
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
const { results } = await db
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<WarningRow>()
return results
}
+32
View File
@@ -0,0 +1,32 @@
{
"FeaturedPlayer": {
"Id": 1,
"TitleOverride": "",
"UrlOverride": null
},
"FeaturedRoomGroup": {
"FeaturedRoomGroupId": 2,
"Name": "Featured Rooms",
"Rooms": [
]
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://recflare.net"
},
"InstagramImages": [
{
"ImageName": "test.jpg",
"ImageUrl": "https://github.com/djdevin/recflare"
}
],
"Videos": [
{
"BlobName": "something.mp4",
"Title": "test",
"Description": "test description",
"ThumbnailBlobName": "something.jpg",
"SourceUrl": "https://www.youtube.com"
}
]
}
+4 -4
View File
@@ -33,19 +33,19 @@
"EndTime": null,
"Key": "AntiHile.DC",
"StartTime": null,
"Value": "true"
"Value": "false"
},
{
"EndTime": null,
"Key": "AntiHile.LPD",
"StartTime": null,
"Value": "true"
"Value": "false"
},
{
"EndTime": null,
"Key": "AntiHile.QD",
"StartTime": null,
"Value": "true"
"Value": "false"
},
{
"EndTime": null,
@@ -117,7 +117,7 @@
"EndTime": null,
"Key": "Backtrace.stopTimeUTC",
"StartTime": null,
"Value": "9999-09-28 23:55"
"Value": "2026-06-01 00:00"
},
{
"EndTime": null,
+14 -4
View File
@@ -21,17 +21,27 @@ export default defineConfig({
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records its last call so tests can assert the notification
// the worker pushed (type + payload); GET the DO to read it back.
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET the DO for the most recent one,
// GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
sent = []
async notifyPlayer(playerId, notificationType, data) {
this.last = { playerId, notificationType, data }
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
async fetch() { return Response.json(this.last ?? null) }
async fetch(request) {
if (request.method === 'DELETE') {
this.sent = []
return new Response(null, { status: 204 })
}
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
return Response.json(this.sent.at(-1) ?? null)
}
}
export default { fetch() { return new Response('ok') } }
`,
+7 -1
View File
@@ -19,11 +19,17 @@
}
],
// Image bucket shared with the `img` worker (which serves objects back by key).
// Saved-image uploads are written here.
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
// invention's uploaded data blob for its `BlobHash`.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
+148 -25
View File
@@ -1,26 +1,150 @@
# auth
Auth Worker served on the `auth` subdomain. A Hono app handling authentication.
Binding-dependent behavior (database queries) is stubbed for now — no real
KV/D1/DO bindings yet.
Auth Worker served on the `auth` subdomain (`auth.recflare.net`) — a Hono app that
authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ---------------------------------- |
| GET | `/eac/challenge` | EAC challenge, served as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Cached logins (stubbed → `[]`) |
| POST | `/connect/token` | OAuth token endpoint, issues a JWT |
| GET | `/role/developer/:id` | Developer role lookup (TODO) |
| 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) |
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`.
**The spec is descriptive, not enforced.** Nothing validates requests against it. That
is deliberate: this worker serves a protocol reverse-engineered from the Rec Room
client, and the handlers are intentionally lenient — every field is read as
`typeof body.x === 'string' ? body.x : ''`, and missing or malformed input generally
falls through to a graceful path rather than a 400. Which parts of that tolerance the
client actually depends on isn't fully known, so enforcing a schema would risk
rejecting requests that work today. Read a "required" field as _the client always
sends it_, not _the server rejects it if absent_.
A test asserts that every route the worker serves appears in the spec, so adding a
route without documenting it fails rather than silently shipping an incomplete spec.
## Grants
`POST /connect/token` selects behavior from `grant_type`:
- **`create_account`** — mints an account with an auto-assigned random username and
places the player in the Orientation room (RoomId 13), which the client enters
without matchmaking. A posted `password` becomes the login credential.
- **`cached_login`** — logs into an already-linked account using platform ownership as
the credential; no password. The posted `account_id` must be linked to exactly the
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
all, which is what closes id/username-only takeover. When it also carries a verifying
`platform_auth`, that identity is **linked** to the account (see below).
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
asserts a platform) must be a platform we can verify. Two are:
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
Steam's signature against Steam's system public key. Verified **offline**: no
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
client-supplied `platform_id`.
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
to a user id, so here the posted `platform_id` is an _input_ to the check and a
spoofed one fails. This means an outbound request on every Meta login, and no Meta
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client.
Everything else is refused. Whichever platform, the identity that gets bound or linked
is the verified one, never the raw `platform_id` field.
### One account, many platform identities
An account can be reached from several platform identities — a player's PC and their
headset both open the same account, with no password after the first time. The links
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
(platform, platform id, account).
That table is the **one source of truth** for both halves of a cached login: the picker
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
`cached_login` grant asks it whether the account it was handed is linked to the identity
just proven. They used to be two separate checks over the account blob's single
`platformId`, which could disagree — the client would be offered an account that then
answered "no linked account" forever.
A second device is linked by **logging in with a password there**: the client posts its
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
verified identity is ever linked, since a link is a password-free way into the account.
A proof that doesn't verify never fails the login — it just leaves that device without a
cached login.
The account blob keeps `platform`/`platformId` as the account's **primary** identity
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
of one identity per account invites exactly the picker/grant disagreement above. Look
identities up in `platform_account`.
## Signup caps
`create_account` is capped on two independent arms, per verified platform identity and
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
arm is coarse and will produce false positives behind NAT, shared campus and mobile
networks. Both default to 3.
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
limit could otherwise have accounts created for it with a password and link its way into
all of them. Hitting it never fails a password login — the account just doesn't get a
cached login on that device.
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
small private server, or when a shared network is being locked out.
## Bindings
| Binding | Type | Notes |
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
table, so they stay independent of the `rooms` worker's migrations on the same
database. Run them with `pnpm -F auth migrate`.
## Signing key
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root
`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"`
`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
Tokens are signed HS256 with the `JWT_SECRET` binding (see `@repo/jwt`), resolved at
request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
**Cloudflare Secrets Store** that every worker binds, so `auth`-signed tokens verify in
`rooms`, `api`, `match`, etc. The store id is kept out of source in the root `.env` as
`RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` `store_id`
placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
If the secret resolves empty, the worker refuses to issue a token at all rather than
sign one with an empty key — every worker validates against that same key, so an
empty-key token would be forgeable by anyone.
One-time setup (needs Cloudflare auth):
@@ -30,20 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
# sign-ins then answer 500 until it holds the real value.
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
```
Rotating the store value invalidates all existing tokens (clients re-authenticate).
## Notes / TODO
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
filesystem) — replace `EAC_CHALLENGE` with the real challenge text.
- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB
binding to be implemented.
- `/role/developer/:id` is a stub (`// TODO: implement`).
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
The Meta secret is read per request, so updating it takes effect without a redeploy.
@@ -0,0 +1,8 @@
-- Drop the platform identity from refresh_tokens. A refreshed access token now takes
-- `platform`/`platform_id` from the account, which is where the bound identity lives —
-- the copy stored at issue time was redundant, and went stale if the account's
-- identity changed mid-session. Kept in sync with REFRESH_SCHEMA_DDL in
-- src/refresh-db.ts.
ALTER TABLE refresh_tokens DROP COLUMN platform;
ALTER TABLE refresh_tokens DROP COLUMN platform_id;
@@ -0,0 +1,35 @@
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
-- PC and a headset gets a cached login on both. The account blob's single
-- `platformId`/`platform` pair could only hold one, so logging in on the second
-- device meant a password every time.
--
-- Links move into their own table, which becomes the one source of truth for both
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
-- stay as the account's *primary* identity — the first one linked — for the account
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
-- because nothing ever defaulted that field: an account can carry a platformId with no
-- platform recorded, and back when Steam was the only verifiable platform an unset one
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
-- tests run.
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> '';
@@ -0,0 +1,23 @@
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
-- one source of truth for the login picker and the `cached_login` grant. The column's
-- last reader was 0007's own backfill, which has already run.
--
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
-- open?" — it only ever holds the account's primary identity, so an account reachable
-- from a PC and a headset appears here under one of them. That is exactly the split
-- that used to have the picker offer an account the grant then refused.
--
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
-- generated column and its index only — a virtual column stores nothing, so no account
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
--
-- Safe to run before or after the deploy that ships it: no worker queries this column,
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
-- run again; the exported copy selects the blob instead so tests keep working.)
DROP INDEX IF EXISTS idx_accounts_platform_id;
ALTER TABLE account DROP COLUMN platform_id;
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+920 -339
View File
@@ -1,49 +1,106 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
// 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 { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
PlatformIdsRequest,
PlatformType,
roleLookup,
TokenRequest,
TokenResponse,
} from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** Platform-type enum names by value, used for the token's `platform` claim. */
const PLATFORM_TYPES: Record<number, string> = {
[-1]: 'All',
0: 'Steam',
1: 'Oculus',
2: 'PlayStation',
3: 'Xbox',
4: 'RecNet',
5: 'IOS',
6: 'GooglePlay',
7: 'Standalone',
8: 'Pico',
}
/**
* The `error_description` a banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
* this exact string to put a real sentence in front of a player anything varying would
* fall through to the generic "you could not be signed in". Keep the two in sync.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The refusal when it is not THIS account that is banned but one it shares an identity
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
* account being refused may be an innocent housemate of a banned player, so telling them
* "this account is banned" would be a lie, and naming the account we matched them to
* would hand out somebody else's moderation record.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report and every sideloaded
* headset reports this same value. Two things follow, and both are enforced below:
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
* - it is therefore never LINKED to an account. A link is a password-free way in, so
* one link on a shared id would open that account to every sideloaded build.
* It exists only to get such a client onto the username/password login screen.
*/
const SIDELOAD_PLATFORM_ID = '1'
/**
* The canned entry served for the one Oculus cached-login lookup below the sideloaded
* APK's way onto the password login screen. Not backed by a link, an account or a
* platform proof, hence `requirePassword: true`.
*/
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: SIDELOAD_PLATFORM_ID,
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
} as const
/**
* Signup caps, enforced on create_account only (never on login an existing account
@@ -90,12 +147,11 @@ async function placeNewPlayerInOrientation(
accountId: number,
deviceClass: number
): Promise<void> {
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(ORIENTATION_ROOM_ID)
.first<{ data: string }>()
if (!row) return
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
// live in the room blob), so the Orientation scene resolves the same way match does.
const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID)
if (!room) return
const room = JSON.parse(row.data) as Record<string, unknown>
const subRooms = room.SubRooms
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
Record<string, unknown> | undefined
@@ -108,7 +164,7 @@ async function placeNewPlayerInOrientation(
subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: RoomInstanceType.Public,
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
dataBlob: subRoomDataBlob(sub),
eventId: 0,
clubId: 0,
roomCode: '',
@@ -129,7 +185,7 @@ async function placeNewPlayerInOrientation(
deviceClass,
vrMovementMode: 1,
platform: 0,
appVersion: '20230302',
appVersion: GAME_VERSION,
})
}
@@ -139,63 +195,201 @@ async function authedId(c: Context<App>): Promise<number | null> {
}
/**
* The elevated role names for an account's token `role` claim, derived from its
* role flags. Base roles (gameClient) are added by generateToken these are only
* the operator-granted extras. Order is stable so tokens are deterministic.
* 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.
*/
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
if (!account) return []
const roles: string[] = []
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
): 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')
return roles
}
/**
* The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM,
* read out of the same claims dictionary as `role` it never belongs in `scope`. The
* client knows exactly two values, both chat restrictions, and both ride on a junior
* account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which
* drops the claim rather than sending a blank one.
*/
function accountPrivileges(account: Pick<Account, 'isJunior'> | null): string[] {
return account?.isJunior ? ['BanVChat', 'BanRmChat'] : []
}
/**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no
* platform recorded and Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
* platform recorded and until Meta verification landed Steam was the only identity
* we could prove, so an unset one *is* Steam. Every account bound since records its
* platform explicitly; this default only covers those older rows.
*/
function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* Whether an account is the one linked to a given platform identity the single
* check behind both the cached-login picker and the `cached_login` grant. It lives in
* one place on purpose: if the picker offers an account the grant then rejects, the
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity (the SteamID64 from a verified
* platform_auth ticket), never the client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
platform: number,
platformId: string
): boolean {
if (!account.platformId || platformId === '') return false
return account.platformId === platformId && accountPlatform(account) === platform
}
/**
* Project a linked account into the client's CachedLogin DTO the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the platform_auth ticket) is the credential for a cached login no prompt.
* (the verified `platform_auth`) is the credential for a cached login no prompt.
*
* The platform and id come from the LINK, not from the account: an account linked to
* both a Steam and a Meta identity appears in both pickers, and each has to report the
* identity that picker was asked about that's what the client posts back, and what
* the grant then checks the link against.
*/
function toCachedLogin(account: Account) {
function toCachedLogin(account: Account, link: PlatformLink) {
return {
platform: accountPlatform(account),
platformId: account.platformId ?? '',
platform: link.platform,
platformId: link.platformId,
accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false,
}
}
/**
* Project a set of links into picker entries, dropping any whose account no longer
* exists. One batched account read rather than one per link.
*
* Order follows the links (oldest first), so the picker is stable between launches.
*/
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
if (links.length === 0) return []
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
const byId = new Map(accounts.map((a) => [a.accountId, a]))
return links.flatMap((link) => {
const account = byId.get(link.accountId)
return account ? [toCachedLogin(account, link)] : []
})
}
/**
* Link the platform identity a password login proved to the account it logged into,
* so the next launch on that device is a cached login. Called only with a VERIFIED
* identity a link is a password-free way into the account.
*
* Already linked is the common case (every subsequent login on that device) and costs
* one read and nothing else.
*
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
* an identity could otherwise sit at the limit, have accounts created for it with a
* password, and link its way into all of them. Reaching it does NOT fail the login
* the password was valid it just leaves the account without a cached login, so the
* player types their password each time rather than being locked out.
*
* The first identity linked also becomes the account's primary (the blob's
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
* claims report. Later platforms link without disturbing it.
*/
async function linkLoginIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
maxAccountsPerIdentity: number
): Promise<void> {
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
if (
maxAccountsPerIdentity > 0 &&
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
) {
logger.info('platform link refused: account limit reached for this platform identity', {
accountId,
platform,
platformId,
})
return
}
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
logger.info('linked platform identity to account', { accountId, platform, platformId })
const account = await getAccount(db, accountId)
if (account && !account.platformId) {
await updateAccount(db, accountId, { platform, platformId })
}
}
/**
* What a login's `platform_auth` proved, if anything. Failures are split because the
* callers act on them differently: a grant that authenticates BY platform identity has
* to refuse, while a password grant which has already proven who it is carries on
* and just doesn't link.
*
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
* and is the one case that warrants a 5xx.
*/
type PlatformProof =
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
| { status: 'none' }
| { status: 'verified'; platform: number; platformId: string }
| { status: 'unsupported' }
| { status: 'unconfigured' }
| { status: 'rejected'; reason: string }
/**
* Verify a login's `platform_auth` and return the identity it proves.
*
* The two verifiable platforms prove the id in opposite directions, which is why they
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
* so the posted `platform_id` is discarded. Meta's nonce carries nothing it is
* validated *against* the posted `platform_id`, so that field is an input, and a
* spoofed one fails validation rather than being ignored. Either way the id that comes
* back is proven, never the raw client-supplied field, and only a proven id is ever
* written to an account or linked to one.
*/
async function verifyPlatformProof(
env: App['Bindings'],
platform: number,
platformAuth: string,
postedPlatformId: string
): Promise<PlatformProof> {
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
// password grant, which is the path such a client actually takes. Linking it would
// hand every sideloaded headset a password-free login into that account, since they
// all report this same id.
//
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
// password grant carries its own credential and only *links* on a verified proof), it
// just never gets a cached login, so they type their password each launch. That is
// the intended shape of the sideload flow.
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
}
if (platform === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
}
if (platform === PlatformType.Oculus) {
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
// holding an empty/placeholder value) — the same misconfiguration from the player's
// side, so it takes the same branch.
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') return { status: 'unconfigured' }
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
return {
status: 'verified',
platform: PlatformType.Oculus,
platformId: verified.identity.userId,
}
}
return { status: 'unsupported' }
}
const app = new Hono<App>()
.use(
'*',
@@ -207,341 +401,696 @@ const app = new Hono<App>()
})(c, next)
)
// DEBUG: dump every incoming request (method, url, headers, body). Clone the raw
// request so reading the body here doesn't consume the stream the handlers need.
.use('*', async (c, next) => {
const clone = c.req.raw.clone()
const body = await clone.text().catch(() => '')
logger.info('incoming request', {
method: c.req.method,
url: c.req.url,
headers: Object.fromEntries(c.req.raw.headers),
body,
})
await next()
})
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
.get('/eac/challenge', (c) => c.text(`"AA=="`))
.get(
'/eac/challenge',
describeRoute({
tags: ['EAC'],
summary: 'Easy Anti-Cheat challenge',
description:
'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.',
responses: {
200: {
description: 'The challenge, JSON-quoted, as text/plain',
content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } },
},
},
}),
(c) => c.text(`"AA=="`)
)
// Cached logins for a platform id — the accounts linked to this platform-native
// id, so the client can offer them on the login screen (and post one back as a
// cached_login grant). No linked account → [], and the client falls back to a
// fresh login / create_account.
.get('/cachedlogin/forplatformid/:platform/:id', async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
accounts
.filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id))
.map(toCachedLogin)
)
})
.get(
'/cachedlogin/forplatformid/:platform/:id',
describeRoute({
tags: ['Cached login'],
summary: 'Accounts linked to a platform id',
description: [
'Accounts the client may offer on its login screen for this platform identity —',
'the links this identity has, so an entry here is always redeemable by a',
'`cached_login` grant (both read the same table). An account linked to several',
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
'and the client falls back to a fresh login or create_account.',
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
'with `requirePassword: true`, sending the build to username/password login.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
schema: { type: 'string' },
},
{
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
schema: { type: 'string' },
},
],
responses: {
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
),
},
}),
async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
// produce a real Meta identity or a nonce to prove one with — it asks about the
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
// login screen with nothing to do. Hand back one canned entry to push it onto the
// username/password login instead, which is the only flow such a build can finish.
// `requirePassword` is true for exactly that reason: there's no platform proof here,
// and the `cached_login` grant would (correctly) refuse this entry.
//
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
// real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers.
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN])
}
// Listed straight from the link table, which is also what the `cached_login`
// grant authorizes against — so the picker can't offer an account the grant
// then refuses.
const links = Number.isNaN(platformInt)
? await getLinksForPlatformId(c.env.DB, id)
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
return c.json(await toCachedLogins(c.env.DB, links))
}
)
// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
// repeated `id=` params on the auth host; resolve each to its linked accounts.
.post('/cachedlogin/forplatformids', async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
.post(
'/cachedlogin/forplatformids',
describeRoute({
tags: ['Cached login'],
summary: 'Bulk cached-login lookup (friends resolution)',
description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the',
'response cannot be mapped back to a specific input id — the client uses each',
'entrys own `platformId`. No platform accompanies these ids, so each matches on',
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
}),
async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
// No platform accompanies these ids, so they match on any platform.
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
}
return c.json(out)
}
return c.json(out)
})
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => {
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// `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 : ''
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
.post(
'/connect/token',
describeRoute({
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,',
'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',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
'link for that identity cannot be cached-logged-into.',
'',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
'',
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
'that verifies, that identity is LINKED to the account — this is how a player who',
'signed up on one platform gets a cached login on a second device. The login is',
'never failed over the link: an unverifiable proof (or one over the per-identity',
'cap) just leaves the account without a cached login there.',
'',
'**Platform identity.** An account can be reached from several platform identities;',
'the links are the one thing both the picker and `cached_login` consult, and only a',
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
'when it is unset. The first identity linked also becomes the accounts primary',
'(what the account DTO and a refreshed token report); later ones only link.',
'',
'The one platform id that is never verified and never linked is `1` on platform `1`',
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
'build reports it, so it identifies nobody. A password login that carries it still',
'succeeds; it simply links nothing, and the player types their password each launch.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `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',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null.',
'',
'The refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operators `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
'Union of all grants; see the description for per-grant requirements'
),
responses: {
200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or a banned account',
].join(' ')
),
500: json(
OAuthError,
[
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
'(no nonce can be validated without it).',
].join(' ')
),
},
}),
async (c) => {
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// `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 : ''
const platformInt =
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
// The token's `platform` claim is the PlatformType int. A grant that asserts no
// platform (a password login) falls back to Steam/0, the same default the account
// itself carries — see `accountPlatform`.
let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt
// The device this login came from. The client posts both on every grant; they're
// unverified (client-picked) so they're recorded on the account, never trusted as
// a credential. Stored on account creation AND refreshed on each successful login,
// so the account's device tracks the player across devices — the raw material for
// linking accounts that share a device later.
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
const deviceClassInt =
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The device this login came from. The client posts both on every grant; they're
// unverified (client-picked) so they're recorded on the account, never trusted as
// a credential. Stored on account creation AND refreshed on each successful login,
// so the account's device tracks the player across devices — the raw material for
// linking accounts that share a device later.
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
const deviceClassInt =
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== 0) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
// verifyPlatformProof). Only a verified identity is ever bound or linked.
//
// Two grants are GATED on it — they have no other credential, so an unverifiable
// platform is fatal:
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
// (see below), which is how a player who created an account on Steam gets a cached
// login on their headset. If it doesn't verify, the login still succeeds — it just
// links nothing, because a link is a password-free way into the account and must
// never rest on an unproven id.
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' },
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
}
const platformAsserted = !Number.isNaN(platformInt)
const gatedOnPlatform =
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
// The password grant only spends a verification when the client actually offered
// one; the rest of the time there is nothing to link.
const proof: PlatformProof =
gatedOnPlatform || (platformAsserted && platformAuth !== '')
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
: { status: 'none' }
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// A `password` may be posted to establish the account's login credential.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: a credential login. The request identifies the account by
// `username` (RecRoom's password grant posts the username, not the id) or a
// numeric `account_id`, and MUST post the account's correct `password`. An
// account with no password set can't be logged into (no credential to verify)
// — closing the id/username-only takeover. New accounts establish a password
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// 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
// 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(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
{
error: 'invalid_grant',
error_description: 'account limit reached for this platform account',
},
400
)
let verifiedPlatformId: string | null = null
let verifiedPlatform: number | null = null
if (proof.status === 'verified') {
verifiedPlatform = proof.platform
verifiedPlatformId = proof.platformId
} else if (proof.status !== 'none') {
// Log every failure, including the ones a password grant shrugs off: a player
// who silently never gets a cached login on their headset has no other symptom,
// and this line is where "Meta rejected the nonce" becomes visible.
logger.info('platform_auth not verified', {
platform: platformInt,
platformId,
grantType,
status: proof.status,
reason: proof.status === 'rejected' ? proof.reason : undefined,
})
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
if (gatedOnPlatform && proof.status !== 'verified') {
if (proof.status === 'unsupported') {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam and Meta can be verified',
},
400
)
}
if (proof.status === 'unconfigured') {
// An operator misconfiguration, not the client's fault: without the app secret
// every Meta player is locked out, so it answers 500 the way an unset
// JWT_SECRET does below rather than blaming the credential. (We never fall
// back to trusting the posted id — that would let anyone log into any
// Meta-linked account by naming its user id.)
logger.error('refusing a Meta login: META_APP_SECRET is empty')
return c.json(
{
error: 'server_error',
error_description: 'Meta platform verification is not configured',
},
500
)
}
// The reason is for the operator; the client is told only that it was rejected.
// A wrong app secret and a stale nonce look identical from the client side.
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this network',
},
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
400
)
}
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
// From here on `platformId` is the PROVEN identity wherever there is one — the
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
// never the raw client-supplied field.
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// A `password` may be posted to establish the account's login credential.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: a credential login. The request identifies the account by
// `username` (RecRoom's password grant posts the username, not the id) or a
// numeric `account_id`, and MUST post the account's correct `password`. An
// account with no password set can't be logged into (no credential to verify)
// — closing the id/username-only takeover. New accounts establish a password
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A banned player's next move is a new account, so the ban is checked BEFORE
// one is minted — against the only identity a signup has, the IP it came from
// and the platform identity it just proved. Refusing after the fact (as the
// shared check below would) still refuses the token, but leaves the account
// row behind and burns a slot off both signup caps, so the evader gets to keep
// making them.
//
// Nothing here can match the account arm (there is no account yet), so this is
// purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open —
// which is the honest default position: a server that won't accept the IP arm's
// false positives is choosing to let evaders re-register.
const blocked = await resolveBan(c.env.DB, null, {
identity: {
ip: clientIp,
platform: verifiedPlatform,
platformId: verifiedPlatformId,
},
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (blocked) {
logger.info('signup refused: player banned', {
via: blocked.via,
bannedAccountId: blocked.bannedAccountId,
ip: clientIp,
platformId: verifiedPlatformId,
})
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
// 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
// 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(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
return c.json(
{
error: 'invalid_grant',
error_description: 'account limit reached for this platform account',
},
400
)
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this network',
},
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)
// binds nothing. The account blob keeps this first identity as its PRIMARY one
// (for the account DTO and the refresh grant's claims); the link written just
// below is what a later cached login is actually authorized against.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
accountId = String(account.accountId)
if (verifiedPlatformId !== null) {
await linkPlatformIdentity(
c.env.DB,
account.accountId,
verifiedPlatform ?? 0,
verifiedPlatformId
)
}
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
if (!refreshed) {
return c.json(
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
400
)
}
// `platform`/`platform_id` aren't stored with the token — they're taken from
// the account below, so a refreshed token always reflects the identity the
// account is bound to now.
accountId = String(refreshed)
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
// table says that account is linked to exactly this platform identity — this is
// the check that keeps anyone but that platform user out of the account
// (platform ownership is the credential; no password needed). An account with no
// link for the presented identity must use a password.
//
// The picker lists straight from the same table, so it can only offer accounts
// this check accepts.
//
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
// the ticket, or the Meta user id the nonce validated against — never the raw
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
return c.json(
{
error: 'invalid_grant',
error_description: 'no linked account for this platform identity',
},
400
)
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
// is trimmed off the posted value).
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
let resolvedId: number | null = null
if (/^\d+$/.test(postedId)) {
resolvedId = Number(postedId)
} else if (postedUsername !== '') {
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
}
if (resolvedId === null) {
return c.json(
{ error: 'invalid_request', error_description: 'account_id or username is required' },
400
)
}
// The account's password MUST be presented and match. An account with no
// stored hash has no credential to authenticate against, so login is refused
// — this closes the id/username-only takeover.
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
const password = typeof body.password === 'string' ? body.password : ''
if (!storedHash || !(await verifyPassword(password, storedHash))) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
400
)
}
accountId = String(resolvedId)
// The password proved the account; the platform proof (when the client sent one
// and it verified) proves the device's platform identity. Linking the two is
// what gives a player who signed up on Steam a cached login on their headset —
// they type their password once there, and never again.
if (verifiedPlatformId !== null) {
await linkLoginIdentity(
c.env.DB,
resolvedId,
verifiedPlatform ?? 0,
verifiedPlatformId,
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
)
}
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
//
// 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.
//
// 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.
const ban = await resolveBan(c.env.DB, Number(accountId), {
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
accountId = String(account.accountId)
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
if (!refreshed) {
return c.json(
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
400
)
}
accountId = String(refreshed.accountId)
platform = refreshed.platform
platformId = refreshed.platformId
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
if (ban) {
logger.info('token refused: player banned', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json(
{
error: 'invalid_grant',
error_description: 'no linked account for this platform identity',
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
},
400
)
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
// is trimmed off the posted value).
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
let resolvedId: number | null = null
if (/^\d+$/.test(postedId)) {
resolvedId = Number(postedId)
} else if (postedUsername !== '') {
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
}
if (resolvedId === null) {
return c.json(
{ error: 'invalid_request', error_description: 'account_id or username is required' },
400
)
}
// The account's password MUST be presented and match. An account with no
// stored hash has no credential to authenticate against, so login is refused
// — this closes the id/username-only takeover.
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
const password = typeof body.password === 'string' ? body.password : ''
if (!storedHash || !(await verifyPassword(password, storedHash))) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
400
)
}
accountId = String(resolvedId)
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
// binding) would still yield a well-formed token — but one signed with an empty
// key, which every worker validates against, so anyone could forge it. Refuse to
// issue a token at all rather than complete the login with a forgeable credential.
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return c.json(
{ error: 'server_error', error_description: 'token signing is not configured' },
500
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
// binding) would still yield a well-formed token — but one signed with an empty
// key, which every worker validates against, so anyone could forge it. Refuse to
// issue a token at all rather than complete the login with a forgeable credential.
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return c.json(
{ error: 'server_error', error_description: 'token signing is not configured' },
500
)
}
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
// A refresh grant posts no platform of its own, so the identity comes off the
// account — the same read, and the only place the bound identity is authoritative.
if (grantType === 'refresh_token' && roleAccount) {
platform = accountPlatform(roleAccount)
platformId = roleAccount.platformId ?? ''
}
const accessToken = await generateToken(
accountId,
platformId,
platform,
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount)
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
}
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
const accessToken = await generateToken(
accountId,
platformId,
platform,
jwtSecret,
accountRoles(roleAccount)
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, {
accountId: Number(accountId),
platform,
platformId,
})
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
})
)
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
// row (the raw password is never persisted). When the account already has a
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
// empty (as the client sends).
.post('/account/me/changepassword', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/account/me/changepassword',
describeRoute({
tags: ['Account'],
summary: "Change the caller's password",
description: [
'Stores a PBKDF2 hash on the account row; the raw password is never persisted.',
'When the account already has a password, `oldPassword` must match. The first time',
'a password is set, `oldPassword` is empty — which is what the client sends.',
].join(' '),
security: [{ bearerAuth: [] }],
requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
responses: {
200: json(ChangePasswordResponse, 'Password changed'),
400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'),
401: { description: 'Missing or invalid bearer token (empty body)' },
404: { description: 'The account no longer exists (empty body)' },
},
}),
async (c) => {
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 oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (newPassword === '') {
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (newPassword === '') {
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
}
const currentHash = await getPasswordHash(c.env.DB, id)
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
}
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
return c.json({ success: true })
}
const currentHash = await getPasswordHash(c.env.DB, id)
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
}
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
return c.json({ success: true })
})
)
// Developer role lookup. Returns a bare JSON boolean (the client reads the body as
// a bool), and 404s for an unknown player — mirroring the reference API. The role
// 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', async (c) => {
.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)
@@ -553,7 +1102,7 @@ const app = new Hono<App>()
// 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', async (c) => {
.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)
@@ -562,4 +1111,36 @@ const app = new Hono<App>()
return c.json(account.isModerator === true)
})
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare auth',
version: '1.0.0',
description: [
'Authentication and token issuance for recflare, a private-server reimplementation',
'of the Rec Room backend.',
].join('\n'),
},
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+19
View File
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
// only to authenticate US to Meta's graph API when validating a login nonce (see
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
// offline, Meta logins are impossible without it, so an empty value fails those
// logins with a 500 rather than silently trusting the client's platform_id.
META_APP_SECRET: SecretsStoreSecret
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
@@ -19,6 +26,18 @@ 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
/**
* 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
* that share a proven platform identity or an IP with the banned one, and refuses a
* signup from either, which is what stops an evader simply making a new account.
*
* The `ip` arm is coarse (households, NAT, campus and carrier networks share one
* address), so `platform` alone is the setting for a server whose players share
* networks. Whatever this says, a ban always applies to the account it was handed to.
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
}
/** Variables can be extended */
+172
View File
@@ -0,0 +1,172 @@
/**
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
*
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
* nonce that means nothing on its own. The only way to know it is genuine is to ask
* Meta which is why this path makes an outbound request on every Meta login and
* cannot work at all without the app secret.
*
* A Meta login posts
*
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
* platform_id = <the Meta user id>
*
* and validation is what BINDS those two together: `user_nonce_validate` answers
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
* an *input* here rather than something read out of a ticket, and a spoofed one fails
* a nonce Meta issued to user A does not validate as user B. The id is therefore
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
* account. (It's an app-scoped id: it identifies the player within this app only.)
*
* The `AppId` comes from the payload rather than config because it must be the app the
* nonce was issued for a different one simply fails, since the access token below
* pairs it with our secret. `Source` is informational and ignored.
*
* Shape and retry policy follow the reference Go server's utils/oculus.go.
*/
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
/**
* Graph error codes worth retrying 1 (unknown) and 2 (service temporarily
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
* real answer and retrying it just delays a login that is going to fail anyway.
*/
const TRANSIENT_ERROR_CODES = new Set([1, 2])
/**
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
* the client on a headset loading screen.
*/
const MAX_ATTEMPTS = 3
/** The trustworthy identity proven by a validated nonce. */
export interface VerifiedMetaIdentity {
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
userId: string
/** The Meta app the nonce was issued for. */
appId: string
}
/**
* The outcome of a verification. Failures carry a `reason` for the server log: the
* client is told only that its platform_auth was rejected (it can't act on more), but
* an operator debugging a headset that won't log in needs to know whether Meta said
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
*/
export type MetaVerification =
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
export interface MetaPlatformAuth {
nonce: string
appId: string
}
/**
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
* numeric it is interpolated into the access token below, and this is what keeps a
* client-supplied string out of that credential.
*/
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
let parsed: { Nonce?: unknown; AppId?: unknown }
try {
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
} catch {
return null
}
const { Nonce: nonce, AppId: appId } = parsed
if (typeof nonce !== 'string' || nonce === '') return null
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
return { nonce, appId }
}
/** The graph response we care about; everything else in the body is ignored. */
interface NonceValidateResponse {
is_valid?: boolean
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
}
/** One validation round-trip. `retryable` says whether another attempt could differ. */
async function validateOnce(
form: URLSearchParams,
fetcher: typeof fetch
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
let res: Response
try {
res = await fetcher(NONCE_VALIDATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
})
} catch (err) {
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
}
let body: NonceValidateResponse
try {
body = (await res.json()) as NonceValidateResponse
} catch {
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
// verdict — treat it the way a dropped connection is treated.
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
}
if (body.error) {
const { code, message, is_transient } = body.error
return {
ok: false,
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
}
}
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
return { ok: true, retryable: false, reason: '' }
}
/**
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
* was issued to that user for that app.
*
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
* Meta login can be verified, so callers must treat an unset secret as a server
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
* run the retry and response handling without reaching the network.
*/
export async function verifyMetaNonce(
platformAuth: string,
userId: string,
appSecret: string,
fetcher?: typeof fetch
): Promise<MetaVerification> {
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
// The user id is what the nonce is checked against, so an absent or non-numeric one
// can't be verified — reject before spending a round-trip on it.
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
const auth = parseMetaPlatformAuth(platformAuth)
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
// *app*, which is why the secret never leaves the server.
const form = new URLSearchParams({
nonce: auth.nonce,
user_id: userId,
access_token: `OC|${auth.appId}|${appSecret}`,
})
// Resolved per call, not at module load, so a test's stubbed global is honoured.
const doFetch = fetcher ?? globalThis.fetch
let last = { ok: false, retryable: false, reason: 'not attempted' }
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
last = await validateOnce(form, doFetch)
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
if (!last.retryable || attempt === MAX_ATTEMPTS) break
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
}
return { ok: false, reason: last.reason }
}
+222
View File
@@ -0,0 +1,222 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the auth worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
*
* That is deliberate, not an oversight. This worker serves a reverse-engineered
* protocol: the Rec Room client is the only real consumer, and the handlers are
* intentionally lenient every field is read as
* `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls
* through to a graceful path rather than a 400. Which parts of that tolerance the
* client actually depends on is not fully known, so enforcing a schema would risk
* rejecting requests that work today, for a client that is hard to debug against.
*
* So: these schemas record what the client is *observed* to send and what we send
* back. If you want to enforce one, do it per-route and land a test with it.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Emit a zod schema as an `application/x-www-form-urlencoded` request body.
*
* Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema
* and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped
* (not meaningful in an OpenAPI schema position), as is `additionalProperties: false`
* these handlers read the fields they know and ignore the rest, so claiming a
* closed object would misreport the server as stricter than it is.
*/
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return {
description,
content: {
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries
// `~standard` and every draft keyword), so the two never match structurally
// even though the emitted value is valid OpenAPI 3.1. Cast at the boundary.
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
},
}
}
/**
* PlatformType, the client's platform enum. Declaration order is wire order, and is
* the single source for the schema and description below. The `platform` form field
* is posted as the integer; the token's `platform` claim carries the name.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified
* see the platform-auth notes on `POST /connect/token`.
*/
export const PlatformTypeSchema = z
.union([
z.literal(-1),
z
.int()
.min(0)
.max(Math.max(...Object.values(PlatformType))),
])
.describe(
Object.entries(PlatformType)
.map(([name, value]) => `${value} ${name}`)
.join(', ')
)
/**
* One entry on the client's login screen, from `toCachedLogin` an account platform
* identity LINK, not an account. An account linked to two platforms yields one entry in
* each of their pickers, each reporting the identity that picker was asked about.
*/
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z
.string()
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z
.literal(false)
.describe('Always false — platform ownership is the credential for a cached login'),
})
/**
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
* but `requirePassword` is true with no Meta SDK there is nothing to prove platform
* ownership with, so the client falls through to username/password.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
})
/** 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_description: z.string(),
})
/** Successful `POST /connect/token` body. */
export const TokenResponse = z.object({
access_token: z.string().describe('Signed JWT; `sub` is the account id'),
expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'),
token_type: z.literal('Bearer'),
refresh_token: z
.string()
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
scope: z.string().describe('Space-separated granted scopes'),
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
})
/**
* `POST /connect/token` form body the union of every grant's fields, since
* OpenAPI cannot express "these fields iff grant_type=X" without splitting the
* endpoint. Per-grant requirements are spelled out in the route description.
*/
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'),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
.optional()
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
password: z
.string()
.optional()
.describe('Required on a password grant. On create_account, sets the initial password'),
platform: z.string().optional().describe('PlatformType as an integer string'),
platform_id: z
.string()
.optional()
.describe(
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
'the id the nonce is validated against, so it must be the real (numeric) user id'
),
platform_auth: z
.string()
.optional()
.describe(
'Platform proof, required for cached_login and platform create_account, and used to ' +
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
.optional()
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
device_class: z.string().optional().describe('Integer string; defaults to 0'),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
oldPassword: z
.string()
.optional()
.describe('Must match when the account already has a password; empty when first setting it'),
})
/** `POST /account/me/changepassword` response body. */
export const ChangePasswordResponse = z.object({
success: z.boolean(),
error: z.string().optional(),
})
/**
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
* Both return a BARE JSON boolean rather than an object the client reads the whole
* body as a bool and 404 an unknown player, mirroring the reference API.
*/
export function roleLookup(role: 'developer' | 'moderator') {
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.`,
parameters: [
{
name: 'id',
in: 'path' as const,
required: true,
description: 'Account id. A non-numeric value is treated as unknown (404).',
schema: { type: 'string' as const },
},
],
responses: {
200: json(z.boolean(), `\`true\` if the player has the ${role} role`),
404: { description: 'No such player (empty body)' },
},
}
}
/** 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'),
})
+198
View File
@@ -0,0 +1,198 @@
/**
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0007). One row per (platform, platform id, account): the Steam
* user 76561211 is linked to account 42, the Meta user 27061 is linked to account
* 42 as well, and both let that player into that account without a password.
*
* This table replaced the single `platformId`/`platform` pair on the account blob as
* the thing logins are decided from, because that pair could only hold ONE identity
* a player with a PC and a headset had to pick which device got a cached login. The
* blob fields are kept as the account's *primary* identity (the first one linked) for
* the account DTO and the refresh grant's claims; nothing authorizes off them.
*
* It is deliberately the ONE source of truth for both halves of a cached login: the
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
* identity, and the `cached_login` grant asks this table whether the account it was
* handed is linked to the identity that was proven. When those two disagreed the
* client was offered an account it could never log into see the regression test.
*
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
* account with no password", so an unproven `platform_id` must never reach it.
*/
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
// primary key's leading columns, so no separate index is needed for it.
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
// resolves ids it has no platform for.
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
]
/**
* The one-time backfill 0007 ran after creating the table: every identity already bound
* to an account became a link, so nobody lost their cached login at deploy. It has run;
* this exists so a test can still exercise it, which is the only coverage that legacy
* blob-bound accounts get a link at all.
*
* `platform` is COALESCEd to 0 because nothing ever defaulted that field an account
* can carry a platformId with no platform recorded, and back when Steam was the only
* verifiable platform an unset one *was* Steam.
*
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
* against the head schema the tests build. This selects the blob directly instead
* the same values, since the dropped column was DEFINED as
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
*/
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
json_extract(data, '$.platformId'),
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
platform: number
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
}
interface LinkRow {
accountId: number
platform: number
platformId: string
linkedAt: string
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt FROM platform_account`
/**
* Link a verified platform identity to an account. Idempotent re-logging in on the
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
* link. Returns true when this created a new link.
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, platform, platformId, new Date().toISOString())
.run()
return res.meta.changes > 0
}
/**
* The accounts a platform identity opens what the login-screen picker lists.
* Ordered oldest link first so the list is stable between launches (D1 row order
* isn't). Empty id yields nothing rather than matching every link.
*/
export async function getLinksForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results
}
/**
* Links for a bare platform id, whatever platform it belongs to. For the bulk
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
* for the single-id route when the client sends a non-numeric platform.
*/
export async function getLinksForPlatformId(
db: D1Database,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.all<LinkRow>()
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
accountId: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results
}
/**
* Whether this account is linked to this platform identity the single check the
* `cached_login` grant authorizes on. An account with no link for the presented
* identity cannot be cached-logged-into and must use a password.
*/
export async function isPlatformIdentityLinked(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
)
.bind(accountId, platform, platformId)
.first<{ ok: number }>()
return row !== null
}
/**
* How many accounts one platform identity already opens the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by
* creating them under the cap and then linking more in.
*/
export async function countAccountsForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
.bind(platform, platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
+17 -30
View File
@@ -1,21 +1,22 @@
/**
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0003). Only a SHA-256 hash of each token is stored never the
* raw value alongside the account + platform needed to re-mint an access token,
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
* fresh token is issued each refresh (rotation) and a replayed token stops working.
* raw value alongside the account it logs in and an absolute expiry. Tokens are
* single-use: redeeming one deletes it, so a fresh token is issued each refresh
* (rotation) and a replayed token stops working.
*
* The platform identity is NOT kept here (dropped in 0006); a refreshed token takes
* it from the account, which is where the bound identity actually lives.
*/
/** Refresh tokens live this long (s) before the client must log in again. */
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */
export const REFRESH_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
@@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
]
/** The login context needed to re-mint an access token from a refresh token. */
export interface RefreshContext {
accountId: number
platform: string
platformId: string
}
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
async function hashToken(token: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
@@ -37,47 +31,40 @@ async function hashToken(token: string): Promise<string> {
}
/**
* Mint and persist a new refresh token for the given login, returning the raw
* Mint and persist a new refresh token for the given account, returning the raw
* token the only moment it exists in plaintext (only its hash is stored).
*/
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
export async function issueRefreshToken(db: D1Database, accountId: number): Promise<string> {
const token = `${crypto.randomUUID()}`
const now = Math.floor(Date.now() / 1000)
await db
.prepare(
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
.bind(
await hashToken(token),
ctx.accountId,
ctx.platform,
ctx.platformId,
now,
now + REFRESH_TTL_SECONDS
`INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS)
.run()
return token
}
/**
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
* rotation) and return its login context; otherwise return null. The delete is
* rotation) and return the account it logs in; otherwise return null. The delete is
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice a
* concurrent second attempt finds no row. An expired token is deleted and rejected.
*/
export async function consumeRefreshToken(
db: D1Database,
token: string
): Promise<RefreshContext | null> {
): Promise<number | null> {
const now = Math.floor(Date.now() / 1000)
const row = await db
.prepare(
`DELETE FROM refresh_tokens WHERE token_hash = ?1
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
RETURNING account_id AS accountId, expires_at AS expiresAt`
)
.bind(await hashToken(token))
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
.first<{ accountId: number; expiresAt: number }>()
if (!row || row.expiresAt < now) return null
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
return row.accountId
}
+835 -58
View File
@@ -4,9 +4,28 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import { getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain'
import {
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import { isLinkedToPlatformIdentity } from '../../auth.app'
import {
banFromReport,
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -24,14 +43,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -42,24 +75,38 @@ beforeAll(async () => {
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 13,
Name: 'Orientation',
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
)
.run()
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await seedRoomWithSubRooms(env.DB, {
RoomId: 13,
Name: 'Orientation',
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
* Ban an account the way a moderator would: file a report against it and convert that
* report into a ban. `banExpires` null is a permanent ban.
*/
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
await banFromReport(env.DB, row.id, { banExpires })
}
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
async function seedAccount(accountId: number, username: string): Promise<void> {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
.run()
}
/** Decode a JWT payload (no verification) for asserting claims. */
function decodePayload(token: string): Record<string, unknown> {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@@ -96,6 +143,51 @@ async function postToken(
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -117,16 +209,42 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -134,11 +252,11 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -146,7 +264,7 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
@@ -170,6 +288,109 @@ describe('auth worker routes', () => {
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const linked = await cachedLogins(1, META_USER_ID)
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account is actually linked to.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
@@ -185,6 +406,7 @@ describe('auth worker routes', () => {
})
)
.run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
@@ -198,32 +420,69 @@ describe('auth worker routes', () => {
])
})
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
// Steam-linked account can carry a platformId with no platform. The picker offered
// such an account (it treats a missing platform as Steam) while the cached_login
// grant rejected it — "no linked account for this platform identity" forever.
// Both now run the same check.
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// The point of the link table. The same account is reachable from the PC and from
// the headset, and each picker reports the identity IT was asked about — that's
// what the client posts back on the cached_login grant.
const steamId = '76561197962463777'
const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
const steamId = '76561197962463211'
const account = { platformId: steamId } // no `platform` field
// The grant now accepts it — this is what was returning invalid_grant.
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
// The identity is still the credential: another SteamID, an account with no
// platform identity, and an account bound to a different platform are all refused.
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
// And the picker offers exactly the accounts the grant accepts.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
expect(offered.map((a) => a.accountId)).toContain(8)
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
// No link row yet: not offered.
const before = await cachedLogins(0, steamId)
expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -239,7 +498,7 @@ describe('auth worker routes', () => {
expires_in: number
}
expect(json.token_type).toBe('Bearer')
expect(json.expires_in).toBe(3600)
expect(json.expires_in).toBe(TOKEN_TTL_SECONDS)
// header.payload.signature
const parts = json.access_token.split('.')
expect(parts).toHaveLength(3)
@@ -256,9 +515,14 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient')
// A plain account carries only the base role — no elevated roles.
// screenshare is a feature gate, not a grant — every token carries it.
expect(payload.role).toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
@@ -278,6 +542,25 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 92,
username: 'JuniorPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isJunior: true,
})
)
.run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
expect(payload.scope).not.toContain('rn.privilege')
})
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)
@@ -496,9 +779,17 @@ describe('auth worker routes', () => {
})
})
test('POST /connect/token maps the platform int to its enum name', async () => {
const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe('Steam')
test('POST /connect/token carries the platform int on the token', async () => {
const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(5)
// `rn.plat` is the same int, not a pinned 0.
expect(payload['rn.plat']).toBe(5)
})
test('POST /connect/token defaults the platform claim when none is posted', async () => {
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(0)
expect(payload['rn.plat']).toBe(0)
})
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
@@ -514,15 +805,195 @@ describe('auth worker routes', () => {
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(200)
// A fresh access token for the same account, carrying the stored platform.
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('42')
expect(payload.platform).toBe('Steam')
expect(payload.platform_id).toBe('steam-123')
// The platform identity comes off the account, not the refresh token. Account 42
// has none bound (the posted `platform_id` above was never Steam-verified, so it
// was never written), so the refreshed token carries no identity either.
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('')
// The refresh token is rotated (single-use), so a new one is returned.
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
})
test('a refreshed token carries the identity bound to the account', async () => {
// A Steam-bound account: only a verified ticket writes `platformId`, so seed it
// directly rather than posting an (unverified) platform_id on the login.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 43,
username: 'Player43',
passwordHash: await hashPassword(LOGIN_PASSWORD),
platform: 0,
platformId: 'steam-123',
})
)
.run()
const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('43')
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('steam-123')
})
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
// The sideload placeholder identifies nobody — every sideloaded headset reports
// `1`, so a link on it would be a password-free way into this account from any of
// them. The password login still stands; Meta is never even asked, since there is
// nothing there to validate.
await seedPasswordAccount(7105, 'sideloader')
const login = await metaLogin(
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=1` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true // even with Meta answering yes to everything
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
// And so the picker never offers this account off the placeholder — only the
// canned stub entry is there.
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
@@ -624,4 +1095,310 @@ describe('auth worker routes', () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /cachedlogin/forplatformid/{platform}/{id}',
'GET /eac/challenge',
'GET /role/developer/{id}',
'GET /role/moderator/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformids',
'POST /connect/token',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
// The website is a browser origin calling these endpoints directly — the same ones the
// game calls — instead of proxying them through `www`. That only works if the responses
// carry CORS headers: without them the browser discards a perfectly good token response
// and sign-in fails with nothing in any server log to explain it.
describe('CORS', () => {
test('answers the preflight the browser sends before a token grant', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('content-type')
})
// The header has to be on the REAL response too, not just the preflight — and on a
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
// network error rather than "that password is incorrect".
test('allows the origin on the response itself, refusals included', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
}),
env
)
expect(res.status).toBe(400)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
// The bearer header is what the SPA authenticates with, so it must be allowed by name
// — a preflight that omits it makes every signed-in call fail.
test('allows the Authorization header the SPA signs its calls with', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/account/me/changepassword`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
})
})
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
describe('banned accounts', () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
})
test('POST /connect/token refuses a username login from a banned account', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
await banAccount(6103)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
})
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
const res = await postToken('account_id=6104&password=not-the-password')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid account_id or password')
})
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A report is not a ban until a moderator converts it.
test('an unbanned report does not refuse the login', async () => {
await seedAccount(6107, 'MerelyReported')
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 })
const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
})
// The ban is the ACCOUNT's: nothing here stops the player signing up again, which is
// the signup caps' job, not this check's.
test('a banned player can still create a new account', async () => {
await seedAccount(6108, 'BannedButNew')
await banAccount(6108)
const created = await postToken('grant_type=create_account&platform_id=steam-after-ban')
expect(created.status).toBe(200)
})
})
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
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> = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username: name,
passwordHash: await hashPassword(LOGIN_PASSWORD),
...ips,
})
)
.run()
}
const login = (id: number, ip?: string) =>
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
test('an account sharing a banned accounts platform identity cannot log 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')
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')
})
test('an account sharing a banned accounts IP cannot log 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')
})
// 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 requests own IP is matched 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 login(6306, '198.51.100.31')).status).toBe(200)
})
test('an unrelated account signs in normally', async () => {
await account(6307, 'Unrelated', { signupIp: '198.51.100.7' })
await banAccount(6307 + 1000) // a ban on somebody else entirely
expect((await login(6307)).status).toBe(200)
})
// The point of checking before minting: a refused signup must leave nothing behind,
// or the evader keeps the account (and burns a slot off the signup caps) anyway.
test('create_account from a banned IP is refused and creates no account', async () => {
await account(6310, 'BannedSignupSource', { signupIp: '203.0.113.40' })
await banAccount(6310)
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
const res = await postToken('grant_type=create_account', '203.0.113.40')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
expect(after?.n).toBe(before?.n)
})
test('create_account from an unrelated IP still works', async () => {
const res = await postToken('grant_type=create_account', '198.51.100.99')
expect(res.status).toBe(200)
})
// The knob an operator reaches for when the IP arm locks out real players.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
await banAccount(6320)
await account(6321, 'KnobHousemate', { signupIp: '203.0.113.50' })
await account(6322, 'KnobEvader')
await linkPlatformIdentity(env.DB, 6322, 0, 'steam-knobevader')
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.
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says.
const banned = await login(6320)
expect(banned.status).toBe(400)
expect(banned.json.error_description).toBe('this account is banned')
} finally {
env.BAN_EVASION_MATCH = original
}
})
})
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest'
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
// The payload shape a real Meta login posts, captured from a live client. `Source`
// is informational and ignored; the AppId is Rec Room's Meta app.
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
const APP_ID = '1232175103309633'
const USER_ID = '27061366730207360'
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
const APP_SECRET = 'test-app-secret'
/**
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
* request it was handed. Typed to what `verifyMetaNonce` actually passes a string URL
* and a string body rather than the whole of `fetch`, then cast at the boundary.
*/
function stubFetch(bodies: unknown, status = 200) {
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
const calls: Array<{ url: string; form: URLSearchParams }> = []
const fetcher = (async (url: string, init?: { body?: string }) => {
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
const body = queue.length > 1 ? queue.shift() : queue[0]
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
return { fetcher, calls }
}
describe('meta-nonce', () => {
test('parses the platform_auth payload the client posts', () => {
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
})
test.each([
['not json', 'nonsense'],
['no nonce', JSON.stringify({ AppId: APP_ID })],
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
['no app id', JSON.stringify({ Nonce: NONCE })],
// The app id is interpolated into the graph access token, so a non-numeric one
// is refused rather than sent.
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
])('rejects a malformed payload (%s)', (_label, payload) => {
expect(parseMetaPlatformAuth(payload)).toBeNull()
})
test('validates the nonce against the posted user id and returns the identity', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
// The request Meta actually sees: the nonce is bound to THIS user id, and the
// app authenticates itself with `OC|<app id>|<secret>`.
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
expect(calls[0].form.get('nonce')).toBe(NONCE)
expect(calls[0].form.get('user_id')).toBe(USER_ID)
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
})
test('rejects a nonce Meta does not vouch for', async () => {
const { fetcher } = stubFetch({ is_valid: false })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
ok: false,
reason: 'nonce rejected',
})
})
// The whole point of validating against the posted id: a nonce genuinely issued to
// one user does not authenticate another. Meta answers is_valid:false for the
// mismatch, so nobody can log in by naming someone else's Meta user id.
test('a nonce presented for the wrong user id fails', async () => {
const { fetcher, calls } = stubFetch({ is_valid: false })
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
})
test.each([
['missing', ''],
['non-numeric', 'not-an-id'],
])('refuses a %s user id without calling Meta', async (_label, userId) => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(0)
})
test('refuses to attempt verification with no app secret', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
ok: false,
reason: 'no app secret configured',
})
expect(calls).toHaveLength(0)
})
test('surfaces a graph error with its code, for the server log', async () => {
const { fetcher } = stubFetch({
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
})
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({
ok: false,
reason: 'graph error 100: Invalid OAuth access token',
})
})
test('a non-retryable graph error is not retried', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(calls).toHaveLength(1)
})
test('retries a transient graph error and succeeds on a later attempt', async () => {
const { fetcher, calls } = stubFetch([
{ error: { code: 2, message: 'service temporarily unavailable' } },
{ is_valid: true },
])
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(true)
expect(calls).toHaveLength(2)
})
test('gives up after three attempts when Meta stays unavailable', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(3)
})
test('treats a network failure as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
throw new Error('connection reset')
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(attempts).toBe(3)
})
test('treats a non-JSON body (an edge error page) as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
return new Response('<html>502</html>', { status: 502 })
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
expect(attempts).toBe(3)
})
})
+12
View File
@@ -22,11 +22,23 @@
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
//
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
// logins are verified by asking Meta to validate the login nonce, which requires
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
// store or the deploy fails an operator with no Meta app still has to create
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
}
],
"upload_source_maps": true,
+29 -1
View File
@@ -1,6 +1,34 @@
# cdn
A Cloudflare Workers application using Hono
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
bucket, plus the one bundled config file the loading screen reads.
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.
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`.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
the docs page www serves at `/docs`.
**The spec is descriptive, not enforced** — same rationale as the `img`/`auth` workers: a
reverse-engineered protocol, lenient handlers, no runtime validation. A test asserts
every route appears in the spec, so adding one without documenting it fails.
## Conditional and range requests
Every asset route honours `If-None-Match` (→ 304) and a single `Range` (→ 206). The range
support is not an optimization: large-file downloaders fetch in chunks, and answering 200
where a 206 is expected corrupts the reassembled file — which surfaces as an anti-cheat
"Signatures don't match" failure rather than a download error.
## Development
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+198 -43
View File
@@ -1,51 +1,69 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import {
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
writeContentRange,
} from '@repo/hono-helpers'
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
import {
assetResponses,
CONDITIONAL_HEADERS,
json,
keyParam,
LoadingScreenTip,
ServiceStatus,
} from './openapi'
import type { Context } from 'hono'
import type { App, Env } from './context'
/**
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
* are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
* yet and are stubbed.
* are served bare. Everything but the liveness probe and the bundled tip data is
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
*/
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
function parseRange(header: string | undefined): R2Range | undefined {
if (!header) return undefined
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
if (!m) return undefined
const start = m[1]
const end = m[2]
if (start === '' && end !== '') return { suffix: Number(end) } // last N bytes
if (start !== '') {
return end !== ''
? { offset: Number(start), length: Number(end) - Number(start) + 1 }
: { offset: Number(start) }
}
return undefined
}
/**
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
* honoring Range requests. 404s when the file is missing.
* Supports conditional GET and byte-range requests (206) large-file
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
* reassembled file (e.g. EAC "Signatures don't match").
*
* This is why `cache.enabled` is false in wrangler.jsonc: Workers Caching strips `Range`
* before the worker is invoked and slices the 206 out of its own cache, which silently
* degrades to a whole-object 200 whenever the response is not cacheable. The range
* answer has to be ours to guarantee.
*/
async function serveAsset(c: Context<App>, key: string) {
if (key.includes('..')) return c.body(null, 400)
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
const range = parseRange(c.req.header('range'))
const object = await (c.env as Env).CDN_ASSETS.get(key, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
...(range ? { range } : {}),
})
// R2 parses the `Range` header itself when handed the request headers, so there is no
// grammar to reimplement here. It resolves every form (`bytes=a-b`, `bytes=a-`,
// `bytes=-n`) to a concrete offset/length, and anything it cannot parse or satisfy to
// the whole object — see the 206 branch, which is what turns that back into a 200.
// With no `Range` header present this is an ordinary whole-object read.
let object
try {
object = await (c.env as Env).CDN_ASSETS.get(key, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
range: c.req.raw.headers,
})
} catch (e) {
// Defensive: R2 documents InvalidRange (10039) for a range it can't satisfy, which
// is a 416 rather than the 500 the error handler would otherwise turn it into.
// Locally it never fires — workerd resolves an unsatisfiable range to the whole
// object instead of throwing — so this covers the service behaving as documented.
if (e instanceof Error && e.message.includes('(10039)')) return c.body(null, 416)
throw e
}
if (!object) return c.notFound()
const headers = new Headers()
@@ -58,20 +76,12 @@ async function serveAsset(c: Context<App>, key: string) {
// Precondition matched (If-None-Match) → R2 returns no body.
if (!('body' in object)) return new Response(null, { status: 304, headers })
// Range honored → 206 Partial Content with Content-Range.
if (object.range && c.req.header('range')) {
const r = object.range
let offset: number
let length: number
if ('suffix' in r) {
length = r.suffix
offset = object.size - length
} else {
offset = r.offset ?? 0
length = r.length ?? object.size - offset
}
headers.set('content-length', String(length))
headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`)
// A `bytes=` request is ALWAYS answered 206 with a Content-Range naming the bytes
// actually enclosed — never a bare 200 carrying the whole object. That is the one
// answer a chunked downloader cannot survive: it asked for a slice, so it writes
// whatever comes back at that offset, and a whole-object body silently corrupts the
// reassembled file (EAC "Signatures don't match"). See writeContentRange().
if (writeContentRange(headers, c.req.raw.headers, object)) {
return new Response(object.body, { status: 206, headers })
}
@@ -89,28 +99,173 @@ const app = new Hono<App>()
})(c, next)
)
// The website lets a room's owner download their own scene blobs (see the room page
// in `www`), which means a browser reading these bytes from another origin — without
// these headers it can fetch them but not touch the result. `origin: '*'` gives away
// nothing: every route here is already unauthenticated and public to anyone holding
// the key, and nothing on this worker reads a cookie or a token, so there is no
// ambient credential for `*` to expose. The keys are unguessable UUIDs, and that is
// unchanged by who may read a response they already had to name exactly.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'cdn', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') },
}),
(c) => c.json({ service: 'cdn', status: 'ok' })
)
// Loading-screen tips, bundled here as static JSON.
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
.get(
'/config/LoadingScreenTipData',
describeRoute({
tags: ['Config'],
summary: 'Loading-screen tips',
description: [
'The tips the client cycles through on a loading screen. A bundled static file',
'(`static/loading-screen-tip-data.json`), captured from the real service and served',
'verbatim — nothing here is editable at runtime, and every client gets the same list',
'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`',
'fields are the clients own filters, applied client-side.',
].join(' '),
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
}),
(c) => c.json(loadingScreenTipData)
)
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// 404 when missing.
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
.get(
'/sigs/:sigName',
describeRoute({
tags: ['Assets'],
summary: 'Serve a signature blob',
description: [
'Streams the object stored under `sigs/<sigName>`. These are the anti-cheat signature',
'blobs the client fetches at startup; nothing here inspects or validates them.',
].join(' '),
parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS],
responses: assetResponses('The signature blob'),
}),
(c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)
)
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
// load the room. Streamed from R2 under `room/`. The name may contain slashes
// (uploads are foldered by date, e.g. `2026-02-03/<uuid>`), so match the rest of
// the path.
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
.get(
'/room/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve room build data',
description: [
'Streams the object stored under `room/<dataBlob>` — the saved scene the client',
'downloads to load a room. The name comes from a subrooms `DataBlob` (see the `rooms`',
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
'slashes.',
'',
'A rooms IMAGE also lives under this prefix, stored by its bare `ImageName` — the',
'same route serves both.',
].join('\n'),
parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The room data'),
}),
(c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)
)
// Invention data by name. The client fetches this for an invention's
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
// the upload stored it under, so the rest of the path is matched as-is.
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
.get(
'/invention/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve invention data',
description: [
'Streams the object stored under `invention/<dataBlob>` — the data the client',
'downloads to spawn an invention. The name comes from an inventions',
'`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,',
'and it keeps the `.inv` extension the upload stored it under.',
].join(' '),
parameters: [
keyParam('dataBlob', 'The blob name, including `.inv`.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The invention data'),
}),
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// Generic client data by name. Anything the client uploads as FileType 2 lands
// under `data/` (a Holotar recording is the one seen in the wild) and the client
// fetches it back from this prefix. Date-foldered like the room and invention
// blobs, so the rest of the path is matched as-is.
.get(
'/data/:id{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a client data blob',
description: [
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
'observed. Like room and invention blobs the name is date-foldered by the upload,',
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
].join(' '),
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The data blob'),
}),
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare cdn',
version: '1.0.0',
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',
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
'screen reads.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'',
'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.',
'',
'Every asset route supports conditional GETs (`If-None-Match` → 304) and single',
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
'which surfaces as an anti-cheat “Signatures dont match” failure, not a download',
'error. So a `bytes=` request is never answered with a whole-object 200: the 206',
'always carries a `Content-Range` stating which bytes the body holds, even where',
'that turns out to be all of them.',
].join('\n'),
},
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+115
View File
@@ -0,0 +1,115 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the cdn worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/img workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*
* Most of this worker's surface is opaque BYTES, not JSON, so those responses are
* described with a binary content type rather than a zod schema (the same way the `img`
* worker describes image bytes).
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* A binary asset response. Everything streamed out of the bucket is served as
* `application/octet-stream` regardless of what it actually is the client downloads
* these blobs, it never sniffs their type.
*/
export function assetBytes(description: string): OpenAPIV3_1.ResponseObject {
return {
description,
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
}
}
/** The shared responses of every asset route: the byte-serving ones plus its failures. */
export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject {
return {
200: assetBytes(description),
206: assetBytes('A byte range, when the request carried a `Range` header'),
304: { description: '`If-None-Match` matched the stored etag (no body)' },
400: { description: 'The key contains `..` (no body)' },
404: { description: 'No such object in the bucket' },
416: { description: 'The `Range` header could not be satisfied (no body)' },
}
}
/** The `Range` / `If-None-Match` headers every asset route honours. */
export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
{
name: 'Range',
in: 'header',
required: false,
description:
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`), parsed by R2 itself. Any `bytes=` value is answered 206 with a `Content-Range` naming the bytes enclosed — never a bare 200 carrying the whole object, which a chunked downloader would write at the offset it asked for. A multi-range or unsatisfiable value yields the whole object, but says so in the `Content-Range`. A unit other than `bytes` is ignored (200).',
schema: { type: 'string', example: 'bytes=0-1023' },
},
{
name: 'If-None-Match',
in: 'header',
required: false,
description: 'The etag of a previously fetched copy; a match answers 304 with no body.',
schema: { type: 'string' },
},
]
/** A path parameter naming an object in the bucket. */
export function keyParam(
name: string,
description: string,
slashes: boolean
): OpenAPIV3_1.ParameterObject {
return {
name,
in: 'path',
required: true,
description: slashes ? `${description} May contain slashes.` : description,
schema: { type: 'string' },
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('cdn'),
status: z.literal('ok'),
})
/**
* One loading-screen tip. `Context`/`InputType`/`Visibility` are client-side enums that
* decide where a tip may appear, and `PlatformMask` is a bit field of the platforms it
* shows on every tip in the bundled set is left at whatever the 2019 capture had.
*/
export const LoadingScreenTip = z.object({
Name: z.string().describe('A GUID (no dashes) — the tips id, not a display name'),
Title: z.string(),
Message: z.string(),
RoomNames: z
.array(z.string())
.describe('Rooms to restrict the tip to; empty everywhere in the bundled set'),
Context: z.int(),
InputType: z.int(),
Visibility: z.int(),
AllowCycling: z.boolean(),
RestrictToNewUsers: z.boolean(),
ImageName: z.string().describe('An image key the client resolves against the img worker'),
PlatformMask: z.int().describe('Bit field of the platforms the tip shows on'),
CreatedAt: z.string(),
})
+127
View File
@@ -52,6 +52,65 @@ describe('cdn endpoints', () => {
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([12, 13, 14]))
})
// The other two Range forms. Both resolve to a concrete offset/length inside R2, so
// they exercise the same Content-Range math as the closed range above — which read
// every range as a suffix range and emitted `bytes NaN-NaN/6` until it was fixed.
test('GET /sigs/:sigName honors open-ended and suffix Range requests', async () => {
await env.CDN_ASSETS.put('sigs/ranged2', new Uint8Array([10, 11, 12, 13, 14, 15]))
const fetchRange = (range: string) =>
exports.default.fetch(`${ORIGIN}/sigs/ranged2`, { headers: { Range: range } })
// `bytes=4-` — from an offset to the end.
const open = await fetchRange('bytes=4-')
expect(open.status).toBe(206)
expect(open.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await open.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
// `bytes=-2` — the last N bytes.
const suffix = await fetchRange('bytes=-2')
expect(suffix.status).toBe(206)
expect(suffix.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
})
// The corrupting answer to a byte-range request is a bare 200 carrying the whole
// object: the downloader asked for a slice, so it writes the body at that offset and
// the reassembled file is wrong (EAC "Signatures don't match"). R2 resolves a value
// it cannot parse or satisfy to the WHOLE object rather than failing, so these are
// exactly the inputs that used to fall through to a 200 — every one of them must
// still come back 206 with a Content-Range stating what the body actually holds.
test('GET /sigs/:sigName never answers a bytes range with a whole-object 200', async () => {
await env.CDN_ASSETS.put('sigs/ranged3', new Uint8Array([10, 11, 12, 13, 14, 15]))
const fetchRange = (range: string) =>
exports.default.fetch(`${ORIGIN}/sigs/ranged3`, { headers: { Range: range } })
for (const range of [
'bytes=100-200', // wholly past the end of a 6-byte object
'bytes=abc', // not the byte-range grammar
'bytes=0-1,3-4', // multi-range, which R2 does not serve
'bytes=0-5', // satisfiable, and covers everything
]) {
const res = await fetchRange(range)
expect(res.status, range).toBe(206)
expect(res.headers.get('content-range'), range).toBe('bytes 0-5/6')
}
// A range that runs off the end but starts inside is a real partial read.
const partial = await fetchRange('bytes=4-99')
expect(partial.status).toBe(206)
expect(partial.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await partial.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
// A unit other than bytes must be ignored outright — RFC 9110 — not answered
// with a byte-denominated Content-Range.
const other = await fetchRange('items=0-1')
expect(other.status).toBe(200)
expect(other.headers.get('content-range')).toBeNull()
expect(new Uint8Array(await other.arrayBuffer())).toEqual(
new Uint8Array([10, 11, 12, 13, 14, 15])
)
})
test('GET /room/:dataBlob streams the room blob from R2', async () => {
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
@@ -65,6 +124,20 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
// The website lets a room's owner download their own scene data (the room page in
// `www`), which is a browser reading these bytes from another origin. Without the
// header it can fetch them but not read the result — and the page can't tell that
// apart from the blob being gone.
test('answers CORS so a browser on another origin can read a blob', async () => {
await env.CDN_ASSETS.put('room/2026-08-01/cors-check', new Uint8Array([4, 2]))
const res = await exports.default.fetch(`${ORIGIN}/room/2026-08-01/cors-check`, {
headers: { origin: 'https://www.example.net' },
})
expect(res.status).toBe(200)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 2]))
})
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
// api worker hands back as the invention's BlobName.
@@ -79,4 +152,58 @@ describe('cdn endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
expect(res.status).toBe(404)
})
test('GET /data/:id streams the data blob from R2', async () => {
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
})
test('GET /data/:id 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+10 -1
View File
@@ -4,8 +4,17 @@
"main": "src/cdn.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Workers Caching is OFF here, and must stay off: it STRIPS the `Range` header before
// invoking the worker, asks for the whole body, and slices the 206 out of its own
// cache. That works only while the response is actually cacheable on any bypass
// (see the automatic bypass rules) nothing slices, and the client that asked for a
// byte range receives the whole object with a 200. A chunked downloader writes that
// at the offset it asked for and the reassembled file is corrupt (EAC "Signatures
// don't match"). With caching off the `Range` header reaches serveAsset, which
// always answers a `bytes=` request with a 206 and a truthful Content-Range.
// The cost is that every asset read hits R2; correctness on these blobs is worth it.
"cache": {
"enabled": true
"enabled": false
},
// CDN binaries (signature blobs + room build data) are stored as R2 objects
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
+22
View File
@@ -0,0 +1,22 @@
-- Chat messages, owned by the `chat` worker. One row per message posted to a thread;
-- columns rather than a JSON blob (mirroring the reference model), since every field is a
-- scalar the server reads: threads are listed newest-first by (chat_thread_id,
-- chat_message_id), and `time_sent` is carried for display only.
--
-- `chat_message_id` is server-assigned (AUTOINCREMENT) so ids are unique across every
-- thread, matching the client's expectation of a global message id. `contents` is the
-- client's envelope — `{"Type":0,"Version":1,"Data":"..."}` — stored verbatim as an
-- opaque string and served back untouched, so new message types need no schema change.
-- `moderation_state` is the ChatModerationState enum (0 = none). Kept in sync with
-- SCHEMA_DDL in src/message-db.ts.
CREATE TABLE IF NOT EXISTS message (
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_id INTEGER NOT NULL,
sender_player_id INTEGER NOT NULL,
time_sent TEXT NOT NULL,
contents TEXT NOT NULL,
moderation_state INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id);
CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id);
+37
View File
@@ -0,0 +1,37 @@
-- Chat threads and their membership, owned by the `chat` worker.
--
-- A thread is a conversation — a DM pair, a group chat, or a system thread. Membership
-- in `thread_member` is the authorization gate: a player may read or post to a thread
-- only if they hold a row here, and the same rows render the `playerIds` array the
-- client shows. There is deliberately no FK to accounts, here or on
-- `message.sender_player_id`: that table belongs to the `auth` worker, and a thread
-- outlives the accounts in it.
--
-- `latest_message_id` is denormalized onto the thread so the thread list renders from
-- one indexed row per thread instead of a per-thread MAX() over `message`; it also
-- orders that list (message ids being monotonic, newest thread = highest id). Kept in
-- sync on every insert — see touchThread in src/thread-db.ts.
--
-- The per-viewer fields live on the membership row, not the thread: two players in one
-- DM have independent read positions, snoozes, and favorites. Kept in sync with
-- THREAD_SCHEMA_DDL in src/thread-db.ts.
CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Null for DMs and unnamed groups; the client falls back to rendering the members.
chat_thread_name TEXT,
latest_message_id INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id);
CREATE TABLE IF NOT EXISTS thread_member (
chat_thread_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
last_read_message_id INTEGER,
snoozed_until TEXT,
is_favorited INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_thread_id, player_id)
);
-- The thread-list query is "every thread this player is in", so player_id leads.
CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id);
+8 -1
View File
@@ -12,12 +12,19 @@
"deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"migrate": "run-wrangler-migrate",
"test": "run-vitest"
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+818 -4
View File
@@ -1,9 +1,313 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db'
import {
AUTHED,
ChatMessageDto,
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
form,
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
ServiceStatus,
SnoozeThreadRequest,
THREAD_ID_PARAM,
UNAUTHORIZED_RESPONSE,
WithMembersRequest,
} from './openapi'
import {
addThreadMember,
getOrCreateThreadWithMembers,
getThreadForPlayer,
getThreadMemberIds,
getThreadsForPlayer,
isThreadMember,
leftChatContents,
markThreadRead,
postMessage,
removeThreadMember,
setThreadFavorited,
setThreadName,
setThreadSnoozed,
SYSTEM_SENDER_ID,
} from './thread-db'
import type { Context } from 'hono'
import type { App } from './context'
import type { ChatMessage } from './message-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* How many items a `MessageCount` query param asks for. The client sends 16; anything
* missing, unparseable, or out of range falls back to the default rather than 400ing,
* and the cap keeps a hand-written request from pulling a whole thread history.
*/
const DEFAULT_MESSAGE_COUNT = 16
const MAX_MESSAGE_COUNT = 100
/** What the client asks for when opening a thread (`messageCount=50`). */
const DEFAULT_THREAD_MESSAGE_COUNT = 50
function messageCount(c: Context<App>, fallback = DEFAULT_MESSAGE_COUNT): number {
// The GET routes spell it `MessageCount` in the query; the POST forms spell it
// `messageCount` in the body. Accept either, wherever it turns up.
const raw = Number.parseInt(c.req.query('MessageCount') ?? c.req.query('messageCount') ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return fallback
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/** The page size a POST form asks for, which may also arrive in the body. */
async function formMessageCount(c: Context<App>, fallback: number): Promise<number> {
const raw = Number.parseInt((await formField(c, 'messageCount')) ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return messageCount(c, fallback)
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/**
* What a chat action reports back to the client alongside its payload the reference's
* ChatResult. Only success and "bad arguments" are reachable here.
*/
const CHAT_SUCCESS = 0
const CHAT_INVALID_ARGUMENTS = 1
const CHAT_MEMBERSHIP_NOT_FOUND = 3
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
*
* The sender is notified too, deliberately: the client doesn't fold the HTTP response
* into its local thread cache, so without a self-targeted push its own outgoing message
* doesn't appear until the thread is refetched.
*
* Best-effort a hub failure is logged and swallowed, since the message has already
* committed and the client will still see it on the next fetch.
*/
async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<void> {
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const members = await getThreadMemberIds(c.env.DB, message.chatThreadId)
await Promise.all(
members.map((playerId) =>
hub.notifyPlayer(playerId, NotificationType.ChatMessageReceived, { ...message })
)
)
} catch (err) {
logger.error('failed to push ChatMessageReceived notification', {
chatThreadId: message.chatThreadId,
chatMessageId: message.chatMessageId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Send a message to a thread that already exists every message after the one that
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
* the same call under the reference's other spelling, so both routes land here.
*
* Answers `{chatResult, chatThread}` the whole thread with its messages, not just the
* message that was sent, so the client re-renders the conversation from one response.
* Blank or missing contents stores nothing and reports invalid-arguments, still with the
* thread attached, rather than an error status.
*/
async function sendToThread(c: Context<App>) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
// Sending is reading: the reference answers with `lastReadMessageId` already at the
// message just posted, so the sender's own thread doesn't come back unread.
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await threadWithMessages(c, chatThreadId, id, DEFAULT_THREAD_MESSAGE_COUNT)
return c.json({
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
chatThread: thread,
})
}
/**
* Move the caller's read pointer on a thread, to `chatMessageId` or (undefined) to the
* thread's latest message. Answers the bare ChatResult integer the reference sends.
*/
async function markRead(c: Context<App>, chatMessageId?: number) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// Every route reaching here constrains `:id` to digits, so the parse can't fail.
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
await markThreadRead(c.env.DB, chatThreadId, id, chatMessageId)
return c.json(CHAT_SUCCESS)
}
/**
* A thread rendered for opening a conversation: the thread's own fields plus a page of
* its messages, newest first. `latestMessage` gives way to the full page the client is
* sent one or the other, never both and `messages` is always present, empty for a
* thread with nothing in it yet.
*
* Null when the caller isn't a member (or the thread doesn't exist); membership is the
* gate, so the two cases are indistinguishable from outside.
*/
async function threadWithMessages(
c: Context<App>,
chatThreadId: number,
playerId: number,
limit: number
) {
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, playerId)
if (thread === null) return null
const messages = await getThreadMessages(c.env.DB, chatThreadId, { limit })
const { latestMessage: _latest, ...rest } = thread
return { ...rest, messages }
}
/** Ceiling on a new thread's roster, counting the caller. */
const MAX_THREAD_MEMBERS = 50
/** Longest a thread name may be; anything beyond is truncated, not rejected. */
const MAX_THREAD_NAME_LENGTH = 128
/**
* What `snooze=True` stores in `snoozedUntil`. The client sends a boolean but reads back
* an instant, so "snoozed" is expressed as a time far enough out to mean indefinitely.
*/
const SNOOZED_INDEFINITELY = '9999-12-31T23:59:59Z'
/**
* The repeated `ids` fields naming a new thread's members (`ids=2&ids=155`). The client
* sends them as a urlencoded body, but they're read from the query string too, since
* the same call is easy to hand-write that way. Values that aren't integers are dropped.
*/
async function memberIds(c: Context<App>): Promise<number[]> {
const raw = [...(c.req.queries('ids') ?? [])]
const form = await c.req.formData().catch(() => null)
if (form !== null) raw.push(...form.getAll('ids').map(String))
return raw.map((value) => Number.parseInt(value, 10)).filter((id) => Number.isInteger(id))
}
/** A form boolean as the client spells it (`True`/`False`), tolerant of the variants. */
async function formBool(c: Context<App>, name: string): Promise<boolean> {
const value = (await formField(c, name))?.trim().toLowerCase()
return value === 'true' || value === '1' || value === 'yes'
}
/** A single form field, or the query param of the same name. Hono caches the body, so
* this is safe to call alongside `memberIds`. */
async function formField(c: Context<App>, name: string): Promise<string | undefined> {
const form = await c.req.formData().catch(() => null)
const value = form?.get(name)
return typeof value === 'string' ? value : c.req.query(name)
}
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status rename, leave, snooze, favorite,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
function chatResultRoute(
summary: string,
description: string,
extra: {
requestBody?: ReturnType<typeof form>
parameters?: unknown[]
successDescription?: string
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
notFound?: boolean
} = {}
) {
return describeRoute({
tags: ['Chat'],
summary,
description,
security: AUTHED,
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
responses: {
200: json(
ChatResult,
extra.successDescription ??
'The ChatResult (0 on success, 3 when the caller isnt on the thread)'
),
401: UNAUTHORIZED_RESPONSE,
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
},
})
}
/**
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
* the reference's other spelling, and both land in `sendToThread`.
*/
function sendToThreadRoute(spelling: string) {
return describeRoute({
tags: ['Messages'],
summary: `Send a message to an existing thread (${spelling})`,
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
'ChatMessageReceived to every member, the sender included — the client doesnt fold the',
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
'message doesnt appear until the thread is refetched. Note the hub frames `Id` is a',
'STRING: the client dispatches on it and silently drops a numeric one.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(SendMessageRequest, 'The message envelope'),
responses: {
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
})
}
const app = new Hono<App>()
.use(
@@ -19,9 +323,519 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'chat', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
}),
(c) => c.json({ service: 'chat', status: 'ok' })
)
// Chat threads. No DB binding yet — returns `[]`.
.get('/thread', (c) => c.json([]))
// The player's own thread list, newest conversation first — each thread carrying its
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is
// the page size (of threads, despite the name). Membership scopes the query, so a
// player only ever sees their own threads.
.get(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'The callers thread list',
description: [
'Every thread the caller is a member of, newest conversation first — each carrying its',
'`latestMessage` and the callers own read/snooze/favorite state. `MessageCount` is the',
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
'only ever sees their own threads.',
].join(' '),
security: AUTHED,
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadDto.array(), 'The callers threads, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
}
)
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
// create-thread-and-post-first-message call, in one. Resolves to the thread those
// players already share rather than opening a second one.
//
// `messageContents` is the same envelope a message carries
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does.
.post(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'Open a thread with a set of players and post the first message',
description: [
'The clients create-thread-and-post-first-message call, in one. Resolves to the thread',
'those players already share rather than opening a second one. `messageContents` is the',
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
'thread. Pushes ChatMessageReceived to every member (including the sender).',
].join(' '),
security: AUTHED,
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
responses: {
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
// The reference answers a wrapper here, not a bare thread.
return c.json({
chatThread: thread,
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
})
}
)
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
// create: the thread whose membership is exactly `ids` plus the caller, opened only
// if they don't already share one. Returning a fresh empty thread each call would
// bury the real conversation and hand the client a thread with no messages.
//
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than
// the list's single `latestMessage`, so the client can open straight into the
// conversation. The array is always present, empty for a brand-new thread.
.post(
'/thread/withmembers',
describeRoute({
tags: ['Threads'],
summary: 'Fetch or open the thread with exactly these members',
description: [
'The clients GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
'exactly `ids` plus the caller, opened only if they dont already share one (returning a',
'fresh empty thread each call would bury the real conversation). Answers the thread with',
'a `messages` array — what `messageCount` sizes — rather than the lists single',
'`latestMessage`, so the client can open straight into the conversation. The array is',
'always present, empty for a brand-new thread.',
].join(' '),
security: AUTHED,
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
// A thread needs someone else in it; naming only yourself is a bad request
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
return c.json(thread)
}
)
// A page of one thread's messages, newest first — a bare array, not a thread object.
// The client reads a conversation through either spelling: `/thread/2?messageCount=50`
// and `/thread/2/message?MessageCount=16` answer the same thing, so they share a
// handler; only the default page size differs, matching what each caller sends.
//
// 404 rather than 403 for a thread the caller isn't in: whether a thread exists is
// itself private, so a non-member gets the same answer as for a thread that's gone.
// An empty thread is still a 200 with `[]` — a conversation just opened with someone
// has no messages yet and still has to open.
// One thread with its recent messages — what the client opens a conversation with
// (`/thread/13?messageCount=50`). An OBJECT, the same shape /thread/withmembers
// answers: the client parses this one as a thread and rejects a bare array
// ("expected '{', actual '['"). Only /thread/:id/message below serves an array.
//
// 404s only for a thread the caller isn't in, not for one that's simply empty: a
// thread just opened with someone has no messages yet and still has to open.
.get(
'/thread/:id{[0-9]+}',
describeRoute({
tags: ['Threads'],
summary: 'One thread with its recent messages',
description: [
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
'serves an array. 404s only for a thread the caller isnt in, not for one thats simply',
'empty — a thread just opened with someone has no messages yet and still has to open.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
return thread === null ? c.notFound() : c.json(thread)
}
)
// Send a message to a thread that already exists — every message after the one that
// opened the conversation. `/thread/18` is what the client posts; `/thread/18/message`
// is the same call under the reference's other spelling.
//
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
// message. Blank or missing contents is invalid-arguments with no message attached,
// rather than an error status.
.post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
.post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
sendToThread(c)
)
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
// empty name clears it back to unnamed, which renders as the member list. Answers a
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/rename',
chatResultRoute(
'Rename a thread',
[
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
'which renders as the member list. The name is truncated to 128 characters rather than',
'rejected. Answers a bare ChatResult: 3 when the caller isnt on the thread, 0 on success.',
].join(' '),
{ requestBody: form(RenameThreadRequest, 'The new name') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
await setThreadName(c.env.DB, chatThreadId, name)
return c.json(CHAT_SUCCESS)
}
)
// Leave a thread. The thread and its history survive — only the caller's membership
// goes, so they stop seeing it and the remaining members keep the conversation.
//
// A "Player <@U…> left" notice is posted first, so the others see why the roster
// changed; the leaver is still a member at that moment and gets the push too, which
// is what tells their client the thread is gone.
.on(
['POST', 'DELETE'],
'/thread/:id{[0-9]+}/leave',
chatResultRoute(
'Leave a thread',
[
'The thread and its history survive — only the callers membership goes, so they stop',
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
'notice is posted first so the others see why the roster changed; the leaver is still a',
'member at that moment and gets the push too, which is what tells their client the thread',
'is gone.',
].join(' ')
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: leftChatContents(id),
})
await pushChatMessage(c, notice)
await removeThreadMember(c.env.DB, chatThreadId, id)
return c.json(CHAT_SUCCESS)
}
)
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
// per-member setting, so it never affects what anyone else sees.
//
// The client sends a boolean while the field it reads back is `snoozedUntil`, a time.
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
// `False` clears it. If the real server instead snoozes for a fixed window, this is
// the one line to change.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/snooze',
chatResultRoute(
'Snooze or unsnooze a thread',
[
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
'`False` clears it.',
].join(' '),
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const on = await formBool(c, 'snooze')
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
return c.json(CHAT_SUCCESS)
}
)
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
'Like snoozing, a per-member flag that pins the thread in the callers own inbox and',
'leaves everyone elses untouched.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS)
}
)
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
// in it — you can only pull someone into a conversation you're part of.
//
// Answers a bare ChatResult rather than an HTTP status, as the reference does: 3 when
// the caller isn't a member (which doubles as "no such thread", keeping a thread's
// existence private), 4 when the target is already on it, 0 on success. Idempotent —
// re-adding an existing member changes nothing.
.post(
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
chatResultRoute(
'Add a player to a thread',
[
'Gated on the caller already being in it — you can only pull someone into a conversation',
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
].join(' '),
{
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The account id to add (digits only)',
schema: { type: 'string' },
},
],
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
}
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const playerId = Number.parseInt(c.req.param('playerId'), 10)
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or
// `/thread/15/message/:messageId/read` for a specific message, which the client uses
// when the view sits on a message rather than the bottom. Both verbs, as the client
// sends either. Answers the bare ChatResult integer the reference does.
//
// The pointer only moves forward, and never past the thread's real latest message: an
// id the client made up (or one it read from a synthetic message) can't strand the
// thread as permanently read.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/read',
chatResultRoute(
'Mark a whole thread read',
[
'Moves the callers read pointer to the threads latest message. The pointer only moves',
'forward and never past the threads real latest message, so an id the client made up',
'cant strand the thread as permanently read. 404s for a thread the caller isnt on.',
].join(' '),
{ successDescription: 'Always 0 (success)', notFound: true }
),
(c) => markRead(c)
)
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
chatResultRoute(
'Mark read up to a specific message',
[
'What the client sends when the view sits on a message rather than the bottom. Same',
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
'isnt on.',
].join(' '),
{
parameters: [
{
name: 'messageId',
in: 'path',
required: true,
description: 'The message to read up to (digits only)',
schema: { type: 'string' },
},
],
successDescription: 'Always 0 (success)',
notFound: true,
}
),
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
)
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
// in: whether a thread exists is itself private, so a non-member gets the same answer
// as for a thread that's gone.
.get(
'/thread/:id{[0-9]+}/message',
describeRoute({
tags: ['Messages'],
summary: 'A page of one threads messages',
description: [
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isnt in:',
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
'thread thats gone. An empty thread is still a 200 with `[]`.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
}
)
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare chat',
version: '1.0.0',
description: [
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
'thread — and membership is both the authorization gate and the `playerIds` the client',
'renders. Threads, membership and messages are D1-backed; every message also fans out',
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
'client dispatches on it and silently drops a numeric one.)',
].join('\n'),
},
servers: [{ url: 'https://chat.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+11 -1
View File
@@ -1,8 +1,18 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify` worker,
// so this worker can push websocket notifications through its RPC surface.
import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & {
// add additional Bindings here
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
/** Shared `recflare` D1 — this worker owns the `message` and thread tables. */
DB: D1Database
/** The `notify` worker's NotificationsHub DO — pushes ChatMessageReceived to members. */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
}
/** Variables can be extended */
+150
View File
@@ -0,0 +1,150 @@
/**
* Chat messages on the shared `recflare` D1 database the individual messages posted
* to a chat thread (a DM pair or a group). Stored as columns rather than a JSON blob
* (mirroring the reference model): every field is a scalar the server itself reads, and the
* one client-shaped part `contents` is already an opaque string.
*
* `contents` is the client's envelope, e.g.
* `{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends."}`,
* where `Type` selects how the client renders `Data` (plain text, an invite, an image
* ) and `Version` versions that encoding. It is stored verbatim and served back
* untouched, so new message types need no schema change here.
*
* `chatMessageId` is server-assigned and unique across all threads (AUTOINCREMENT), the
* way the client expects to be able to reference a message by id alone.
*
* The `chat` worker owns this schema/migration (migrations/0001_message.sql, applied
* under its own `migrations_table` so it doesn't clash with the other workers'
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests can
* build the table directly.
*/
/** Schema DDL (mirror of migrations/0001_message.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message (
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_id INTEGER NOT NULL,
sender_player_id INTEGER NOT NULL,
time_sent TEXT NOT NULL,
contents TEXT NOT NULL,
moderation_state INTEGER NOT NULL DEFAULT 0
)`,
// Thread listing is always (thread, id) — newest-first pages walk this index.
`CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id)`,
]
/**
* Whether a message has been touched by moderation. `None` is the overwhelmingly common
* case and the column default; the others let a message be withheld from the thread
* without deleting the row.
*/
export enum ChatModerationState {
None = 0,
Flagged = 1,
Hidden = 2,
}
/** A chat message, in the shape the client sends and receives it. */
export interface ChatMessage {
chatMessageId: number
chatThreadId: number
senderPlayerId: number
/** ISO-8601 UTC instant, as .NET serializes `DateTime` (e.g. `2022-05-22T12:47:03.6536656`). */
timeSent: string
/** The raw message envelope, e.g. `{"Type":0,"Version":1,"Data":"hello"}`. */
contents: string
moderationState: ChatModerationState
}
/** A new message, before the server assigns its id and (by default) its timestamp. */
export interface NewChatMessage {
chatThreadId: number
senderPlayerId: number
contents: string
/** Defaults to now. Pass only when replaying a message with its original timestamp. */
timeSent?: string
moderationState?: ChatModerationState
}
/** The stored row, before it's mapped back to the client's camelCase shape. */
interface MessageRow {
chat_message_id: number
chat_thread_id: number
sender_player_id: number
time_sent: string
contents: string
moderation_state: number
}
function toMessage(row: MessageRow): ChatMessage {
return {
chatMessageId: row.chat_message_id,
chatThreadId: row.chat_thread_id,
senderPlayerId: row.sender_player_id,
timeSent: row.time_sent,
contents: row.contents,
moderationState: row.moderation_state,
}
}
/** Post a message to a thread, returning it with its server-assigned id. */
export async function insertMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
const row = await db
.prepare(
`INSERT INTO message (chat_thread_id, sender_player_id, time_sent, contents, moderation_state)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING *`
)
.bind(
message.chatThreadId,
message.senderPlayerId,
message.timeSent ?? new Date().toISOString(),
message.contents,
message.moderationState ?? ChatModerationState.None
)
.first<MessageRow>()
// RETURNING on an INSERT that ran always yields the row; a null here means the
// insert itself failed, which D1 would already have thrown for.
if (row === null) throw new Error('failed to insert chat message')
return toMessage(row)
}
/**
* A page of a thread's messages, newest first. `before` pages backwards through the
* history: pass the `chatMessageId` of the oldest message you already have.
*/
export async function getThreadMessages(
db: D1Database,
chatThreadId: number,
{ limit = 50, before }: { limit?: number; before?: number } = {}
): Promise<ChatMessage[]> {
const { results } = before
? await db
.prepare(
`SELECT * FROM message WHERE chat_thread_id = ?1 AND chat_message_id < ?2
ORDER BY chat_message_id DESC LIMIT ?3`
)
.bind(chatThreadId, before, limit)
.all<MessageRow>()
: await db
.prepare(
`SELECT * FROM message WHERE chat_thread_id = ?1
ORDER BY chat_message_id DESC LIMIT ?2`
)
.bind(chatThreadId, limit)
.all<MessageRow>()
return results.map(toMessage)
}
/** A single message by id, or null if there's no such message. */
export async function getMessage(
db: D1Database,
chatMessageId: number
): Promise<ChatMessage | null> {
const row = await db
.prepare('SELECT * FROM message WHERE chat_message_id = ?1')
.bind(chatMessageId)
.first<MessageRow>()
return row === null ? null : toMessage(row)
}
+234
View File
@@ -0,0 +1,234 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the chat worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* The 404 a thread-scoped route answers when the caller isn't a member. Deliberately
* indistinguishable from "no such thread" whether a thread exists is itself private.
*/
export const NOT_A_MEMBER_RESPONSE = {
description: 'Not a member of the thread (or no such thread) — the two are indistinguishable',
}
// ---- Response schemas ------------------------------------------------------
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
chatMessageId: z.int().describe('Server-assigned, unique across all threads'),
chatThreadId: z.int(),
senderPlayerId: z.int().describe('-5 is the system sender (join/leave notices)'),
timeSent: z.string().describe('ISO-8601 UTC instant, as .NET serializes DateTime'),
contents: z.string().describe('The raw client envelope, e.g. {"Type":0,"Version":1,"Data":"hi"}'),
moderationState: z.int().describe('0 None, 1 Flagged, 2 Hidden'),
})
/** The per-viewer fields every rendered thread carries, plus the thread's own. */
const threadBase = {
chatThreadId: z.int(),
playerIds: z.array(z.int()).describe('The threads members, ordered by id'),
lastReadMessageId: z
.int()
.describe('0 when never read — never null (the client deserializes a non-nullable int)'),
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
/**
* A thread as it appears in the thread LIST: the thread, its members, the caller's own
* read/snooze/favorite state, and its single most recent message.
*/
export const ChatThreadDto = z.object({
latestMessage: ChatMessageDto.nullable().describe('Null only for a thread with no messages yet'),
...threadBase,
})
/**
* A thread as it appears when a conversation is OPENED: the same fields, but with a page
* of `messages` (newest first) in place of `latestMessage`. The client is sent one or the
* other, never both; `messages` is always present, empty for a brand-new thread.
*/
export const ChatThreadWithMessagesDto = z.object({
...threadBase,
messages: z.array(ChatMessageDto).describe('Newest first; empty for a thread with nothing in it'),
})
/**
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
* thread"), 4 player already on the thread.
*/
export const ChatResult = z
.int()
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
/**
* `POST /thread` the reference's wrapper: the created (or resolved) thread plus the
* result of the first message. Blank `messageContents` opens the thread without posting
* and reports invalid-arguments (1), still with the thread attached.
*/
export const CreateThreadResponse = z.object({
chatThread: ChatThreadDto,
chatResult: ChatResult,
})
/**
* `POST /thread/:id` and `/thread/:id/message` the whole thread with its messages, not
* just the message that was sent, so the client re-renders the conversation from one
* response.
*/
export const SendMessageResponse = z.object({
chatResult: ChatResult,
chatThread: ChatThreadWithMessagesDto.nullable(),
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
service: z.literal('chat'),
status: z.literal('ok'),
})
// ---- Request schemas -------------------------------------------------------
/**
* `POST /thread` form body. `ids` is repeated (`ids=2&ids=155`) and names the OTHER
* members; the caller is always added. Values that aren't integers are dropped. The
* fields are also read from the query string, since the same call is easy to hand-write
* that way.
*/
export const CreateThreadRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageContents: z
.string()
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
].join(' ')
),
})
/**
* `POST /thread/withmembers` form body the client's GetChatBetweenPlayers. Same
* repeated `ids`, plus the page size for the returned `messages`.
*/
export const WithMembersRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageCount: z
.int()
.optional()
.describe('Page size for `messages`; defaults to 50, capped at 100'),
})
/** `POST /thread/:id` (and `/thread/:id/message`) form body. */
export const SendMessageRequest = z.object({
messageContents: z
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
messageCount: z.int().optional().describe('Page size for the returned threads `messages`'),
})
/** `POST|PUT /thread/:id/rename` form body. Any member may rename; there is no owner. */
export const RenameThreadRequest = z.object({
name: z
.string()
.describe('Truncated to 128 chars, not rejected. Empty clears it back to unnamed'),
})
/** `POST|PUT /thread/:id/snooze` form body. */
export const SnoozeThreadRequest = z.object({
snooze: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/** `PUT|POST /thread/:id/favorite` form body. */
export const FavoriteThreadRequest = z.object({
favorite: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
export const THREAD_ID_PARAM = {
name: 'id',
in: 'path',
required: true,
description: 'Chat thread id (digits only — a non-numeric path matches no route)',
schema: { type: 'string' },
} as const
/** The `MessageCount` / `messageCount` query param the GET routes accept. */
export function messageCountParam(fallback: number) {
return {
name: 'MessageCount',
in: 'query',
required: false,
description: `Page size; defaults to ${fallback}, capped at 100. \`messageCount\` is accepted too. Anything unparseable or out of range falls back rather than 400ing`,
schema: { type: 'integer' },
} as const
}
+1315 -4
View File
@@ -1,10 +1,75 @@
import { SELF } from 'cloudflare:test'
import { describe, expect, it } from 'vitest'
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import '../../chat.app'
import { NotificationType } from '../../../../notify/src/notification-types'
import {
ChatModerationState,
getMessage,
getThreadMessages,
insertMessage,
SCHEMA_DDL,
} from '../../message-db'
import {
createThread,
findThreadWithMembers,
getThreadForPlayer,
getThreadsForPlayer,
isThreadMember,
leftChatContents,
markThreadRead,
postMessage,
removeThreadMember,
setThreadFavorited,
startedChatContents,
SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL,
} from '../../thread-db'
import type { Env } from '../../context'
import type { ChatMessage } from '../../message-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
describe('chat endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
@@ -12,9 +77,1255 @@ describe('chat endpoints', () => {
expect(await res.json()).toEqual({ service: 'chat', status: 'ok' })
})
it('GET /thread returns an empty array', async () => {
it('GET /thread 401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread`)
expect(res.status).toBe(401)
})
it('GET /thread serves the caller their own threads', async () => {
const player = 881001
const thread = await createThread(env.DB, [player, 881002])
const latest = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881002,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread?MessageCount=16&Mode=0`, {
headers: await bearer(player),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual([
{
latestMessage: latest,
chatThreadId: thread,
playerIds: [player, 881002],
lastReadMessageId: 0,
chatThreadName: '',
chatThreadType: 0,
snoozedUntil: null,
isFavorited: false,
},
])
})
it('GET /thread/:id/message serves the thread newest first', async () => {
const player = 881003
const thread = await createThread(env.DB, [player, 881004])
const older = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881004,
timeSent: '2022-02-19T22:13:56.7224503',
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
})
const newer = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881004,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=16&Mode=0`, {
headers: await bearer(player),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([newer, older])
})
it('GET /thread/:id/message honours MessageCount', async () => {
const player = 881005
const thread = await createThread(env.DB, [player])
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: player,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=2`, {
headers: await bearer(player),
})
expect(await res.json()).toHaveLength(2)
})
// A thread's existence is itself private, so a non-member gets the same 404 as for a
// thread that never existed — not a 403 that confirms it's there.
it('GET /thread/:id/message hides threads the caller is not in', async () => {
const thread = await createThread(env.DB, [881006, 881007])
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message`, {
headers: await bearer(881008),
})
expect(res.status).toBe(404)
const missing = await SELF.fetch(`${ORIGIN}/thread/999999/message`, {
headers: await bearer(881008),
})
expect(missing.status).toBe(404)
})
})
describe('message storage', () => {
// A real message as the client sends it, kept verbatim (including the JSON-in-a-string
// `contents` envelope) so the round-trip is tested against the actual payload shape.
const CONTENTS =
'{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends. We\'re friends in Rec Room now!"}'
it('round-trips a message, assigning an id', async () => {
const stored = await insertMessage(env.DB, {
chatThreadId: 116181128,
senderPlayerId: 10441985,
timeSent: '2022-05-22T12:47:03.6536656',
contents: CONTENTS,
})
expect(stored.chatMessageId).toBeGreaterThan(0)
expect(stored).toEqual({
chatMessageId: stored.chatMessageId,
chatThreadId: 116181128,
senderPlayerId: 10441985,
timeSent: '2022-05-22T12:47:03.6536656',
contents: CONTENTS,
moderationState: ChatModerationState.None,
})
expect(await getMessage(env.DB, stored.chatMessageId)).toEqual(stored)
})
it('defaults timeSent to now', async () => {
const stored = await insertMessage(env.DB, {
chatThreadId: 999,
senderPlayerId: 42,
contents: CONTENTS,
})
expect(Date.parse(stored.timeSent)).toBeGreaterThan(Date.now() - 60_000)
})
it('lists a thread newest first and pages backwards', async () => {
const thread = 116181129
const first = await insertMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 1,
contents: CONTENTS,
})
const second = await insertMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 2,
contents: CONTENTS,
})
const page = await getThreadMessages(env.DB, thread)
expect(page.map((m) => m.chatMessageId)).toEqual([second.chatMessageId, first.chatMessageId])
const older = await getThreadMessages(env.DB, thread, { before: second.chatMessageId })
expect(older.map((m) => m.chatMessageId)).toEqual([first.chatMessageId])
// Messages from other threads never leak into a thread's listing.
expect(await getThreadMessages(env.DB, 404)).toEqual([])
})
})
describe('thread storage', () => {
// The viewing player from the captured thread-list response.
const VIEWER = 10441985
function contents(data: string): string {
return JSON.stringify({ Type: 0, Version: 1, Data: data })
}
it('renders the thread list in the shape the client expects', async () => {
const dm = await createThread(env.DB, [9489959, VIEWER])
const latest = await postMessage(env.DB, {
chatThreadId: dm,
senderPlayerId: VIEWER,
timeSent: '2022-05-22T12:47:03.6536656',
contents: contents(
"This is jordanparki7 from your Oculus friends. We're friends in Rec Room now!"
),
})
await markThreadRead(env.DB, dm, VIEWER, latest.chatMessageId)
const [thread] = await getThreadsForPlayer(env.DB, VIEWER)
expect(thread).toEqual({
latestMessage: latest,
chatThreadId: dm,
playerIds: [9489959, VIEWER],
lastReadMessageId: latest.chatMessageId,
chatThreadName: '',
chatThreadType: 0,
snoozedUntil: null,
isFavorited: false,
})
})
it('keeps a named group thread with all its members', async () => {
const members = [VIEWER, 10452682, 12534039, 12535328, 12631702]
const group = await createThread(env.DB, members, 'Group Chat =]')
await postMessage(env.DB, {
chatThreadId: group,
senderPlayerId: VIEWER,
contents: contents('sussy baka'),
})
const thread = await getThreadForPlayer(env.DB, group, VIEWER)
expect(thread?.chatThreadName).toBe('Group Chat =]')
expect(thread?.playerIds).toEqual(members.sort((a, b) => a - b))
})
// System notices ("Player <@U…> started a chat") and player messages both carry
// markup the server must not touch — the mention token, and HTML entities the
// client escaped itself. Stored and served back byte-for-byte.
it('stores message contents verbatim, markup and all', async () => {
const thread = await createThread(env.DB, [VIEWER, 29565301])
const notice = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 29565301,
contents: contents('Player <@U29565301> started a chat'),
})
const escaped = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 29563053,
contents: contents('Ly2 bae &lt;&lt;&lt;333'),
})
expect(await getMessage(env.DB, notice.chatMessageId)).toEqual(notice)
expect((await getMessage(env.DB, escaped.chatMessageId))?.contents).toBe(escaped.contents)
})
it('orders threads newest first and honours the page size', async () => {
const viewer = 777001
const older = await createThread(env.DB, [viewer, 1])
const newer = await createThread(env.DB, [viewer, 2])
await postMessage(env.DB, {
chatThreadId: older,
senderPlayerId: viewer,
contents: contents('first'),
})
await postMessage(env.DB, {
chatThreadId: newer,
senderPlayerId: viewer,
contents: contents('second'),
})
const threads = await getThreadsForPlayer(env.DB, viewer)
expect(threads.map((t) => t.chatThreadId)).toEqual([newer, older])
expect(await getThreadsForPlayer(env.DB, viewer, { limit: 1 })).toHaveLength(1)
})
it('gates reads on membership', async () => {
const thread = await createThread(env.DB, [777002, 777003])
expect(await isThreadMember(env.DB, thread, 777002)).toBe(true)
expect(await isThreadMember(env.DB, thread, 777004)).toBe(false)
// A non-member sees neither the thread nor its place in their own list.
expect(await getThreadForPlayer(env.DB, thread, 777004)).toBeNull()
expect(await getThreadsForPlayer(env.DB, 777004)).toEqual([])
})
it('keeps read state, favorites, and snoozes per viewer', async () => {
const a = 777005
const b = 777006
const thread = await createThread(env.DB, [a, b])
const first = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: a,
contents: contents('one'),
})
const second = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: b,
contents: contents('two'),
})
await markThreadRead(env.DB, thread, a, second.chatMessageId)
await setThreadFavorited(env.DB, thread, a, true)
await markThreadRead(env.DB, thread, b, first.chatMessageId)
const forA = await getThreadForPlayer(env.DB, thread, a)
const forB = await getThreadForPlayer(env.DB, thread, b)
expect(forA?.lastReadMessageId).toBe(second.chatMessageId)
expect(forA?.isFavorited).toBe(true)
expect(forB?.lastReadMessageId).toBe(first.chatMessageId)
expect(forB?.isFavorited).toBe(false)
// A late ack from a second client can't walk the thread back to unread.
await markThreadRead(env.DB, thread, a, first.chatMessageId)
expect((await getThreadForPlayer(env.DB, thread, a))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('leaves an empty thread with no latest message', async () => {
const thread = await createThread(env.DB, [777007])
expect(await getThreadForPlayer(env.DB, thread, 777007)).toMatchObject({
latestMessage: null,
lastReadMessageId: 0,
playerIds: [777007],
})
})
it('drops a removed member from the roster but keeps the thread', async () => {
const thread = await createThread(env.DB, [777008, 777009])
await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 777008,
contents: contents('mellon'),
})
await removeThreadMember(env.DB, thread, 777009)
expect(await getThreadForPlayer(env.DB, thread, 777009)).toBeNull()
expect((await getThreadForPlayer(env.DB, thread, 777008))?.playerIds).toEqual([777008])
})
})
describe('POST /thread/withmembers', () => {
async function withMembers(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('opens a thread with the named players plus the caller', async () => {
const caller = 882001
const res = await withMembers(caller, 'ids=2&ids=155&messageCount=50')
expect(res.status).toBe(200)
const thread = (await res.json()) as {
chatThreadId: number
playerIds: number[]
messages: unknown[]
}
// A page of messages, not the list's single latestMessage. A brand-new thread
// isn't empty: it opens with the system "started a chat" notice.
expect(thread.messages).toEqual([
expect.objectContaining({
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(caller),
}),
])
expect(thread).toMatchObject({
playerIds: [2, 155, caller],
lastReadMessageId: 0,
chatThreadName: '',
snoozedUntil: null,
isFavorited: false,
})
expect(thread).not.toHaveProperty('latestMessage')
// The thread is real: it shows up in the caller's list, and its members can read it.
expect((await getThreadsForPlayer(env.DB, caller)).map((t) => t.chatThreadId)).toContain(
thread.chatThreadId
)
expect(await isThreadMember(env.DB, thread.chatThreadId, 155)).toBe(true)
})
// Two nulls GetChatBetweenPlayers can't survive: lastReadMessageId deserializes into
// a non-nullable int ("expected 'Number Token', actual 'null'"), and chatThreadName
// is dereferenced unchecked (NullReferenceException). Unread is 0, unnamed is ''.
it('never serializes lastReadMessageId or chatThreadName as null', async () => {
const res = await withMembers(882008, 'ids=153')
const body = await res.text()
expect(body).not.toContain('"lastReadMessageId":null')
expect(body).not.toContain('"chatThreadName":null')
const thread = JSON.parse(body) as { lastReadMessageId: number; chatThreadName: string }
expect(thread.lastReadMessageId).toBe(0)
expect(thread.chatThreadName).toBe('')
})
it('collapses duplicate ids and the caller naming themselves', async () => {
const caller = 882002
const res = await withMembers(caller, `ids=${caller}&ids=882003&ids=882003`)
expect(((await res.json()) as { playerIds: number[] }).playerIds).toEqual([caller, 882003])
})
// Fetch-or-create: reopening a chat with the same people must land back in the
// conversation that already has the history, not a fresh empty one.
it('returns the existing thread rather than opening a second', async () => {
const caller = 882004
const first = (await (await withMembers(caller, 'ids=882005')).json()) as {
chatThreadId: number
}
await postMessage(env.DB, {
chatThreadId: first.chatThreadId,
senderPlayerId: 882005,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const second = (await (await withMembers(caller, 'ids=882005')).json()) as {
chatThreadId: number
messages: unknown[]
}
expect(second.chatThreadId).toBe(first.chatThreadId)
// The opening notice plus the real message — reopening adds neither a thread nor
// a second notice.
expect(second.messages).toHaveLength(2)
})
// Membership is matched as a whole set, so a DM isn't mistaken for a group that
// happens to contain the same two people.
it('does not confuse a subset or superset for the same thread', async () => {
const caller = 882009
const pair = (await (await withMembers(caller, 'ids=882010')).json()) as {
chatThreadId: number
}
const trio = (await (await withMembers(caller, 'ids=882010&ids=882011')).json()) as {
chatThreadId: number
}
expect(trio.chatThreadId).not.toBe(pair.chatThreadId)
})
it('honours messageCount when paging the thread', async () => {
const caller = 882012
const opened = (await (await withMembers(caller, 'ids=882013')).json()) as {
chatThreadId: number
}
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId: opened.chatThreadId,
senderPlayerId: caller,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const paged = (await (await withMembers(caller, 'ids=882013&messageCount=2')).json()) as {
messages: unknown[]
}
expect(paged.messages).toHaveLength(2)
})
it('rejects a thread with nobody else in it', async () => {
const caller = 882006
expect((await withMembers(caller, '')).status).toBe(400)
expect((await withMembers(caller, `ids=${caller}`)).status).toBe(400)
expect((await withMembers(caller, 'ids=notanumber')).status).toBe(400)
})
it('rejects an oversized roster', async () => {
const ids = Array.from({ length: 60 }, (_, i) => `ids=${883000 + i}`).join('&')
expect((await withMembers(882007, ids)).status).toBe(400)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=2',
})
expect(res.status).toBe(401)
})
})
describe('POST /thread', () => {
async function createViaPost(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
// The call the client actually makes after /thread/withmembers: members, blank
// contents. A blank field must not post an empty message, and reports
// invalid-arguments rather than success.
it('opens a thread with no message when messageContents is blank', async () => {
const caller = 884001
const res = await createViaPost(caller, 'ids=155&ids=2&messageContents=')
expect(res.status).toBe(200)
const body = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { senderPlayerId: number } | null }
chatResult: number
}
expect(body.chatResult).toBe(1)
expect(body.chatThread).toMatchObject({ playerIds: [2, 155, caller] })
// Nothing of the caller's was posted — but the thread still isn't empty: it opens
// with the system notice, which is what the client needs to render it at all.
const messages = await getThreadMessages(env.DB, body.chatThread.chatThreadId)
expect(messages).toEqual([
expect.objectContaining({
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(caller),
}),
])
expect(body.chatThread.latestMessage?.senderPlayerId).toBe(SYSTEM_SENDER_ID)
})
it('posts the first message when messageContents is given', async () => {
const caller = 884002
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
const res = await createViaPost(
caller,
`ids=884003&messageContents=${encodeURIComponent(contents)}`
)
const body = (await res.json()) as {
chatThread: {
chatThreadId: number
latestMessage: { contents: string; senderPlayerId: number } | null
}
chatResult: number
}
expect(body.chatResult).toBe(0)
// Stored verbatim, attributed to the caller, and already the thread's latest.
expect(body.chatThread.latestMessage).toMatchObject({ contents, senderPlayerId: caller })
// The opening notice, then the caller's message.
expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
})
// Sending to people you already have a thread with appends to it, rather than
// stranding the message in a second conversation.
it('appends to the existing thread with the same members', async () => {
const caller = 884005
const first = (await (
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22one%22%7D')
).json()) as { chatThread: { chatThreadId: number } }
const second = (await (
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22two%22%7D')
).json()) as { chatThread: { chatThreadId: number } }
expect(second.chatThread.chatThreadId).toBe(first.chatThread.chatThreadId)
// The opening notice, then both messages.
expect(await getThreadMessages(env.DB, first.chatThread.chatThreadId)).toHaveLength(3)
})
it('rejects a thread with nobody else in it', async () => {
expect((await createViaPost(884004, 'messageContents=')).status).toBe(400)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=2&messageContents=',
})
expect(res.status).toBe(401)
})
})
describe('GET /thread/:id', () => {
async function openThread(caller: number, chatThreadId: number, query = '?messageCount=50') {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}${query}`, {
headers: await bearer(caller),
})
}
it('opens a thread with its recent messages, newest first', async () => {
const caller = 885001
const chatThreadId = await createThread(env.DB, [caller, 885002])
const older = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885002,
timeSent: '2022-02-19T22:13:56.7224503',
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
})
const newer = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885002,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await openThread(caller, chatThreadId)
expect(res.status).toBe(200)
// An object, not a bare array: the client parses this one as a thread and rejects
// an array outright ("expected '{', actual '['").
const body = await res.text()
expect(body.startsWith('{')).toBe(true)
const thread = JSON.parse(body) as { messages: unknown[] }
expect(thread).toMatchObject({
chatThreadId,
playerIds: [caller, 885002],
lastReadMessageId: 0,
chatThreadName: '',
snoozedUntil: null,
isFavorited: false,
})
expect(thread.messages).toEqual([newer, older])
expect(thread).not.toHaveProperty('latestMessage')
})
// The sibling route serves the same messages as a bare array — the two shapes are
// deliberately different, and the client depends on which is which.
it('carries the same messages /thread/:id/message serves as an array', async () => {
const caller = 885010
const chatThreadId = await createThread(env.DB, [caller, 885011])
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885011,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const thread = (await (await openThread(caller, chatThreadId)).json()) as {
messages: unknown[]
}
const messages = await (
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/message?MessageCount=50`, {
headers: await bearer(caller),
})
).json()
expect(Array.isArray(messages)).toBe(true)
expect(thread.messages).toEqual(messages)
})
it('honours messageCount', async () => {
const caller = 885003
const chatThreadId = await createThread(env.DB, [caller, 885004])
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: caller,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const res = await openThread(caller, chatThreadId, '?messageCount=2')
expect(((await res.json()) as { messages: unknown[] }).messages).toHaveLength(2)
})
// A thread opened moments ago has nothing in it and still has to open — an empty
// messages array, not a 404.
it('opens an empty thread with an empty messages array', async () => {
const caller = 885005
const chatThreadId = await createThread(env.DB, [caller, 885006])
const res = await openThread(caller, chatThreadId)
expect(res.status).toBe(200)
const { messages } = (await res.json()) as { messages: unknown[] }
// Built directly by createThread with no starter, so genuinely empty.
expect(messages).toEqual([])
})
it('hides threads the caller is not in', async () => {
const chatThreadId = await createThread(env.DB, [885007, 885008])
expect((await openThread(885009, chatThreadId)).status).toBe(404)
expect((await openThread(885009, 999999)).status).toBe(404)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1?messageCount=50`)
expect(res.status).toBe(401)
})
})
describe('ChatMessageReceived push', () => {
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
interface SentNotification {
playerId: number
notificationType: NotificationType
data: Record<string, unknown>
}
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): { takeSent(): Promise<SentNotification[]> }
}
async function send(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
beforeEach(async () => {
await hub.getByName('global').takeSent()
})
// Every member is notified, the sender included: the client doesn't fold the HTTP
// response into its thread cache, so without a self-push its own message doesn't
// show until the next refetch.
it('notifies every member of the thread, sender included', async () => {
const caller = 886001
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
const res = await send(
caller,
`ids=886002&ids=886003&messageContents=${encodeURIComponent(contents)}`
)
const { chatThread } = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { chatMessageId: number } }
}
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
senderPlayerId: caller,
timeSent: expect.any(String),
contents,
moderationState: 0,
})
})
it('pushes nothing when there is no message to push', async () => {
await send(886004, 'ids=886005&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
})
// A membership row whose thread row is gone must never be resolved to: it would hand
// back an id nothing can render ("thread N vanished after creation"), and because the
// oldest match wins it would keep winning on every later call.
describe('orphaned membership rows', () => {
it('ignores members of a thread whose message_thread row is gone', async () => {
const caller = 887001
const other = 887002
const orphaned = await createThread(env.DB, [caller, other])
await env.DB.prepare('DELETE FROM message_thread WHERE chat_thread_id = ?1')
.bind(orphaned)
.run()
expect(await findThreadWithMembers(env.DB, [caller, other])).toBeNull()
// Opening the chat recovers: a usable thread comes back, and it isn't the orphan.
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `ids=${other}&messageCount=50`,
})
expect(res.status).toBe(200)
const thread = (await res.json()) as { chatThreadId: number; playerIds: number[] }
expect(thread.chatThreadId).not.toBe(orphaned)
expect(thread.playerIds).toEqual([caller, other])
// And it stays stable — the orphan never wins a later lookup.
expect(await findThreadWithMembers(env.DB, [caller, other])).toBe(thread.chatThreadId)
})
})
describe('marking a thread read', () => {
async function read(caller: number, path: string, method = 'POST') {
return SELF.fetch(`${ORIGIN}${path}`, { method, headers: await bearer(caller) })
}
it('moves the pointer to a specific message, on both verbs', async () => {
const caller = 888001
const chatThreadId = await createThread(env.DB, [caller, 888002])
const first = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888002,
contents: '{"Type":0,"Version":1,"Data":"one"}',
})
const second = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888002,
contents: '{"Type":0,"Version":1,"Data":"two"}',
})
const res = await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
expect(res.status).toBe(200)
// The bare ChatResult integer, not an envelope.
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
first.chatMessageId
)
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`, 'PUT')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('marks the whole thread read without a message id', async () => {
const caller = 888003
const chatThreadId = await createThread(env.DB, [caller, 888004])
const latest = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888004,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
expect((await read(caller, `/thread/${chatThreadId}/read`)).status).toBe(200)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
latest.chatMessageId
)
})
// The client acks whatever id it was shown — including the synthetic message's
// 9007199254740976. Clamping keeps that from stranding the thread as read forever.
it('clamps an id beyond the thread to the real latest message', async () => {
const caller = 888005
const chatThreadId = await createThread(env.DB, [caller, 888006])
const real = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888006,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
await read(caller, `/thread/${chatThreadId}/message/9007199254740976/read`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
real.chatMessageId
)
// A later real message is still unread, rather than swallowed by the bogus ack.
const next = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888006,
contents: '{"Type":0,"Version":1,"Data":"later"}',
})
expect(
(await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId
).toBeLessThan(next.chatMessageId)
})
it('never moves the pointer backwards', async () => {
const caller = 888007
const chatThreadId = await createThread(env.DB, [caller, 888008])
const first = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888008,
contents: '{"Type":0,"Version":1,"Data":"one"}',
})
const second = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888008,
contents: '{"Type":0,"Version":1,"Data":"two"}',
})
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`)
await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [888009, 888010])
expect((await read(888011, `/thread/${chatThreadId}/read`)).status).toBe(404)
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/read`, { method: 'POST' })
expect(anon.status).toBe(401)
})
})
describe('POST /thread/:id', () => {
// The exact body the client sends: a Version 2 envelope whose Data carries a `<=>`
// prefix. Nothing in the worker parses it, so it must survive byte-for-byte.
const CONTENTS = '{"Type":0,"Version":2,"Data":"<=>hey"}'
async function send(caller: number, path: string, contents = CONTENTS) {
return SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `messageContents=${encodeURIComponent(contents)}`,
})
}
it('appends to an existing thread and answers the send wrapper', async () => {
const caller = 889001
const chatThreadId = await createThread(env.DB, [caller, 889002], null, caller)
const res = await send(caller, `/thread/${chatThreadId}`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
chatResult: number
chatThread: {
chatThreadId: number
playerIds: number[]
lastReadMessageId: number
chatThreadType: number
messages: ChatMessage[]
}
}
expect(body.chatResult).toBe(0)
// The whole thread comes back, newest message first, with the opening notice
// beneath it — the shape the client re-renders the conversation from.
expect(body.chatThread).toMatchObject({ chatThreadId, playerIds: [caller, 889002] })
expect(body.chatThread.messages).toHaveLength(2)
expect(body.chatThread.messages[0]).toMatchObject({
chatThreadId,
senderPlayerId: caller,
contents: CONTENTS,
moderationState: 0,
})
expect(body.chatThread.messages[1]).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID })
// Sending marks the thread read for the sender, so it doesn't come back unread.
expect(body.chatThread.lastReadMessageId).toBe(body.chatThread.messages[0]!.chatMessageId)
// And it's stored, not just echoed.
expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages)
})
it('accepts the /thread/:id/message spelling too', async () => {
const caller = 889003
const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller)
const res = await send(caller, `/thread/${chatThreadId}/message`)
expect(((await res.json()) as { chatResult: number }).chatResult).toBe(0)
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(2)
})
it('pushes ChatMessageReceived to every member', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): {
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
}
}
const caller = 889005
const chatThreadId = await createThread(env.DB, [caller, 889006], null, caller)
await hub.getByName('global').takeSent()
await send(caller, `/thread/${chatThreadId}`)
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
const caller = 889007
const chatThreadId = await createThread(env.DB, [caller, 889008], null, caller)
const res = await send(caller, `/thread/${chatThreadId}`, ' ')
expect(res.status).toBe(200)
const body = (await res.json()) as { chatResult: number; chatThread: { messages: unknown[] } }
expect(body.chatResult).toBe(1)
// The thread still comes back — only the opening notice is in it.
expect(body.chatThread.messages).toHaveLength(1)
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'messageContents=hi',
})
expect(anon.status).toBe(401)
})
})
describe('POST /thread/:id/member/:playerId', () => {
async function addMember(caller: number, chatThreadId: number, playerId: number) {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${playerId}`, {
method: 'POST',
headers: await bearer(caller),
})
}
it('adds a player to a thread the caller is in', async () => {
const caller = 890001
const chatThreadId = await createThread(env.DB, [caller, 890002], null, caller)
const res = await addMember(caller, chatThreadId, 890003)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect(await isThreadMember(env.DB, chatThreadId, 890003)).toBe(true)
// The new member sees the thread, its history included.
const thread = await getThreadForPlayer(env.DB, chatThreadId, 890003)
expect(thread?.playerIds).toEqual([caller, 890002, 890003])
})
it('reports the player is already on the thread', async () => {
const caller = 890004
const chatThreadId = await createThread(env.DB, [caller, 890005], null, caller)
expect(await (await addMember(caller, chatThreadId, 890005)).json()).toBe(4)
})
// A non-member gets the same answer as for a thread that doesn't exist, so the
// endpoint can't be used to probe for threads.
it('refuses a caller who is not on the thread', async () => {
const chatThreadId = await createThread(env.DB, [890006, 890007], null, 890006)
expect(await (await addMember(890008, chatThreadId, 890009)).json()).toBe(3)
expect(await isThreadMember(env.DB, chatThreadId, 890009)).toBe(false)
expect(await (await addMember(890008, 999999, 890009)).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/member/2`, { method: 'POST' })
expect(res.status).toBe(401)
})
})
describe('renaming and leaving a thread', () => {
async function post(caller: number, path: string, body?: string, method = 'POST') {
return SELF.fetch(`${ORIGIN}${path}`, {
method,
headers: {
...(await bearer(caller)),
...(body === undefined ? {} : { 'Content-Type': 'application/x-www-form-urlencoded' }),
},
body,
})
}
it('renames a thread for everyone on it', async () => {
const caller = 891001
const chatThreadId = await createThread(env.DB, [caller, 891002], null, caller)
const res = await post(caller, `/thread/${chatThreadId}/rename`, 'name=my%20chat')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// Both members see the new name.
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('my chat')
expect((await getThreadForPlayer(env.DB, chatThreadId, 891002))?.chatThreadName).toBe('my chat')
})
it('clears the name back to unnamed, never null', async () => {
const caller = 891003
const chatThreadId = await createThread(env.DB, [caller, 891004], 'Group Chat =]', caller)
await post(caller, `/thread/${chatThreadId}/rename`, 'name=')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('')
})
it('truncates an overlong name rather than rejecting it', async () => {
const caller = 891005
const chatThreadId = await createThread(env.DB, [caller, 891006], null, caller)
await post(caller, `/thread/${chatThreadId}/rename`, `name=${'x'.repeat(200)}`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toHaveLength(
128
)
})
it('refuses to rename a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [891007, 891008], null, 891007)
expect(await (await post(891009, `/thread/${chatThreadId}/rename`, 'name=nope')).json()).toBe(3)
expect((await getThreadForPlayer(env.DB, chatThreadId, 891007))?.chatThreadName).toBe('')
})
it('leaves a thread, posting the notice and keeping the history', async () => {
const caller = 891010
const stayer = 891011
const chatThreadId = await createThread(env.DB, [caller, stayer], null, caller)
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: caller,
contents: '{"Type":0,"Version":1,"Data":"bye"}',
})
const res = await post(caller, `/thread/${chatThreadId}/leave`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// Gone for the leaver, intact for everyone else.
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
expect(await getThreadForPlayer(env.DB, chatThreadId, caller)).toBeNull()
const remaining = await getThreadForPlayer(env.DB, chatThreadId, stayer)
expect(remaining?.playerIds).toEqual([stayer])
expect(remaining?.latestMessage).toMatchObject({
senderPlayerId: SYSTEM_SENDER_ID,
contents: leftChatContents(caller),
})
// Opening notice, the message, and the leave notice.
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(3)
})
it('accepts DELETE for leave as well as POST', async () => {
const caller = 891012
const chatThreadId = await createThread(env.DB, [caller, 891013], null, caller)
expect(
await (await post(caller, `/thread/${chatThreadId}/leave`, undefined, 'DELETE')).json()
).toBe(0)
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
})
it('reports membership-not-found when leaving a thread you are not on', async () => {
const chatThreadId = await createThread(env.DB, [891014, 891015], null, 891014)
expect(await (await post(891016, `/thread/${chatThreadId}/leave`)).json()).toBe(3)
// Nothing was posted to a thread the caller has no business touching.
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
it('401s without a token', async () => {
const rename = await SELF.fetch(`${ORIGIN}/thread/1/rename`, { method: 'POST' })
expect(rename.status).toBe(401)
const leave = await SELF.fetch(`${ORIGIN}/thread/1/leave`, { method: 'POST' })
expect(leave.status).toBe(401)
})
})
describe('POST /thread/:id/snooze', () => {
async function snooze(caller: number, chatThreadId: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/snooze`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('snoozes and unsnoozes for the caller alone', async () => {
const caller = 892001
const other = 892002
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
const res = await snooze(caller, chatThreadId, 'snooze=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBe(
'9999-12-31T23:59:59Z'
)
// Snoozing is per-member: the other player is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.snoozedUntil).toBeNull()
await snooze(caller, chatThreadId, 'snooze=False')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBeNull()
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [892003, 892004], null, 892003)
expect(await (await snooze(892005, chatThreadId, 'snooze=True')).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/snooze`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'snooze=True',
})
expect(res.status).toBe(401)
})
})
describe('PUT /thread/:id/favorite', () => {
async function favorite(caller: number, chatThreadId: number, body: string, method = 'PUT') {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/favorite`, {
method,
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('favorites and unfavorites for the caller alone', async () => {
const caller = 893001
const other = 893002
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
const res = await favorite(caller, chatThreadId, 'favorite=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
// Per-member, like snoozing: the other player's inbox is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
await favorite(caller, chatThreadId, 'favorite=False')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
})
it('accepts POST as well as PUT', async () => {
const caller = 893003
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/favorite`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'favorite=True',
})
expect(res.status).toBe(401)
})
})
describe('openapi', () => {
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
// and emitted a reference the framework didn't hoist into components.schemas.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on([...], …)` routes contribute every method they were registered for.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /thread/{id}/leave',
'GET /',
'GET /thread',
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
'POST /thread/{id}/leave',
'POST /thread/{id}/member/{playerId}',
'POST /thread/{id}/message',
'POST /thread/{id}/message/{messageId}/read',
'POST /thread/{id}/read',
'POST /thread/{id}/rename',
'POST /thread/{id}/snooze',
'PUT /thread/{id}/favorite',
'PUT /thread/{id}/message/{messageId}/read',
'PUT /thread/{id}/read',
'PUT /thread/{id}/rename',
'PUT /thread/{id}/snooze',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+473
View File
@@ -0,0 +1,473 @@
/**
* Chat threads and their membership on the shared `recflare` D1 database. A thread is a
* conversation a DM pair, a named group chat, or a system thread; the messages in it
* live in `message` (see message-db.ts).
*
* Membership (`thread_member`) does double duty: it is the authorization gate a
* player may read or post to a thread only if they hold a row and it is what renders
* the `playerIds` array the client shows. Nothing here has a foreign key to accounts,
* here or on a message's sender: that table belongs to the `auth` worker, and a thread
* outlives the accounts in it.
*
* The thread denormalizes `latest_message_id` so the thread list renders from one
* indexed row per thread rather than a per-thread MAX() over `message`, and so it can
* be ordered by recency without a join message ids are monotonic, so the highest id
* is the newest thread. `postMessage` keeps it in sync.
*
* The per-viewer fields `lastReadMessageId`, `snoozedUntil`, `isFavorited` live on
* the membership row, not the thread: two players in one DM have independent read
* positions, snoozes, and favorites.
*
* The `chat` worker owns this schema/migration (migrations/0002_thread.sql).
* `THREAD_SCHEMA_DDL` mirrors it so tests can build the tables directly.
*/
import { insertMessage } from './message-db'
import type { ChatMessage, NewChatMessage } from './message-db'
/** Schema DDL (mirror of migrations/0002_thread.sql). */
export const THREAD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_name TEXT,
latest_message_id INTEGER,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id)`,
`CREATE TABLE IF NOT EXISTS thread_member (
chat_thread_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
last_read_message_id INTEGER,
snoozed_until TEXT,
is_favorited INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_thread_id, player_id)
)`,
// The thread-list query is "every thread this player is in", so player_id leads.
`CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id)`,
]
/**
* A thread as the client receives it: the thread, its members, its most recent message,
* and the viewing player's own read/snooze/favorite state. This is the element shape of
* the thread-list response.
*/
export interface ChatThread {
/** Null only for a thread with no messages yet. */
latestMessage: ChatMessage | null
chatThreadId: number
playerIds: number[]
/**
* 0 when the player has never read the thread never null. The client deserializes
* this into a non-nullable int and fails the whole response on a null ("expected
* 'Number Token', actual 'null'"), unlike `latestMessage`, which it accepts as null.
*/
lastReadMessageId: number
/**
* Empty for DMs and unnamed groups never null. The client dereferences this name
* without a null check (a null NullReferenceExceptions its way out of
* GetChatBetweenPlayers) and falls back to naming the members when it's blank.
*/
chatThreadName: string
/**
* Which kind of conversation this is. Every thread the reference serves here comes back
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant
* but the field itself has to be present: the client deserializes it as a non-nullable
* int and drops the whole response when it's missing.
*/
chatThreadType: number
snoozedUntil: string | null
isFavorited: boolean
}
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
const CHAT_THREAD_TYPE_DEFAULT = 0
/** The joined row backing a rendered thread, before it's shaped for the client. */
interface ThreadRow {
chat_thread_id: number
chat_thread_name: string | null
player_ids: string | null
last_read_message_id: number | null
snoozed_until: string | null
is_favorited: number
msg_chat_message_id: number | null
msg_chat_thread_id: number | null
msg_sender_player_id: number | null
msg_time_sent: string | null
msg_contents: string | null
msg_moderation_state: number | null
}
function toThread(row: ThreadRow): ChatThread {
return {
latestMessage:
row.msg_chat_message_id === null
? null
: {
chatMessageId: row.msg_chat_message_id,
chatThreadId: row.msg_chat_thread_id!,
senderPlayerId: row.msg_sender_player_id!,
timeSent: row.msg_time_sent!,
contents: row.msg_contents!,
moderationState: row.msg_moderation_state!,
},
chatThreadId: row.chat_thread_id,
// group_concat of the membership rows, already ordered by player id.
playerIds: row.player_ids === null ? [] : row.player_ids.split(',').map(Number),
// Null in the column means "never read"; the client insists on a number.
lastReadMessageId: row.last_read_message_id ?? 0,
// Null in the column means "unnamed"; the client dereferences it unchecked.
chatThreadName: row.chat_thread_name ?? '',
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
snoozedUntil: row.snoozed_until,
isFavorited: row.is_favorited !== 0,
}
}
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Reads only threads the player is a member of, so the membership join is the
* authorization check as well as the query. The inner ordered subquery around
* group_concat is what makes `playerIds` come back sorted rather than in row order.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
WHERE me.player_id = ?1
ORDER BY t.latest_message_id DESC
LIMIT ?2`
)
.bind(playerId, limit)
.all<ThreadRow>()
return results.map(toThread)
}
/** One thread as it renders for one player, or null if they aren't a member of it. */
export async function getThreadForPlayer(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<ChatThread | null> {
const row = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
)
.bind(chatThreadId, playerId)
.first<ThreadRow>()
return row === null ? null : toThread(row)
}
/**
* Whether a player may read or post to a thread. Every thread-scoped route gates on
* this before touching messages.
*/
export async function isThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<boolean> {
const row = await db
.prepare('SELECT 1 AS ok FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
.bind(chatThreadId, playerId)
.first<{ ok: number }>()
return row !== null
}
/**
* The pseudo-player system messages are sent as. Not a real account the client renders
* a message from this sender as a notice rather than as someone speaking, which is why
* `message.sender_player_id` carries no foreign key and permits negative ids.
*/
export const SYSTEM_SENDER_ID = -5
/**
* The notice a thread opens with: `Player <@U10441985> started a chat`. The `<@U…>` token
* is a mention the client resolves to a display name, so the id goes in raw.
*/
export function startedChatContents(playerId: number): string {
return JSON.stringify({
Type: 0,
Version: 1,
Data: `Player <@U${playerId}> started a chat`,
})
}
/**
* The notice left behind when someone walks out of a group: `Player <@U14922080> left`.
* Same `<@U…>` mention token the opening notice uses.
*/
export function leftChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
}
/**
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
* list rather than a blank title.
*/
export async function setThreadName(
db: D1Database,
chatThreadId: number,
name: string
): Promise<void> {
await db
.prepare('UPDATE message_thread SET chat_thread_name = ?2 WHERE chat_thread_id = ?1')
.bind(chatThreadId, name === '' ? null : name)
.run()
}
/**
* Open a thread between a set of players, returning its new id. `name` is null for DMs
* and unnamed groups. Duplicate player ids collapse, so a caller need not dedupe.
*
* Pass `startedBy` to open the thread the way the real server does with a system
* "started a chat" notice as its first message. A thread with no messages at all is one
* the client won't display, so every thread born from a request gets one; the parameter
* is optional only so tests can build a bare thread directly.
*
* Every call opens a *distinct* thread, even for a member set that already has one
* threads are not keyed by their membership, and the same pair may hold several.
*/
export async function createThread(
db: D1Database,
playerIds: number[],
name: string | null = null,
startedBy?: number
): Promise<number> {
const row = await db
.prepare(
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
RETURNING chat_thread_id`
)
.bind(name, new Date().toISOString())
.first<{ chat_thread_id: number }>()
if (row === null) throw new Error('failed to create chat thread')
const members = [...new Set(playerIds)]
if (members.length > 0) {
await db.batch(
members.map((playerId) =>
db
.prepare(
`INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id)
VALUES (?1, ?2)`
)
.bind(row.chat_thread_id, playerId)
)
)
}
if (startedBy !== undefined) {
await postMessage(db, {
chatThreadId: row.chat_thread_id,
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(startedBy),
})
}
return row.chat_thread_id
}
/**
* The existing thread whose membership is *exactly* this set of players, or null. The
* oldest match wins, so a set that somehow accumulated duplicates keeps resolving to the
* conversation with the history in it.
*
* This is what makes "open a chat with these people" reuse the conversation you already
* have with them rather than starting an empty one each time. Matching is on the whole
* set: a DM and a group that happens to contain those two people are different threads.
*
* Only threads that still have a `message_thread` row can match. Membership rows whose
* thread is gone are ignored rather than resolved to: matching one would hand back an id
* that nothing else in the worker can render, and since the oldest match wins it
* would keep winning on every subsequent call.
*/
export async function findThreadWithMembers(
db: D1Database,
playerIds: number[]
): Promise<number | null> {
const members = [...new Set(playerIds)]
if (members.length === 0) return null
// ?1 is the member count; ?2… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
const row = await db
.prepare(
`SELECT m.chat_thread_id FROM thread_member m
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
GROUP BY m.chat_thread_id
HAVING COUNT(*) = ?1
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
ORDER BY m.chat_thread_id
LIMIT 1`
)
.bind(members.length, ...members)
.first<{ chat_thread_id: number }>()
return row?.chat_thread_id ?? null
}
/**
* The thread with exactly these members, opening one if it doesn't exist yet. Two
* simultaneous first-messages to the same set can still race into two threads; the
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
* them afterwards.
*/
export async function getOrCreateThreadWithMembers(
db: D1Database,
playerIds: number[],
startedBy: number
): Promise<number> {
return (
(await findThreadWithMembers(db, playerIds)) ??
(await createThread(db, playerIds, null, startedBy))
)
}
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
export async function getThreadMemberIds(db: D1Database, chatThreadId: number): Promise<number[]> {
const { results } = await db
.prepare('SELECT player_id FROM thread_member WHERE chat_thread_id = ?1 ORDER BY player_id')
.bind(chatThreadId)
.all<{ player_id: number }>()
return results.map((r) => r.player_id)
}
/** Add a player to an existing thread. A no-op if they're already in it. */
export async function addThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<void> {
await db
.prepare('INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)')
.bind(chatThreadId, playerId)
.run()
}
/** Remove a player from a thread. The thread and its messages outlive the membership. */
export async function removeThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<void> {
await db
.prepare('DELETE FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
.bind(chatThreadId, playerId)
.run()
}
/**
* Post a message and advance the thread's denormalized `latest_message_id` the only
* way messages should be written, so the thread list never goes stale. Two statements
* rather than a batch, because the update needs the id the insert assigns.
*/
export async function postMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
const stored = await insertMessage(db, message)
await db
.prepare('UPDATE message_thread SET latest_message_id = ?2 WHERE chat_thread_id = ?1')
.bind(stored.chatThreadId, stored.chatMessageId)
.run()
return stored
}
/**
* Advance a player's read position, to a specific message or (with no id) to the whole
* thread. Only ever moves forward: an out-of-order ack from a second client can't walk
* the thread back to unread.
*
* The id is also clamped to the thread's real latest message, so a client acking an id
* that was never stored can't strand the pointer beyond every future message and leave
* the thread permanently "read".
*/
export async function markThreadRead(
db: D1Database,
chatThreadId: number,
playerId: number,
chatMessageId?: number
): Promise<void> {
await db
.prepare(
`UPDATE thread_member
SET last_read_message_id = MAX(
COALESCE(last_read_message_id, 0),
MIN(
COALESCE(?3, (SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0),
COALESCE((SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0)
)
)
WHERE chat_thread_id = ?1 AND player_id = ?2`
)
.bind(chatThreadId, playerId, chatMessageId ?? null)
.run()
}
/** Favorite or unfavorite a thread, for one player only. */
export async function setThreadFavorited(
db: D1Database,
chatThreadId: number,
playerId: number,
isFavorited: boolean
): Promise<void> {
await db
.prepare(
'UPDATE thread_member SET is_favorited = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
)
.bind(chatThreadId, playerId, isFavorited ? 1 : 0)
.run()
}
/** Snooze a thread's notifications until an instant, or clear the snooze with null. */
export async function setThreadSnoozed(
db: D1Database,
chatThreadId: number,
playerId: number,
snoozedUntil: string | null
): Promise<void> {
await db
.prepare(
'UPDATE thread_member SET snoozed_until = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
)
.bind(chatThreadId, playerId, snoozedUntil)
.run()
}
+34
View File
@@ -9,6 +9,40 @@ export default defineConfig({
bindings: {
ENVIRONMENT: 'VITEST',
},
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
// worker's DO (script_name: "notify"). That worker isn't part of this
// isolated test, so provide a minimal stub exposing the same NotificationsHub
// RPC surface. This one also records what it was sent and hands it back via
// `takeSent`, so tests can assert on the ChatMessageReceived fan-out.
workers: [
{
name: 'notify',
modules: true,
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
constructor(ctx, env) {
super(ctx, env)
this.sent = []
}
async notifyPlayer(playerId, notificationType, data) {
this.sent.push({ playerId, notificationType, data })
return { delivered: 1, queued: false }
}
async broadcast() { return { delivered: 0 } }
async takeSent() {
const sent = this.sent
this.sent = []
return sent
}
}
export default { fetch() { return new Response('ok') } }
`,
},
],
},
}),
],
+35
View File
@@ -4,6 +4,41 @@
"main": "src/chat.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Shared `recflare` DB (created manually with `wrangler d1 create recflare`; the
// "local" placeholder is spliced out at deploy time). The `chat` worker owns the
// `message` table (schema/migration here); its own migrations_table keeps history
// separate from the other workers' migrations on the shared database.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local",
"migrations_dir": "migrations",
"migrations_table": "d1_migrations_chat"
}
],
"logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
}
],
// The `notify` worker's NotificationsHub DO used to push ChatMessageReceived to
// every member of a thread when a message lands.
"durable_objects": {
"bindings": [
{
"name": "RECFLARE_NOTIFICATIONS_HUB",
"class_name": "NotificationsHub",
"script_name": "notify"
}
]
},
"upload_source_maps": true,
"observability": {
"logs": {
+7 -1
View File
@@ -16,10 +16,16 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+1331 -333
View File
@@ -1,15 +1,16 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
clearHomeClub,
ClubJoinability,
ClubMembershipType,
ClubVisibility,
countClubsByCreator,
createClub,
createClubAnnouncement,
deleteClub,
getClub,
getClubAnnouncements,
getClubDetails,
@@ -18,16 +19,75 @@ import {
getClubsByMember,
getHomeClub,
getMembership,
glyphLength,
joinClub,
leaveClub,
MAX_ADDITIONAL_IMAGES,
MAX_CLUB_DESCRIPTION_LENGTH,
MAX_CLUB_NAME_LENGTH,
requestToJoinClub,
searchClubs,
setClubAdditionalImage,
setHomeClub,
setMemberType,
updateClub,
} from './clubs-db'
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AnnouncementIdEnvelope,
AnnouncementRequest,
AUTHED,
CategoryTags,
ChatDisabledResponse,
ClubAnnouncementsEnvelope,
ClubDetailsDto,
ClubDetailsEnvelope,
ClubDto,
ClubEnvelope,
ClubhouseRequest,
ClubMembersEnvelope,
ClubSearchResponse,
CreateClubRequest,
EmptyObject,
ErrorEnvelope,
form,
HomeClubRequest,
ImageNameRequest,
InviteMemberRequest,
json,
JsonArray,
MinLevelRequest,
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscriptionDetailsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
* search, announcements, the club gallery, a club's clubhouse room, and each player's
* home club. Everything is D1-backed (the shared `recflare` database); the
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
*
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/** The `clubId` path parameter, shared by every per-club route. */
const CLUB_ID_PARAM = {
name: 'clubId',
in: 'path',
required: true,
description: 'The clubs id (digits only — a non-numeric id doesnt match the route)',
schema: { type: 'string' },
} as const
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer.
@@ -36,11 +96,28 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Longest a club name may be (the reference's MaxNameLength). */
const MAX_CLUB_NAME_LENGTH = 16
/**
* How many clubs one account may create, when the `MAX_CLUBS_PER_ACCOUNT` var is
* unset. Counts the clubs the account created (subscription clubs excluded those
* aren't made by hand). Setting the var to 0 lifts the cap entirely. Existing clubs
* are never touched: lowering the cap just stops new ones.
*/
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
/**
* The tiers `members/invite` may grant the real member roles only. Creator (100) is
* excluded so an invite can't mint a second owner, and the pending/none/banned states
* aren't something you "invite" someone to.
*/
const INVITABLE_TIERS: ReadonlySet<number> = new Set([
ClubMembershipType.Member,
ClubMembershipType.Moderator,
ClubMembershipType.Coowner,
])
/** The punctuation a club name may use, on top of letters and digits. */
const ALLOWED_NAME_PUNCTUATION = new Set([...` .,'!?-_&()#@:+`])
const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
/**
* Club names are letters (any Latin script), digits, and basic punctuation the
@@ -128,439 +205,1360 @@ const app = new Hono<App>()
// account. Auth-gated. 404 when they have no home club, the club is gone, or it has
// no clubhouse room: the client expects a 404 for "no home club" and errors on an
// empty object. Returns the bare club (not the envelope), as the reference does.
.get('/club/home/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getHomeClub(c.env.DB, id)
return club === null ? c.notFound() : c.json(club)
})
.get(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'The players home club',
description: [
'The club whose clubhouse the player spawns into (a field on their account row).',
'404 when they have no home club, the club is gone, or it has no clubhouse room —',
'the client expects a 404 for “no home club” and errors on an empty object. Returns',
'the bare club, not the envelope, as the reference does.',
].join(' '),
security: AUTHED,
responses: {
200: json(ClubDto, 'The players home club'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No home club, or it has no clubhouse room' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getHomeClub(c.env.DB, id)
return club === null ? c.notFound() : c.json(club)
}
)
// Set the player's home club (`clubId` form field). They must be a member of it —
// you can't make a club you don't belong to your home. Answers the envelope.
.put('/club/home/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.put(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Set the players home club',
description: [
'Points the players home club at the posted `clubId`. They must already be a member',
'of it — you cant make a club you dont belong to your home. Answers the envelope',
'carrying the bare club.',
].join(' '),
security: AUTHED,
requestBody: form(HomeClubRequest, 'The club to make home'),
responses: {
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller isnt a member of that club'),
404: { description: 'No such club' },
},
}),
async (c) => {
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 key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
const clubId = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Member) {
return c.json(
{ error: 'You are not a member of that club.', success: false, value: null },
403
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
const clubId = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
}
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
await setHomeClub(c.env.DB, id, clubId)
return c.json({ error: '', success: true, value: club })
})
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Member) {
return c.json(
{ error: 'You are not a member of that club.', success: false, value: null },
403
)
}
await setHomeClub(c.env.DB, id, clubId)
return c.json({ error: '', success: true, value: club })
}
)
// Clear the player's home club — they spawn into the default hub again instead of a
// clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a
// 404), and it doesn't touch their membership of the club. The envelope's value is
// null because there's no home club left to describe; GET goes back to 404ing.
.delete(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Clear the players home club',
description: [
'The player spawns into the default hub again instead of a clubhouse. No body,',
'idempotent (clearing when none is set is a no-op, not a 404), and it doesnt touch',
'their membership of the club. The envelopes `value` is null because theres no home',
'club left to describe; GET goes back to 404ing.',
].join(' '),
security: AUTHED,
responses: {
200: json(NullEnvelope, 'Cleared (value null)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
await clearHomeClub(c.env.DB, id)
return c.json({ error: '', success: true, value: null })
}
)
// A real Rec Room client endpoint with no backing implementation yet. The
// client calls it on the clubs host at /subscription/mine/member (no /club
// prefix) and sends no auth header, so it isn't gated. Returns an empty
// array = no club subscription memberships (the client chokes on null).
.get('/subscription/mine/member', (c) => c.json([]))
.get(
'/subscription/mine/member',
describeRoute({
tags: ['Subscriptions'],
summary: 'The callers club-subscription memberships',
description: [
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isnt gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// Subscription details for an account (numeric id) — simulated: no club, no subs.
.get('/subscription/details/:accountId{[0-9]+}', (c) =>
c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0,
subscriberCount: 0,
})
.get(
'/subscription/details/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscription details for an account',
description: 'Simulated — no subscription club, no subscribers.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
}),
(c) =>
c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0,
subscriberCount: 0,
})
)
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
// into an object, so it must return `{}` (not `[]`).
.get('/subscription/details/:subscription', (c) => c.json({}))
.get(
'/subscription/details/:subscription',
describeRoute({
tags: ['Subscriptions'],
summary: 'Details for a named subscription',
description: [
'A named subscription (e.g. `rrplus`). The client deserializes this into an object,',
'so it must return `{}` — not `[]`.',
].join(' '),
parameters: [
{
name: 'subscription',
in: 'path',
required: true,
description: 'The subscription name, e.g. `rrplus`',
schema: { type: 'string' },
},
],
responses: { 200: json(EmptyObject, 'Always an empty object') },
}),
(c) => c.json({})
)
// Subscriber count for an account. No club subscriptions yet → 0.
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
.get(
'/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscriber count for an account',
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
}),
(c) => c.json(0)
)
// The player's clubs that have unread announcements (MyClubsWithUnread-
// Announcements). Nothing tracks what a player has read yet → nothing is unread.
.get('/announcements/v2/mine/unread', (c) => c.json([]))
.get(
'/announcements/v2/mine/unread',
describeRoute({
tags: ['Announcements'],
summary: 'The players clubs with unread announcements',
description: [
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
'nothing is unread → always `[]`.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// A club's announcements — its noticeboard, newest first. Public. Answers the
// envelope, with `LastAnnouncementId` the newest one (null when there are none)
// and `LastReadAnnouncementId` 0: nothing tracks read state yet.
.get('/announcements/club/:clubId{[0-9]+}', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcements = await getClubAnnouncements(c.env.DB, clubId)
return c.json({
error: '',
success: true,
value: {
Announcements: announcements,
ClubId: clubId,
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
LastReadAnnouncementId: 0,
},
})
})
.get(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'A clubs announcements',
description: [
'The clubs noticeboard, newest first. Public. Answers the envelope, with',
'`LastAnnouncementId` the newest one (null when there are none) and',
'`LastReadAnnouncementId` 0 — nothing tracks read state yet. An unknown club simply',
'has no announcements.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ClubAnnouncementsEnvelope, 'The clubs noticeboard') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcements = await getClubAnnouncements(c.env.DB, clubId)
return c.json({
error: '',
success: true,
value: {
Announcements: announcements,
ClubId: clubId,
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
LastReadAnnouncementId: 0,
},
})
}
)
// Post an announcement to a club. Co-owner or above only. The envelope's value is
// the new announcement's id.
.post('/announcements/club/:clubId{[0-9]+}', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'Post an announcement to a club',
description: 'Co-owner or above only. The envelopes `value` is the new announcements id.',
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(AnnouncementRequest, 'The announcement fields'),
responses: {
200: json(AnnouncementIdEnvelope, 'The new announcements id'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
title: field('title'),
body: field('body'),
imageName: field('imageName'),
meta: field('meta'),
})
return c.json({ error: '', success: true, value: announcementId })
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
title: field('title'),
body: field('body'),
imageName: field('imageName'),
meta: field('meta'),
})
return c.json({ error: '', success: true, value: announcementId })
})
)
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
// memberships from `club_member`. A caller with no valid token has no clubs, so
// this answers an empty list rather than 401ing — the client shows the "my clubs"
// shelf either way, and an error there breaks the screen.
.get('/club/mine/member', async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByMember(c.env.DB, id))
})
.get(
'/club/mine/member',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player is a member of',
description: [
'GetMyMembershipClubs — the callers memberships from `club_member`, oldest club',
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
'shelf either way, and an error there breaks the screen.',
].join(' '),
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The callers clubs (empty when signed out)') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByMember(c.env.DB, id))
}
)
// The clubs the player created (GetMyCreatedClubs). Empty list when signed out,
// like mine/member.
.get('/club/mine/created', async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByCreator(c.env.DB, id))
})
.get(
'/club/mine/created',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player created',
description: 'GetMyCreatedClubs, oldest first. Empty list when signed out, like mine/member.',
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The clubs the caller created') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByCreator(c.env.DB, id))
}
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page
// (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`.
.get('/club/search', async (c) => {
const count = Number.parseInt(c.req.query('count') ?? '', 10)
return c.json(
await searchClubs(
c.env.DB,
c.req.query('category') ?? '',
c.req.query('query') ?? '',
c.req.query('sort'),
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
.get(
'/club/search',
describeRoute({
tags: ['Clubs'],
summary: 'Club search / browse',
description: [
'Public, non-subscription clubs. Public (no auth). `TotalClubs` is the full match',
'count, not the page size.',
].join(' '),
parameters: [
{
name: 'category',
in: 'query',
required: false,
description: 'Filter to one category (exact, case-insensitive)',
schema: { type: 'string' },
},
{
name: 'query',
in: 'query',
required: false,
description: 'Substring of the club name or description',
schema: { type: 'string' },
},
{
name: 'sort',
in: 'query',
required: false,
description: '1 = newest first, 2 = by name, anything else = most members first',
schema: { type: 'string' },
},
{
name: 'count',
in: 'query',
required: false,
description: 'Page size; out of range (or absent) falls back to 30',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubSearchResponse, 'The matching page of clubs') },
}),
async (c) => {
const count = Number.parseInt(c.req.query('count') ?? '', 10)
return c.json(
await searchClubs(
c.env.DB,
c.req.query('category') ?? '',
c.req.query('query') ?? '',
c.req.query('sort'),
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
)
)
)
})
}
)
// The set of club category tags a club can be filed under — a fixed list.
.get('/club/categoryTags', (c) =>
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
.get(
'/club/categoryTags',
describeRoute({
tags: ['Clubs'],
summary: 'Club category tags',
description: 'The fixed set of categories a club can be filed under.',
responses: { 200: json(CategoryTags, 'The category list') },
}),
(c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
)
// Create a club. The client posts a form to `/club/create` with lowercase fields
// (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success,
// value }` envelope carrying the new club's details — not a bare club.
.post('/club/create', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/club/create',
describeRoute({
tags: ['Clubs'],
summary: 'Create a club',
description: [
'The client posts a form with lowercase fields (`name`, `description`, `category`);',
'either casing is accepted. Enums arrive by name (`visibility=Public`,',
'`joinability=Open`). `ClubType` is never taken from the client — a player-created',
'club is always a regular one, since letting the client pick would let it mint a',
'subscription club (type 1), which is excluded from every listing. The caller becomes',
'the clubs Creator. Answers the `{ error, success, value }` envelope carrying the new',
'clubs full details — not a bare club.',
].join(' '),
security: AUTHED,
requestBody: form(CreateClubRequest, 'The new clubs fields'),
responses: {
200: json(ClubDetailsEnvelope, 'The new clubs details'),
400: json(
ErrorEnvelope,
'Missing/invalid/too-long name, or the per-account club limit is reached'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
// The client sends lowercase field names; accept either casing.
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
// The client sends lowercase field names; accept either casing.
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
const name = field('name')?.trim() ?? ''
const description = field('description') ?? ''
if (name === '') return clubError(c, 'You must enter a name for your club.')
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
const club = await createClub(c.env.DB, id, {
name,
description,
// An unset category files the club under Social, as the reference does.
category: field('category')?.trim() || 'Social',
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName'),
// ClubType is deliberately not taken from the client: a player-created club
// is always a regular one. Letting the client pick would let it mint a
// subscription club (type 1), which is excluded from every club listing.
minLevel: int(field('minLevel')),
})
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
})
// Edit a club's details. The client PUTs a form of the fields it's changing —
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
// and absent fields keep their stored value. `customTags` may repeat; when present
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
// same `{ error, success, value }` envelope create does.
.put('/club/:clubId{[0-9]+}/modifydetails', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
// Editing details is a co-owner power — plain members and moderators can't.
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
// `all: true` so a repeated `customTags` field arrives as a list.
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const list = (name: string): string[] | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
if (key === undefined) return undefined
const v = body[key]
const values = Array.isArray(v) ? v : [v]
return values.filter((t): t is string => typeof t === 'string')
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
// An empty name/description means "unchanged", not "clear it" — the reference
// only applies these when non-empty.
const name = field('name')?.trim() || undefined
if (name !== undefined) {
const name = field('name')?.trim() ?? ''
const description = field('description') ?? ''
if (name === '') return clubError(c, 'You must enter a name for your club.')
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
// Counted in code points like the name above, so an emoji-heavy description is
// measured the way a player sees it rather than by UTF-16 units.
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
// The per-account cap, checked after the cheap validations so a rejected name
// costs no extra D1 read.
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) {
logger.info('club create rejected: per-account club limit', { accountId: id })
return clubError(c, `You can only have ${maxClubs} clubs.`)
}
const club = await createClub(c.env.DB, id, {
name,
description,
// An unset category files the club under Social, as the reference does.
category: field('category')?.trim() || 'Social',
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName'),
// ClubType is deliberately not taken from the client: a player-created club
// is always a regular one. Letting the client pick would let it mint a
// subscription club (type 1), which is excluded from every club listing.
minLevel: int(field('minLevel')),
})
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
}
)
const updated = await updateClub(c.env.DB, clubId, {
name,
description: field('description') || undefined,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName') || undefined,
minLevel: int(field('minLevel')),
customTags: list('customTags'),
})
if (updated === null) return c.notFound()
// Edit a club's details. The client PUTs a form of the fields it's changing —
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
// and absent fields keep their stored value. `customTags` may repeat; when present
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
// same `{ error, success, value }` envelope create does.
//
// `/modify` is the same endpoint under the shorter name the client also PUTs to
// (`name=…&description=…&category=…`); one handler, so the two can't drift.
.on(
'PUT',
['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'],
describeRoute({
tags: ['Clubs'],
summary: 'Edit a clubs details',
description: [
'The client PUTs a form of just the fields its changing — enums by name',
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
'“clear it”). `customTags` may repeat; when present it replaces the clubs tag set',
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
'name the client also PUTs to — one handler, so the two cant drift. Answers the same',
'details envelope create does, since the client re-renders the club screen from it.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ModifyClubRequest, 'The fields to change'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Invalid or too-long name'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
// Editing details is a co-owner power — plain members and moderators can't.
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
// `all: true` so a repeated `customTags` field arrives as a list.
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<
string,
unknown
>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const list = (name: string): string[] | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
if (key === undefined) return undefined
const v = body[key]
const values = Array.isArray(v) ? v : [v]
return values.filter((t): t is string => typeof t === 'string')
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
// An empty name/description means "unchanged", not "clear it" — the reference
// only applies these when non-empty.
const name = field('name')?.trim() || undefined
if (name !== undefined) {
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
}
// Same absent-means-unchanged rule as the name, so a club with no description
// isn't forced to grow one just to be edited.
const description = field('description') || undefined
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
const updated = await updateClub(c.env.DB, clubId, {
name,
description,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName') || undefined,
minLevel: int(field('minLevel')),
customTags: list('customTags'),
})
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// A club's full details — the club plus its tags, the per-tier permissions, and the
// caller's own membership. Public (a signed-out viewer just gets MyMembershipType
// 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes
// the details object straight out.
.get('/club/:clubId{[0-9]+}/details', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json(await getClubDetails(c.env.DB, club, id))
})
.get(
'/club/:clubId{[0-9]+}/details',
describeRoute({
tags: ['Clubs'],
summary: 'A clubs full details',
description: [
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
'callers own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
'straight out, as the reference does.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsDto, 'The clubs details (not enveloped)'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json(await getClubDetails(c.env.DB, club, id))
}
)
// Whether a club has turned its club chat off. Nothing can disable club chat yet
// (no setting, no storage), so chat is always on → `false`. A bare JSON boolean,
// like the other `is…`/`has…` gates the client polls; not in the reference, so if
// the client chokes on this it likely wants the `{ error, success, value }`
// envelope the other club endpoints use.
.get('/club/:clubId{[0-9]+}/hasDisabledClubChat', (c) => c.json(false))
.get(
'/club/:clubId{[0-9]+}/hasDisabledClubChat',
describeRoute({
tags: ['Clubs'],
summary: 'Whether the club has turned club chat off',
description: [
'Nothing can disable club chat yet (no setting, no storage), so chat is always on →',
'`false`. A bare JSON boolean, like the other `is…`/`has…` gates the client polls; not',
'in the reference, so if the client chokes on this it likely wants the',
'`{ error, success, value }` envelope the other club endpoints use.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ChatDisabledResponse, 'Always false') },
}),
(c) => c.json(false)
)
// A club's members. `membershipType` filters to exactly that tier (an exact match,
// not a threshold — `30` lists co-owners only, not the creator above them), and
// `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest
// tier first). Public, and an unknown club is an empty list. Answers the envelope.
.get('/club/:clubId{[0-9]+}/members', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const raw = c.req.query('membershipType')
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
.get(
'/club/:clubId{[0-9]+}/members',
describeRoute({
tags: ['Membership'],
summary: 'A clubs members',
description:
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
parameters: [
CLUB_ID_PARAM,
{
name: 'membershipType',
in: 'query',
required: false,
description: [
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
'co-owners only, not the creator above them',
].join(' '),
schema: { type: 'string' },
},
{
name: 'sortBy',
in: 'query',
required: false,
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubMembersEnvelope, 'The clubs membership rows') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const raw = c.req.query('membershipType')
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
const members = await getClubMembers(
c.env.DB,
clubId,
Number.isNaN(membershipType) ? undefined : membershipType,
c.req.query('sortBy')
)
return c.json({ error: '', success: true, value: members })
})
const members = await getClubMembers(
c.env.DB,
clubId,
Number.isNaN(membershipType) ? undefined : membershipType,
c.req.query('sortBy')
)
return c.json({ error: '', success: true, value: members })
}
)
// Set the minimum player level required to join the club. The reference has no such
// route (it only takes `minLevel` on modifydetails), but the client PUTs it here.
// Same rules as the other club edits: co-owner or above, and the details envelope.
.put('/club/:clubId{[0-9]+}/minlevel', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.put(
'/club/:clubId{[0-9]+}/minlevel',
describeRoute({
tags: ['Clubs'],
summary: 'Set the clubs minimum join level',
description: [
'The reference has no such route (it only takes `minLevel` on modifydetails), but the',
'client PUTs it here. Same rules as the other club edits: co-owner or above, and the',
'details envelope back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(MinLevelRequest, 'The new minimum level'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
const minLevel = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
const updated = await updateClub(c.env.DB, clubId, { minLevel })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
const minLevel = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
const updated = await updateClub(c.env.DB, clubId, { minLevel })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
)
// Set (or clear) the club's clubhouse room — the room players spawn into when the
// club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner
// or above only. Answers the envelope with a null value, as the reference does.
.put('/club/:clubId{[0-9]+}/clubhouse', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// or above only. Answers the details envelope (the reference returns a null value
// here, but the client re-renders from the response and leaves the old clubhouse
// on screen unless it gets the updated club back).
//
// DELETE is the same thing with the clearing spelled out — it ignores any body and
// always unsets the room, so "remove the clubhouse" doesn't depend on the client
// remembering to send an empty PUT.
.on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/clubhouse',
describeRoute({
tags: ['Clubs'],
summary: 'Set or clear the clubs clubhouse room',
description: [
'The clubhouse is the room players spawn into when the club is their home. PUT with',
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
'clubhouse” doesnt depend on the client remembering to send an empty PUT. Co-owner or',
'above only. Answers the full details envelope: the reference returns a null value',
'here, but the client re-renders from the response and leaves the old clubhouse on',
'screen unless it gets the updated club back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Non-numeric roomId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
let roomId: number | null = null
if (c.req.method !== 'DELETE') {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
return clubError(c, 'Invalid roomId.')
}
roomId = raw === '' ? null : Number.parseInt(raw, 10)
}
const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
return clubError(c, 'Invalid roomId.')
}
await updateClub(c.env.DB, clubId, {
clubhouseRoomId: raw === '' ? null : Number.parseInt(raw, 10),
})
return c.json({ error: '', success: true, value: null })
})
)
// The club's main image. PUT sets it from an uploaded image's `imageName` (the
// name the `storage` worker handed back); co-owner or above only. GET reads it —
// the reference has no GET here (it 404s), but the client asks for it, so this
// answers the same details envelope rather than erroring; the image name is on
// `value.Club.MainImageName`.
.get('/club/:clubId{[0-9]+}/mainimage', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
})
.put('/club/:clubId{[0-9]+}/mainimage', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
.get(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Read the clubs main image',
description: [
'The reference has no GET here (it 404s), but the client asks for it, so this answers',
'the same details envelope rather than erroring; the image name is on',
'`value.Club.MainImageName`. Public.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, carrying MainImageName'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
}
)
.put(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Set the clubs main image',
description: [
'Sets the main image from an uploaded images `imageName` (the name the `storage`',
'worker handed back). Co-owner or above only. Answers the details envelope.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ImageNameRequest, 'The uploaded images name'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing imageName'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
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 key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
if (imageName === '') return clubError(c, 'imageName is required.')
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
const imageName =
typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
if (imageName === '') return clubError(c, 'imageName is required.')
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based
// — the client PUTs the first image to 0, the second to 1). Takes the same
// `imageName` the `storage` worker handed back. Co-owner or above, like the main
// image. The list is packed: a PUT past the end appends rather than leaving a gap,
// and the images come back in order on `value.AdditionalImages`.
//
// DELETE removes that position's image and shifts the rest up, so there's never a
// blank slot in the gallery. It ignores any body, so it can't accidentally set an
// image instead, and deleting a position that holds nothing is a no-op.
.on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'Set or remove one of the clubs gallery images',
description: [
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
'rest up, so theres never a blank slot. DELETE ignores any body (so it cant',
'accidentally set an image instead) and deleting an empty position is a no-op. The',
'images come back on `value.AdditionalImages` as whole image records, in order — a',
'bare array of names fails the clients parser.',
].join(' '),
security: AUTHED,
parameters: [
CLUB_ID_PARAM,
{
name: 'index',
in: 'path',
required: true,
description: 'The 0-based gallery slot; a club has 3 slots (02)',
schema: { type: 'string' },
},
],
requestBody: form(ImageNameRequest, 'The uploaded images name (PUT only)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'The index is past the clubs gallery slots'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const index = Number.parseInt(c.req.param('index'), 10)
if (index >= MAX_ADDITIONAL_IMAGES) {
return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`)
}
let imageName = ''
if (c.req.method !== 'DELETE') {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
}
const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName)
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// A single club by id. 404 when the club isn't in the DB. Public.
.get('/club/:clubId{[0-9]+}', async (c) => {
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
return club ? c.json(club) : c.notFound()
})
.get(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'A single club by id',
description: 'The bare club (not the details view, not enveloped). Public.',
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
return club ? c.json(club) : c.notFound()
}
)
// Delete a club, along with its memberships and announcements. The creator only —
// not co-owners, who can edit a club but can't destroy one — which is also the way
// out for a creator, since they aren't allowed to leave (see /members/leave).
// Answers the envelope with a null value; the club is gone, so there are no details
// left to return.
.delete(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'Delete a club',
description: [
'Deletes the club along with its memberships and announcements, and clears it from the',
'home club of anyone whod set it. The creator only — not co-owners, who can edit a',
'club but cant destroy one — which is also the way out for a creator, since they',
'arent allowed to leave. The envelopes `value` is null: the club is gone, so there',
'are no details left to return.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(NullEnvelope, 'Deleted (value null)'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Not the clubs creator'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Creator) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
await deleteClub(c.env.DB, clubId)
return c.json({ error: '', success: true, value: null })
}
)
// Ask to join a club. No body — the club id and the Bearer token are the whole
// request. What it does depends on the club's Joinability: an Open club takes the
// caller straight in as a Member, an AskToJoin club records a PendingRequested row
// for a co-owner to approve, and an InviteOnly club refuses (you can only get in
// through an invite). Repeats are idempotent; a banned account stays out. Answers
// the details envelope so the client can read its new `MyMembershipType`.
.put(
'/club/:clubId{[0-9]+}/members/requesttojoin',
describeRoute({
tags: ['Membership'],
summary: 'Ask to join a club',
description: [
'No body — the club id and the Bearer token are the whole request. What it does',
'depends on the clubs Joinability: an Open club takes the caller straight in as a',
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
'idempotent; a banned account stays out. Answers the details envelope so the client',
'can read its new `MyMembershipType`.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers new membership'),
400: json(ErrorEnvelope, 'The club is invite only'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller is banned from the club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const outcome = await requestToJoinClub(c.env.DB, clubId, id)
if (outcome === null) return c.notFound()
if (outcome.result === 'inviteOnly') {
return clubError(c, 'This club is invite only.')
}
if (outcome.result === 'banned') {
return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403)
}
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, outcome.club, id),
})
}
)
// Invite an account into the club at a given tier — the co-owner's "add member" /
// role-assignment write. `accountId` is who to add and `membershipType` the tier they
// get (10 Member, 20 Moderator, 30 Co-owner); the client sends both as form fields.
// Co-owner or above only. The membership is upserted, so this also promotes/demotes an
// existing member and overrides a ban — but it can't mint another Creator (100) and it
// can't touch the club's own Creator. Answers the details envelope, like the other
// membership writes.
.put(
'/club/:clubId{[0-9]+}/members/invite',
describeRoute({
tags: ['Membership'],
summary: 'Invite an account into the club',
description: [
'Adds `accountId` to the club at `membershipType` (10 Member, 20 Moderator, 30',
'Co-owner) — the co-owners “add member” / role-assignment write; both arrive as form',
'fields, and an absent `membershipType` defaults to Member. Co-owner or above only. The',
'membership is upserted, so this also promotes/demotes an existing member and overrides',
'a ban; it cant mint another Creator (100) or change the clubs own Creator. Answers the',
'details envelope, like the other membership writes.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(InviteMemberRequest, 'The account to add and the tier to grant'),
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details after the invite'),
400: json(
ErrorEnvelope,
'Missing/invalid accountId, a tier outside Member/Moderator/Co-owner, or targeting the creator'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const accountId = Number.parseInt(field('accountId') ?? '', 10)
if (Number.isNaN(accountId) || accountId <= 0) return clubError(c, 'Invalid accountId.')
// An absent tier means "add as a plain Member"; anything present must be one of the
// grantable roles (in particular not Creator), so an invite can't mint a second owner.
const rawType = field('membershipType')
const membershipType =
rawType === undefined || rawType.trim() === ''
? ClubMembershipType.Member
: Number.parseInt(rawType, 10)
if (!INVITABLE_TIERS.has(membershipType)) return clubError(c, 'Invalid membershipType.')
// The Creator is fixed — you can't demote them or promote someone over them.
if (accountId === club.CreatorAccountId) {
return clubError(c, 'You cant change the clubs creator.')
}
const updated = await setMemberType(c.env.DB, clubId, accountId, membershipType)
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// Leave a club. No body, like requesttojoin — the club id and the Bearer token are
// the whole request. Idempotent (leaving a club you're not in is a no-op), and it
// also withdraws a pending request; a ban is preserved, since you can't clear one
// by leaving. The creator is refused — they'd leave the club ownerless, so they
// have to delete it instead. Answers the details envelope so the client sees
// `MyMembershipType` drop to 0 (or stay at -1 for a banned account).
.post(
'/club/:clubId{[0-9]+}/members/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club',
description: [
'No body, like requesttojoin. Idempotent (leaving a club youre not in is a no-op),',
'and it also withdraws a pending request; a ban is preserved, since you cant clear',
'one by leaving. The creator is refused — theyd leave the club ownerless, so they',
'have to delete it instead. Answers the details envelope so the client sees',
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers membership gone'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const outcome = await leaveClub(c.env.DB, clubId, id)
if (outcome === null) return c.notFound()
if (outcome.result === 'creator') {
return c.json(
{
error: 'You created this club — delete it instead of leaving.',
success: false,
value: null,
},
403
)
}
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, outcome.club, id),
})
}
)
// Join / leave a club (auth-gated, idempotent). Both return the club with its
// refreshed MemberCount; 404 when the club doesn't exist.
.post('/club/:clubId{[0-9]+}/join', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
})
.post('/club/:clubId{[0-9]+}/leave', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
})
.post(
'/club/:clubId{[0-9]+}/join',
describeRoute({
tags: ['Membership'],
summary: 'Join a club',
description: [
'Auth-gated and idempotent. On an Open club the caller becomes a Member immediately;',
'on an InviteOnly/AskToJoin club the join is recorded as PendingRequested, and a ban',
'cant be shed by re-joining. Returns the bare club with its refreshed MemberCount',
'(not the details envelope — see members/requesttojoin for that).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
}
)
// Leaving is refused for the creator here too (see /members/leave), so the two
// routes can't disagree about who's still in the club.
.post(
'/club/:clubId{[0-9]+}/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club (bare-club form)',
description: [
'The counterpart to `/join`: returns the bare club with its refreshed MemberCount',
'rather than the details envelope. Leaving is refused for the creator here too (see',
'`/members/leave`), so the two routes cant disagree about whos still in the club.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
if (outcome === null) return c.notFound()
if (outcome.result === 'creator') {
return c.json(
{
error: 'You created this club — delete it instead of leaving.',
success: false,
value: null,
},
403
)
}
return c.json(outcome.club)
}
)
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare clubs',
version: '1.0.0',
description: [
'Club endpoints for recflare, a private-server reimplementation of the Rec Room',
'backend. The client calls these on the `clubs` host: club creation and editing,',
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
'search, announcements, the club gallery and clubhouse room, and each players home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the clubs FULL details as',
'`value` rather than null.',
].join('\n'),
},
servers: [{ url: 'https://clubs.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+6
View File
@@ -8,6 +8,12 @@ export type Env = SharedHonoEnv & {
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
DB: D1Database
// How many clubs one account may create (optional). Unset falls back to
// DEFAULT_MAX_CLUBS_PER_ACCOUNT in clubs.app.ts; 0 lifts the cap. Typed
// `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
// number while the same var set from the dashboard or `--var` arrives as a string —
// read it through `intVar`, never as a bare number.
MAX_CLUBS_PER_ACCOUNT?: string | number
// add additional Bindings here
}
+350
View File
@@ -0,0 +1,350 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the clubs worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
export const JsonArray = z.array(z.unknown())
/** An empty JSON object — the stub shape the client deserializes into an object. */
export const EmptyObject = z.object({})
// ---- Core entities ---------------------------------------------------------
/**
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
* object the tags and gallery are served on the details view instead.
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
AllowJuniors: z.boolean(),
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
CreatorAccountId: z.int(),
IsRRO: z.boolean(),
MinLevel: z.int(),
State: z.int(),
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
})
/**
* An image record as every image on the site is served (`SavedImage`). A club's gallery
* serves these whole see AdditionalImages on the details view.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* What a membership tier may do in a club. These are the defaults every club gets
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
* so they're derived per club rather than stored.
*/
export const ClubPermissionDto = z.object({
ClubId: z.int(),
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
ApproveMember: z.boolean(),
BanUnban: z.boolean(),
CreateEvent: z.boolean(),
EditDetails: z.boolean(),
EditPermissionSettings: z.boolean(),
PostAnnouncement: z.boolean(),
})
/**
* The club-details payload the client renders a club screen from: the club, its tags,
* the per-tier permissions, its gallery, and the caller's own membership.
*/
export const ClubDetailsDto = z.object({
AdditionalImages: z
.array(SavedImageDto)
.describe(
[
'The clubs gallery as WHOLE image records, not image names — the client',
'deserializes each entry into an object, so a bare array of names fails its parser',
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
'rest up, never leaving a blank slot.',
].join(' ')
),
Club: ClubDto,
ClubId: z.int(),
CoownerPermissions: ClubPermissionDto,
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails repeated customTags'),
MemberPermissions: ClubPermissionDto,
ModeratorPermissions: ClubPermissionDto,
MyMembershipType: z
.int()
.describe(
[
'The callers own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
'100 creator',
].join(' ')
),
})
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export const ClubMemberDto = z.object({
ClubMemberId: z.int(),
ClubId: z.int(),
AccountId: z.int(),
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
})
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
export const ClubAnnouncementDto = z.object({
AnnouncementId: z.int(),
ClubId: z.int(),
AccountId: z.int().describe('Who posted it'),
Title: z.string(),
Body: z.string(),
ImageName: z.string(),
Meta: z.string(),
CreatedAt: z.string().nullable(),
})
// ---- Envelopes -------------------------------------------------------------
//
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
// entity the client re-renders from, so routes that change a club return the FULL
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
// clubhouse on screen until it answered the details envelope.
/** The success envelope carrying a club's full details. */
export const ClubDetailsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDetailsDto,
})
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
export const ClubEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDto,
})
/**
* The envelope with nothing left to describe clearing the home club, deleting a club.
* Only used where the entity is genuinely gone; anything the client re-renders from
* returns the details envelope instead.
*/
export const NullEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** A rejected action: the same envelope, carrying the message the client shows. */
export const ErrorEnvelope = z.object({
error: z.string().describe('The message shown to the player'),
success: z.boolean().describe('Always false'),
value: z.null(),
})
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
export const ClubMembersEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.array(ClubMemberDto),
})
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
export const ClubAnnouncementsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.object({
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
ClubId: z.int(),
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
}),
})
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
export const AnnouncementIdEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.int().describe('The new announcements id'),
})
// ---- Other response shapes -------------------------------------------------
/** `GET /club/search` — a page of clubs plus the full match count. */
export const ClubSearchResponse = z.object({
Clubs: z.array(ClubDto),
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
subscriberCount: z.int().describe('Always 0'),
})
/** The set of category tags a club can be filed under — a fixed list. */
export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe('Always 0 — there are no club subscriptions yet')
/**
* `GET /club/:clubId/hasDisabledClubChat` a bare JSON boolean, like the other
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
* always false; not in the reference, so if the client chokes on this it likely wants
* the `{ error, success, value }` envelope the other club endpoints use.
*/
export const ChatDisabledResponse = z.boolean()
// ---- Request schemas -------------------------------------------------------
//
// Every write takes a form body (urlencoded or multipart — the client posts both) with
// lowercase field names; the handlers match field names case-insensitively.
/** `POST /club/create` form body. */
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
description: z.string().optional().describe('At most 512 characters'),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
})
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z
.string()
.optional()
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
customTags: z
.array(z.string())
.optional()
.describe('May repeat; when present it replaces the clubs tag set wholesale'),
})
/** `PUT /club/home/me` form body. */
export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
})
/** `PUT /club/:clubId/clubhouse` form body. */
export const ClubhouseRequest = z.object({
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
})
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
export const ImageNameRequest = z.object({
imageName: z.string().describe('The image name the `storage` worker handed back'),
})
/** `PUT /club/:clubId/members/invite` form body. */
export const InviteMemberRequest = z.object({
accountId: z.string().describe('The account to add to the club; a positive integer'),
membershipType: z
.string()
.optional()
.describe('The tier to grant — 10 Member, 20 Moderator, 30 Co-owner; defaults to Member'),
})
/** `POST /announcements/club/:clubId` form body. */
export const AnnouncementRequest = z.object({
title: z.string().optional(),
body: z.string().optional(),
imageName: z.string().optional(),
meta: z.string().optional(),
})
+583 -9
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../clubs.app'
import { SCHEMA_DDL } from '../../clubs-db'
import { CLUB_SCHEMA_DDL } from '@repo/domain'
import type { Env } from '../../context'
@@ -18,7 +18,7 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// Build the club / club_member tables (mirrors the migration).
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CLUB_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — a player's home club is a field on
// their account row, so /club/home/me reads and writes it here.
@@ -28,6 +28,16 @@ beforeAll(async () => {
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
// Image metadata (owned by the img worker, written by api on upload) — a club's
// gallery serves the whole image record behind each stored image name.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL
)`
).run()
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
await env.DB.batch(
[42, 9100, 9101].map((accountId) =>
@@ -231,9 +241,16 @@ describe('clubs endpoints', () => {
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Names cap at 40 characters.
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
// refused at half the length a player can see (the description has no charset rule
// — only the name does).
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
@@ -325,6 +342,25 @@ describe('clubs endpoints', () => {
body: 'name=Ghost',
})
expect(missing.status).toBe(404)
// /modify is the same endpoint under the client's shorter name.
const short = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=my%20club&description=rock%20out&category=Casual',
})
const renamed = (await (await short('6000')).json()) as Details
expect(renamed.value.Club).toMatchObject({
Name: 'my club',
Description: 'rock out',
Category: 'Casual',
})
// ...and it's gated the same way.
expect((await short('6001')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status
).toBe(401)
})
test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => {
@@ -473,26 +509,255 @@ describe('clubs endpoints', () => {
// Give it a clubhouse → the home club now resolves.
const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
expect(clubhouse.status).toBe(200)
expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null })
// The details envelope carries the new room back — the client re-renders from it.
const setRoom = (await clubhouse.json()) as {
error: string
success: boolean
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(setRoom).toMatchObject({ error: '', success: true })
expect(setRoom.value.Club.ClubhouseRoomId).toBe(77)
const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 })
// Clearing the clubhouse takes the home club away again.
await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')
// Clearing the clubhouse takes the home club away again — and reports the cleared
// room, so the client doesn't keep showing the old one.
const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as {
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(clear.value.Club.ClubhouseRoomId).toBeNull()
const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(cleared.status).toBe(404)
// Only co-owners may set the clubhouse; signed out is a 401 on both.
// DELETE clears it too, ignoring any body it's sent.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100')
const deleted = (await (
await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100')
).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } }
expect(deleted.success).toBe(true)
expect(deleted.value.Club.ClubhouseRoomId).toBeNull()
// DELETE /club/home/me drops the home club without touching the membership, and
// is idempotent when there's none set.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer('9100'),
})
expect(dropped.status).toBe(200)
expect(await dropped.json()).toEqual({ error: '', success: true, value: null })
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') }))
.status
).toBe(404)
// Still a member of the club they'd made their home.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') })
).json()) as Club[]
expect(mine.map((c) => c.ClubId)).toContain(clubId)
// Clearing again, and clearing when nothing was set, both succeed.
for (const sub of ['9100', '9101']) {
const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer(sub),
})
expect(again.status).toBe(200)
}
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status
).toBe(401)
// Only co-owners may set or clear the clubhouse; signed out is a 401 on both.
expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403)
expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' }))
.status
).toBe(401)
const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' })
expect(anon.status).toBe(401)
})
test('PUT /club/:id/additionalimage/:index fills the clubs gallery slots', async () => {
type Image = { Id: number; ImageName: string; PlayerId: number }
type Details = { error: string; success: boolean; value: { AdditionalImages: Image[] } }
// The gallery is served as whole image records, joined from the image table the
// `api` worker writes on upload. Seed the rows those names point at.
const first = 'sharecamera/2026-07-21/e37fc41f-005e-4216-8f1e-a37dca953981.jpg'
const insertImage = env.DB.prepare('INSERT OR IGNORE INTO image (data) VALUES (?1)')
await env.DB.batch(
[first, 'b.jpg', 'c.jpg'].map((ImageName, i) =>
insertImage.bind(
JSON.stringify({
Id: 500 + i,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 7100,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: '2026-07-21T00:00:00Z',
CheerCount: 0,
CommentCount: 0,
})
)
)
)
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7100')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Gallery',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const setImage = async (index: number, imageName: string, sub = '7100') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ imageName }).toString(),
})
const names = (images: Image[]) => images.map((i) => i.ImageName)
// A fresh club has no gallery images.
const fresh = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(fresh.AdditionalImages).toEqual([])
// The client's exact request: the storage worker's image name into slot 0. It
// comes back as the whole image record, not a bare name.
const set = (await (await setImage(0, first)).json()) as Details
expect(set).toMatchObject({ error: '', success: true })
expect(set.value.AdditionalImages).toEqual([
expect.objectContaining({ Id: 500, ImageName: first, PlayerId: 7100 }),
])
// The list stays packed: a PUT past the end appends rather than leaving a gap.
const third = (await (await setImage(2, 'c.jpg')).json()) as Details
expect(names(third.value.AdditionalImages)).toEqual([first, 'c.jpg'])
const second = (await (await setImage(2, 'b.jpg')).json()) as Details
expect(names(second.value.AdditionalImages)).toEqual([first, 'c.jpg', 'b.jpg'])
// Re-PUTting a position replaces just that image. A name with no image row still
// renders, as a placeholder record.
const replaced = (await (await setImage(0, 'a2.jpg')).json()) as Details
expect(names(replaced.value.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg', 'b.jpg'])
expect(replaced.value.AdditionalImages[0]).toMatchObject({ Id: 0, ImageName: 'a2.jpg' })
// An empty name removes that image and shifts the rest up — no blank left behind.
const cleared = (await (await setImage(1, '')).json()) as Details
expect(names(cleared.value.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
// They're on the club's details payload, for everyone reading the club.
const details = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(names(details.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
// DELETE removes that position's image and shifts the rest up, ignoring any body.
// Deleting a position that holds nothing is a no-op.
const deleteImage = async (index: number, sub = '7100', body?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
method: 'DELETE',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const dropped = (await (await deleteImage(0, '7100', 'imageName=sneaky.jpg')).json()) as Details
expect(dropped).toMatchObject({ error: '', success: true })
expect(names(dropped.value.AdditionalImages)).toEqual(['b.jpg'])
// Position 1 holds nothing now, so deleting it changes nothing.
expect(
names(((await (await deleteImage(1)).json()) as Details).value.AdditionalImages)
).toEqual(['b.jpg'])
// A PUT past the end appends, so the club is back to two images.
const refilled = (await (await setImage(1, 'a3.jpg')).json()) as Details
expect(names(refilled.value.AdditionalImages)).toEqual(['b.jpg', 'a3.jpg'])
// Same gate as the PUT.
expect((await deleteImage(0, '7101')).status).toBe(403)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
method: 'DELETE',
})
).status
).toBe(401)
expect((await deleteImage(3)).status).toBe(400)
// There are only three slots, and only co-owners may set them.
expect((await setImage(3, 'd.jpg')).status).toBe(400)
expect((await setImage(0, 'hijack.jpg', '7101')).status).toBe(403)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
method: 'PUT',
})
).status
).toBe(401)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/99999/additionalimage/0`, {
method: 'PUT',
headers: await bearer('7100'),
})
).status
).toBe(404)
})
test('POST /club/create enforces the per-account club cap', async () => {
const create = async (name: string) =>
exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7200')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name }).toString(),
})
// The cap an operator actually runs is the `MAX_CLUBS_PER_ACCOUNT` var; the
// constant in the worker is only the fallback.
const original = env.MAX_CLUBS_PER_ACCOUNT
try {
env.MAX_CLUBS_PER_ACCOUNT = 2
expect((await create('CapOne')).status).toBe(200)
expect((await create('CapTwo')).status).toBe(200)
const rejected = await create('CapThree')
expect(rejected.status).toBe(400)
const body = (await rejected.json()) as { error: string; success: boolean }
expect(body.success).toBe(false)
expect(body.error).toMatch(/only have 2 clubs/i)
// A subscription club doesn't count against the cap — it isn't made by hand.
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
.bind(
JSON.stringify({
ClubId: 9500,
Name: 'Subs7200',
ClubType: 1,
CreatorAccountId: 7200,
CreatedAt: '2026-07-01T00:00:00Z',
})
)
.run()
env.MAX_CLUBS_PER_ACCOUNT = 3
expect((await create('CapThreeReal')).status).toBe(200)
// 0 lifts the cap entirely.
env.MAX_CLUBS_PER_ACCOUNT = 0
expect((await create('Uncapped')).status).toBe(200)
} finally {
env.MAX_CLUBS_PER_ACCOUNT = original
}
})
test('GET /club/search filters by category/query and sorts', async () => {
type Result = {
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
@@ -875,4 +1140,313 @@ describe('clubs endpoints', () => {
).json()) as Club[]
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
})
test('requesttojoin follows the club joinability', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const request = async (clubId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer(sub),
})
type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null }
const open = await create('820', { name: 'Open Doors', joinability: 'Open' })
const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' })
const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' })
// Open → straight in as a Member (10).
const joined = (await (await request(open, '830')).json()) as Details
expect(joined).toMatchObject({ success: true })
expect(joined.value?.MyMembershipType).toBe(10)
// AskToJoin → PendingRequested (1), and a repeat request leaves it there.
const asked = (await (await request(ask, '830')).json()) as Details
expect(asked.value?.MyMembershipType).toBe(1)
expect(
(((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType
).toBe(1)
// InviteOnly → refused, with no membership row created.
const refused = await request(invite, '830')
expect(refused.status).toBe(400)
expect(((await refused.json()) as Details).success).toBe(false)
// No token, and an unknown club.
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, {
method: 'PUT',
})
).status
).toBe(401)
expect((await request(99999, '830')).status).toBe(404)
})
test('PUT /club/:id/members/invite adds and promotes members, co-owner only', async () => {
type Member = { AccountId: number; MembershipType: number }
type Details = {
error: string
success: boolean
value: { Club: { MemberCount: number } } | null
}
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Invitational',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const invite = async (fields: Record<string, string>, sub = '870'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
const tiers = async (): Promise<Map<number, number>> => {
const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)
const body = (await res.json()) as { value: Member[] }
return new Map(body.value.map((m) => [m.AccountId, m.MembershipType]))
}
// The client's exact request: add account 872 as a Member (10).
const added = await invite({ accountId: '872', membershipType: '10' })
expect(added.status).toBe(200)
const addedBody = (await added.json()) as Details
expect(addedBody).toMatchObject({ error: '', success: true })
expect(addedBody.value?.Club.MemberCount).toBe(2) // creator + 872
expect((await tiers()).get(872)).toBe(10)
// Inviting an existing member at a higher tier promotes them in place.
expect((await invite({ accountId: '872', membershipType: '20' })).status).toBe(200)
expect((await tiers()).get(872)).toBe(20)
// membershipType defaults to Member when omitted.
await invite({ accountId: '873' })
expect((await tiers()).get(873)).toBe(10)
// Can't mint another creator, can't touch the creator, needs a valid accountId — and
// a rejected invite writes nothing.
expect((await invite({ accountId: '874', membershipType: '100' })).status).toBe(400)
expect((await invite({ accountId: '870', membershipType: '30' })).status).toBe(400)
expect((await invite({ accountId: 'abc' })).status).toBe(400)
expect((await tiers()).has(874)).toBe(false)
// A non-co-owner can't invite (872 is a moderator now, still below co-owner); signed
// out is a 401; an unknown club 404s.
expect((await invite({ accountId: '875' }, '872')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'accountId=875',
})
expect(anon.status).toBe(401)
const missing = await exports.default.fetch(`${ORIGIN}/club/99999/members/invite`, {
method: 'PUT',
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'accountId=875',
})
expect(missing.status).toBe(404)
})
test('members/leave drops a membership and withdraws a pending request', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, {
method: action === 'leave' ? 'POST' : 'PUT',
headers: await bearer(sub),
})
type Details = {
success: boolean
value: { Club: { MemberCount: number }; MyMembershipType: number } | null
}
const open = await create('840', { name: 'Revolving', joinability: 'Open' })
const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' })
// A member leaves → membership 0, and the count drops back to the creator alone.
await call(open, '850', 'requesttojoin')
const left = (await (await call(open, '850', 'leave')).json()) as Details
expect(left.success).toBe(true)
expect(left.value?.MyMembershipType).toBe(0)
expect(left.value?.Club.MemberCount).toBe(1)
// Leaving again is a no-op, not an error.
expect(
(((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// Leaving withdraws a pending request too.
expect(
(((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {})
.MyMembershipType
).toBe(1)
expect(
(((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// The creator can't leave their own club — they'd leave it ownerless.
for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: await bearer('840'),
})
expect(res.status).toBe(403)
}
// ...and they're still the creator afterwards.
const stillIn = (await (
await exports.default.fetch(`${ORIGIN}/club/${open}/details`, {
headers: await bearer('840'),
})
).json()) as { MyMembershipType: number }
expect(stillIn.MyMembershipType).toBe(100)
// No token, and an unknown club.
expect(
(await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' }))
.status
).toBe(401)
expect((await call(99999, '850', 'leave')).status).toBe(404)
})
test('DELETE /club/:id is the creators only, and takes the memberships with it', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const del = async (clubId: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}`, {
method: 'DELETE',
...(sub === undefined ? {} : { headers: await bearer(sub) }),
})
const clubId = await create('860', { name: 'Doomed', joinability: 'Open' })
await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer('861'),
})
// Signed out, a plain member, and an unknown club.
expect((await del(clubId)).status).toBe(401)
expect((await del(clubId, '861')).status).toBe(403)
expect((await del(99999, '860')).status).toBe(404)
// The creator can. The club, and its members, are gone.
const res = await del(clubId, '860')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404)
expect(
await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json()
).toMatchObject({ value: [] })
const member861 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') })
).json()) as Array<{ ClubId: number }>
expect(member861.map((c) => c.ClubId)).not.toContain(clubId)
// Deleting twice 404s rather than reporting success.
expect((await del(clubId, '860')).status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
// setup doesn't always hoist into components.schemas, leaving it dangling.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
// contribute both their methods, and modifydetails/modify both their paths.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /club/home/me',
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /club/categoryTags',
'GET /club/home/me',
'GET /club/mine/created',
'GET /club/mine/member',
'GET /club/search',
'GET /club/{clubId}',
'GET /club/{clubId}/details',
'GET /club/{clubId}/hasDisabledClubChat',
'GET /club/{clubId}/mainimage',
'GET /club/{clubId}/members',
'GET /subscription/details/{accountId}',
'GET /subscription/details/{subscription}',
'GET /subscription/mine/member',
'GET /subscription/subscriberCount/{accountId}',
'POST /announcements/club/{clubId}',
'POST /club/create',
'POST /club/{clubId}/join',
'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave',
'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse',
'PUT /club/{clubId}/mainimage',
'PUT /club/{clubId}/members/invite',
'PUT /club/{clubId}/members/requesttojoin',
'PUT /club/{clubId}/minlevel',
'PUT /club/{clubId}/modify',
'PUT /club/{clubId}/modifydetails',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+4
View File
@@ -35,6 +35,10 @@
"head_sampling_rate": 1 // 100%
}
},
// The per-account cap (MAX_CLUBS_PER_ACCOUNT) is deliberately NOT set here. It's
// injected at deploy time from the gitignored .env (RECFLARE_MAX_CLUBS_PER_ACCOUNT, see
// .env.example), so tuning it never means editing a versioned file. Unset the
// default falls back to DEFAULT_MAX_CLUBS_PER_ACCOUNT in src/clubs.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
+8
View File
@@ -6,12 +6,20 @@ A Cloudflare Workers application using Hono
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
`false`.
- `POST /purchase/v1/initiatepurchase` — begins a purchase, answering
`{ "transactionId": 1234567890 }`. Nothing is charged and no transaction is
recorded, so the id is a fixed placeholder and the posted body is ignored.
- `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special
offers), served from the bundled `static/catalog-v1-all.json`. The client's
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
contains only available SKUs.
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
(limited-time offers/promos); `[]` (none active).
- `GET /reminder/currentTokenBundles/v2` — token-bundle purchase reminders (the
"buy more tokens" nudge); `[]` (none to show).
- `GET /openapi.json` — the generated OpenAPI 3.1 spec for the routes above.
Descriptive only; nothing is validated against it. Also aggregated into the
docs UI on `www` at `/docs`.
## Development
+6 -1
View File
@@ -16,8 +16,13 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+133 -6
View File
@@ -1,9 +1,21 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import catalog from '../static/catalog-v1-all.json'
import {
BareBoolean,
boolQuery,
CatalogSku,
HealthResponse,
InitiatePurchaseRequest,
InitiatePurchaseResponse,
json,
JsonArray,
jsonBody,
} from './openapi'
import type { App } from './context'
@@ -11,6 +23,14 @@ import type { App } from './context'
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
* method routes are served bare.
*/
/**
* The transaction id every purchase initiation answers with. Real money never changes
* hands here and nothing is persisted, so the client only needs a well-formed handle to
* carry through the rest of its store flow.
*/
const PLACEHOLDER_TRANSACTION_ID = 1234567890
const app = new Hono<App>()
.use(
'*',
@@ -25,24 +45,131 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the commerce worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'commerce', status: 'ok' })
)
// Whether the player has ever spent money. A 404 here makes the client treat
// it as an error, so we return `false` (no purchases).
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
.get(
'/purchase/v1/hasspentmoney',
describeRoute({
tags: ['Purchase'],
summary: 'Whether the player has ever spent money',
description: [
'Always `false` — nobody buys anything on this server. A 404 here makes the client',
'treat the call as an error, so the answer is the bare boolean rather than nothing.',
].join(' '),
responses: { 200: json(BareBoolean, 'Always false (no purchases)') },
}),
(c) => c.json(false)
)
// Begin a purchase. The client asks for a transaction handle before it takes the
// player to the platform store; nothing is charged or recorded here, so the id is a
// fixed placeholder and the posted body is ignored.
.post(
'/purchase/v1/initiatepurchase',
describeRoute({
tags: ['Purchase'],
summary: 'Begin a purchase',
description: [
'Hands the client the transaction handle it carries through the rest of the store',
'flow. Nothing is charged and no transaction is recorded, so the id is a fixed',
'placeholder and the posted body is accepted and ignored — an absent or unparseable',
'body is a 200, not a 400.',
].join(' '),
requestBody: jsonBody(InitiatePurchaseRequest, 'The purchase the player confirmed'),
responses: { 200: json(InitiatePurchaseResponse, 'The (placeholder) transaction id') },
}),
(c) => c.json({ transactionId: PLACEHOLDER_TRANSACTION_ID })
)
// The purchasable SKU catalog (token packs, special offers), served from the
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
// catalog is already only the available SKUs, so the param doesn't change the
// response.
.get('/api/catalog/v1/all', (c) => c.json(catalog))
.get(
'/api/catalog/v1/all',
describeRoute({
tags: ['Catalog'],
summary: 'The purchasable SKU catalog',
description: [
'The token packs, bundles and special offers the store shows, served from the bundled',
'static catalog. The clients `onlyAvailableSkus` is accepted and ignored: the bundled',
'catalog already contains only available SKUs.',
].join(' '),
parameters: [
boolQuery('onlyAvailableSkus', 'Accepted and ignored — the catalog is already filtered'),
],
responses: { 200: json(CatalogSku.array(), 'Every available SKU') },
}),
(c) => c.json(catalog)
)
// Current purchase campaigns (limited-time offers/promos). None exist, and
// an empty list is the client's "no active campaigns" state.
.get('/purchasecampaign/allcurrent/v2', (c) => c.json([]))
.get(
'/purchasecampaign/allcurrent/v2',
describeRoute({
tags: ['Purchase'],
summary: 'Current purchase campaigns',
description: [
'Limited-time offers and promos. Always `[]` — none exist, and an empty list is the',
'clients “no active campaigns” state.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty (no active campaigns)') },
}),
(c) => c.json([])
)
// Token-bundle purchase reminders (the "buy more tokens" nudge). None to show,
// and an empty list is the client's "no reminders" state.
.get('/reminder/currentTokenBundles/v2', (c) => c.json([]))
.get(
'/reminder/currentTokenBundles/v2',
describeRoute({
tags: ['Purchase'],
summary: 'Token-bundle purchase reminders',
description: [
'The “buy more tokens” nudges. Always `[]` — there are none to show, and an empty list',
'is the clients “no reminders” state.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty (no reminders)') },
}),
(c) => c.json([])
)
// 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(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare commerce',
version: '1.0.0',
description: [
'The store surface for recflare, a private-server reimplementation of the Rec Room',
'backend: the SKU catalog the client shows and the purchase calls it makes around it.',
'',
'No money moves here. There is no store integration and no purchase storage, so the',
'catalog is a bundled static asset, the campaign and reminder feeds are empty, and a',
'purchase initiation answers with a placeholder transaction id.',
].join('\n'),
},
servers: [{ url: 'https://commerce.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+118
View File
@@ -0,0 +1,118 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the commerce worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match/playersettings workers: a reverse-engineered
* protocol, lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** An optional boolean query parameter. */
export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'boolean' } }
}
// ---- Loose shapes ----------------------------------------------------------
/** An opaque JSON object — a body whose fields haven't been reversed yet. */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (an empty-list stub). */
export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — `hasspentmoney` answers `false` with no envelope. */
export const BareBoolean = z.boolean()
// ---- Service ---------------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('commerce'),
status: z.literal('ok'),
})
// ---- Catalog ---------------------------------------------------------------
/**
* The per-SKU `data` blob. `giftDropIds` are the drops granted when the SKU is redeemed
* (empty for the bundles, which grant their contents directly); `message` is the label the
* store shows on the purchase.
*/
export const CatalogSkuData = z.object({
giftDropIds: z.array(z.int()),
message: z.string(),
subscriptionPurchase: z
.unknown()
.optional()
.describe('Present only on the subscription SKU; its shape is not reversed yet'),
})
/**
* One purchasable SKU from `GET /api/catalog/v1/all` a token pack, a bundle or a
* special offer. `price` is in cents on the store the client is running against, and the
* per-store id fields are only present where that SKU ships on that store, so all of them
* are optional except the Oculus/Apple/Google ids the reference catalog always carries.
*/
export const CatalogSku = z.object({
skuId: z.int(),
name: z.string(),
description: z.string().describe('Often an empty string for token packs'),
imageName: z.string().describe('The store tile image; the img worker serves it by name'),
price: z.int().describe('Store price in cents, e.g. 99 = $0.99'),
oculusSkuId: z.string(),
appleProductId: z.string(),
googlePlaySkuId: z.string(),
picoSkuId: z.string().optional(),
xboxProductId: z.string().optional(),
xboxStoreId: z.string().optional(),
psnProductLabel: z.string().optional(),
psnEntitlementLabel: z.string().optional(),
nintendoSkuId: z.string().optional(),
isSingleUse: z.boolean(),
shouldAppearInTokenStore: z.boolean(),
dataSchemaVersion: z.int(),
data: CatalogSkuData,
})
// ---- Purchase --------------------------------------------------------------
/**
* `POST /purchase/v1/initiatepurchase` body what the client sends when the player
* confirms a purchase (the SKU and the store it is being bought on). Accepted and
* ignored: the field names have not been reversed yet, and nothing here talks to a store.
*/
export const InitiatePurchaseRequest = JsonObject.describe(
'The clients purchase-initiation payload; accepted and ignored'
)
/**
* `POST /purchase/v1/initiatepurchase` the handle the client carries through the rest
* of the store flow. Nothing is persisted, so this is a fixed placeholder id.
*/
export const InitiatePurchaseResponse = z.object({
transactionId: z.int().describe('Placeholder — no transaction is recorded'),
})
@@ -18,6 +18,22 @@ describe('commerce endpoints', () => {
expect(await res.json()).toBe(false)
})
it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ skuId: 178, platform: 'Standalone' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ transactionId: 1234567890 })
})
it('POST /purchase/v1/initiatepurchase ignores the body entirely', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, { method: 'POST' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ transactionId: 1234567890 })
})
it('GET /api/catalog/v1/all serves the SKU catalog', async () => {
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
expect(res.status).toBe(200)
@@ -38,4 +54,45 @@ describe('commerce endpoints', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /api/catalog/v1/all',
'GET /purchase/v1/hasspentmoney',
'GET /purchasecampaign/allcurrent/v2',
'GET /reminder/currentTokenBundles/v2',
'POST /purchase/v1/initiatepurchase',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
// not always hoist, leaving a dangling reference.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+455 -70
View File
@@ -1,77 +1,462 @@
# econ
Economy Worker served on the `econ` subdomain. Hosts the avatar/economy
endpoints the game client calls on the `econ` service (distinct from the main
`api` worker). DB-backed data is stubbed for now — no bindings yet.
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
main `api` worker, which also serves many of them — the client may call either host).
## Endpoints
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
empty-list stubs.
- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served
from the bundled `static/default-avatar-items.json` catalog.
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
the same source file as `defaultunlocked`, so it returns the identical
catalog.
- `GET /api/avatar/v4/items``[Authorize]`. The player's avatar items: the
items they've bought (from `buyItem`, in the `inventory` table) prepended to
the default catalog. A player who has bought nothing gets just the catalog.
- `GET /api/avatar/v2``[Authorize]`. The player's avatar. No DB binding yet,
so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor,
HairColor }` seeded for a new player.
- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar
items. No auth; returns `{ items: [] }` with no DB binding.
The client requests this when custom-item creation is allowed, so a missing
route here shows up as "Failed to download unlocked avatar items".
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
static JSON file verbatim); returns the bundled
`static/my-progress.json` default for all players until a DB binding exists.
- `GET /api/avatar/v3/saved``[Authorize]`. Saved outfits; `[]` without a DB.
- `GET /api/avatar/v2/gifts``[Authorize]`. The player's unopened gift boxes
(from their purchases), out of the shared `received_gift` table; `[]` when
they have none.
- `POST /api/avatar/v2/gifts/consume` — open a box (form body `Id=<n>&UnlockedLevel=<n>`,
posted with a trailing slash). Deletes the box scoped to the caller; the item was
already granted at purchase, so this is cosmetic. Always answers the success envelope
`{ error: "", success: true, value: null }` (a captured real consume returns this, not
an empty body — the client parses it to finish opening the box), even for a
missing/already-opened box, so a fire-and-forget re-open never errors. Also served by
the `api` worker (the client may call either host).
- `POST /api/storefronts/v2/buyItem``[Authorize]`. Buy a storefront item.
Looks the item up in `static/storefronts/sf{StorefrontType}.json`, confirms the
client's `RequestedPrice` still matches, debits the buyer atomically, grants the
item, and returns a gift box. An avatar-item drop goes into the `inventory` table
(own-once); a consumable drop goes into the `consumable` table (each buy stacks a
new instance). The response's `Balance` is the change applied (the negated price),
not the resulting total — the client reads its new total from `GET /balance/:type`.
`409` on a stale price, `404` on an unknown item, `400` on insufficient balance.
- `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth).
- `POST /api/settings/v2/set``[Authorize]`. Persist settings; 200 ack only.
- `GET /api/consumables/v2/getUnlocked``[Authorize]`. The consumables the
player has bought (from `buyItem`, in the `consumable` table), grouped by item
into the unlocked-consumable DTO (`Ids`/`CreatedAts` per instance, `Count` their
sum); `[]` when they've bought none.
- `GET /api/storefronts/v4/balance/2``[Authorize]`. Token balance; `[]`.
- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from
the bundled `static/storefronts-v3-giftdropstore-3.json`.
- `GET /api/storefronts/v1/adcarouselitems` — storefront ad-carousel items,
served from the bundled `static/ad-carousel-items.json` (one placeholder
banner until real promo data exists).
- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the
bundled `static/weekly-challenge.json`.
- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`.
- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`.
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
`{ subscription: null, platformAccountSubscribedPlayerId: null }`.
- Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id`
and `GET /api/roomcurrencies/v1/currencies` both return `[]`.
## Routes
These economy routes are also served by the `api` worker; they're
duplicated here because the client calls them on the `econ` host.
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer.
## TODO before production
| Method | Path | Auth | Description |
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
- Gifting to another player (`buyItem` with a `Gift` block) grants the item and
box to the recipient, but there's no notification. `buyItem` grants avatar-item
and consumable drops; currency/xp drops aren't granted yet.
- Consumables are granted and listed but never spent — nothing consumes them, so
`Count` only ever grows (each purchase grants `1`; catalogs don't specify a
per-item quantity).
The app runs with `strict: false`, so trailing-slash variants match (the client posts
`/gifts/consume/` with a trailing slash).
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
in the spec, so adding one without documenting it fails.
## Purchases (`buyItem`)
The core flow. The client posts the storefront/item ids, the currency, and the
`RequestedPrice` it rendered; the handler:
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
price the catalog no longer offers;
3. debits the buyer **atomically** (`400` on insufficient balance);
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
Two things are easy to get wrong:
- **`Balance` in the response is the _change_ applied** (the negated price), not the
resulting total. The client reads its new total from `GET /balance/:type`.
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
waits on the cosmetic "open it" moment.
A `Gift` block routes the item (and box) to another player, but the caller always pays.
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
## Query drops — the loot boxes (`IsQuery`)
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
(`AvatarItemDesc`, `EquipmentModificationGuid`, `ConsumableItemDesc`) are empty on purpose,
and what the player gets is picked at grant time. sf2's tooltip states the rule outright —
_"A random 4-star item that you don't have."_ Eight ship in the catalogs, two families of
the same ladder:
| sf2 "Star Boxes" (`ItemSetId` 44, `Unique`) | Rarity | sf3 "Random box" family |
| ------------------------------------------- | ------ | ----------------------- |
| — | 0 | Common Random box |
| 2-Star Unique Box | 10 | Uncommon Random box |
| 3-Star Unique Box | 20 | Rare Random box |
| 4-Star Unique Box | 30 | Epic Random box |
| — | 50 | Legendary Random box |
That table is the **star ↔ rarity ladder** (`STAR_RARITY` in `econ.app.ts`): sf2's three
boxes pin 2/3/4 → 10/20/30 by carrying both their name and their `QueryRedirectRarity`, and
sf3's five-name ladder fills in the ends. It's the same tier list twice, so read a rarity
number in either dialect.
`rollQueryDrop` resolves one inside `grantGiftDrop`, so both faucets — a purchase and the
weekly gift — hand over a real item rather than an unopenable box:
- **The pool is sf3**, the general store (`ROLL_STOREFRONT_TYPE`). It's the only catalog
with a real pool at every tier (1161 items against 840 in the themed ones), it's where
the Random box family itself sells, and "a random 4-star item" means the item universe,
not whichever seasonal shelf the box came off.
- **Filtered to what the player doesn't own**, which is the `Unique` promise and the only
reading of "an item you don't have" that means anything.
- **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a
box), and so are consumables: they stack, so "don't have" never becomes false and they'd
crowd out the real prizes.
- **`avatarItemsOnly` narrows it to worn items**, dropping equipment skins from the pool.
Level-up boxes use it; storefront boxes don't, since "a random 4-star item" means both.
- **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
agree; sf3's boxes carry only `Rarity`.
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of
every 4-star item still gets the box, just nothing in it.
- **`buyItem` answers with the ROLLED item, not the box.** The client draws the purchase
from `BalanceUpdates[0].Data[0]`, and a query drop's own item fields are all empty — echo
those and the player sees an empty box for a purchase that actually granted something. The
stored box was always correct; only the response was wrong.
## Consume envelopes
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
target. A captured real consume returns this envelope, not an empty body: the client
parses it to finish the action, so a bare 200 reads as a failure and the item never
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
## Weekly challenge (`static/weekly-challenge.json`)
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
stamped in — see Progress below). The server never evaluates the rules: the client reads
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
definition of a week's challenges — ids, display strings, matching rules and the reward
preview.
Everything below was read off reference data (one captured live rotation), not a spec.
Field meanings marked _(inferred)_ are read from how the values line up with the strings
the client renders; the rest are pinned by the data itself. The file itself is edited
freely as rotations change — the examples here are the captured week, so expect the shipped
rotation to differ.
### Top level
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
| `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
**The frozen clock:** `ServerTime` (Mar 31) sits _inside_ `StartAt``EndAt` (Mar 25 → Apr 1),
about a day before the end, and the file is static — so the client always sees an active
rotation with a ~1-day countdown rather than an expired one. If you edit the window, move
`ServerTime` inside the new one too, or the challenges may render as already over.
### A challenge entry
| Field | Notes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
| `Tooltip` | The longer hint under it. |
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
token to a room and renders a tappable name. Subrooms use a dotted path
(`^Paintball.Clearcut`). It is optional decoration, not markup the client requires — the
same rotation writes both `"Complete 10 games in ^Paintball"` and, plainly,
`"Complete 3 games of Paintball: Clear Cut"`.
### The `Config` rule tree
An escaped JSON string holding a tree of nodes, each with a numeric type in `ct`: a
**Match** (`ct: 0`, `wc` is a list of predicates that must all hold for one game result) or
a **Counter** (`ct: 1`, `ctc` is the child node to count and `t` the target). Leaves match a
scene allow-list (`ct: 7`, subroom `UnitySceneId`s) or a session variable (`ct: 9`, e.g.
`won`). The server never evaluates any of it — the client does, and posts the tree back with
its own count written in.
**Reading or writing one? See `.agents/weekly-challenge-config/SKILL.md`** — the full
grammar, the two idioms the file uses, how to resolve a scene guid to a room, the shared
scenes that make a challenge complete in more rooms than you meant (`Soccer` and `Stadium`
are one scene), and an authoring checklist.
### The `Gift` block
Same item vocabulary as a storefront `GiftDrop` (`AvatarItemDesc` — a comma-separated list
of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabName`,
`EquipmentModificationGuid`) plus `Xp`, `Level` and `StorefrontType`, but two fields are
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
feed one shape to the other's reader.
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q`
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
`sf3.json` as `2121` ("Camera Skin (Comic)").
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
`GiftContext`/`GiftRarity` are exactly what that translation is for.
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
catalog if a rotation we publish sets them; neither is present in the captured one.
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
what the player gets _instead_ when they already own the item — the real game phrased it
"…or a 4-Star Box!" — so it is granted as a query drop (a roll) at the tier its star count
names, via the ladder in the query-drop section. Renaming it to `3-Star Box` retunes the
consolation tier with no code change; a name that doesn't parse falls back to 4 stars.
### Winning the gift (`challenge_gift`)
There is no claim endpoint and the client never asks: the reward is handed out from the
`updateProgress` call that reaches the threshold. Every completing report on the **live**
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
`inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
**Three of five, not five of five** (`CHALLENGES_REQUIRED_FOR_GIFT`). A week publishes five
challenges and the gift is for playing most of them, so the two a player can't reach — a
quest they don't own, a mode they don't like — don't sink the whole week. The count is of
challenges the rotation still **publishes**: a live client can report an id an edited
rotation no longer lists, and three of those shouldn't buy a gift nobody worked for. A
rotation publishing fewer than three can only ask for what it has.
**The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the
rotation's reward is one fixed item that sells in the store — they get the
`FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth
nothing. A `Gift` block carrying no ownable item at all (no avatar desc, no equipment guid)
counts as "already owned", so a rotation whose reward is _only_ a box is written by leaving
the block empty and naming the tier.
- **`challenge_gift` makes it happen once.** One row per (account, rotation); the row's
existence _is_ the grant. The client keeps reporting after the set is finished, so the
insert is the gate: `ON CONFLICT … DO NOTHING … RETURNING` claims it in one statement, and
a second report returns no row and grants nothing.
- **Claim first, grant second** — at-most-once. If the grant then fails the reward is lost
rather than doubled; it's logged (`failed to grant weekly challenge gift`) and re-granted
by hand if it ever happens. A faucet that sticks is easier to spot than one that leaks.
- **The response is unchanged; the socket carries the news.** `updateProgress` answers the
same four fields whether or not a gift was won, and a `GiftPackageReceivedImmediate` (31)
frame goes out over the hub with the box — that's what pops the reward panel the moment
the set is finished, instead of the player finding it on the next read of the gifts list.
The payload is the reference server's field-for-field (`Id`, `FromGiftDropId: 0`,
`FromPlayerId`, the item fields, `Platform`/`PlatformsToSpawnOn: -1`, `BalanceType: -2`,
`Message`), and it names the **rolled** item when the fallback box is what was granted.
`Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a
box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is
logged and swallowed, since the gift is already granted and stored.
- **`CompletedRequired: true` makes the rotation all-or-nothing** — the threshold becomes
every published challenge. That reading of the flag is still _inferred_ (it is `false` in
the captured rotation, which is the partial default), but it's the one its name and the
three-of-five rule agree on.
- **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0`
in the captured rotation.
- **A report against an old rotation never wins anything**, and an empty `Challenges` array
earns nothing (its threshold clamps to zero, which every player would otherwise meet
without playing).
- **Players already past the threshold when this shipped still get it**: the client
re-reports completed challenges, and the first such report is a completing report.
### Progress (`challenge_status`)
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
running count, so a per-player copy would just be a staler duplicate of static data — it is
echoed back untouched but never persisted. The response is the four posted fields, except
`Complete` is the **stored** value rather than the posted one, because:
- **Completion latches within a rotation.** The client reports repeatedly, and a later
report saying "not complete" (a fresh session, a retry arriving out of order) must not
un-finish something already finished.
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
the same id in a later week would otherwise start out already complete. A report whose
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
are scoped to the rotation for the same reason.
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
with every `Complete` false rather than a 401, since the rotation is public and a failure
on this route can stall the client's load. The overlay rebuilds the response object rather
than stamping the imported JSON in place: that import is module state shared across every
request an isolate serves, so mutating it would leak one player's completions to the next
caller.
## Game rewards (`reward_status`)
The client asks for a reward whenever it thinks one is due, posting a form body of the type
and the message to show for it:
```
rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day
rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
```
Since the client asks rather than the server offering, whether a reward is actually **owed**
is decided here, from `reward_status` — one row per (account, reward type, gift context)
holding the last claim and a count. One claim per type per activity per hour
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay`
suggests; per-type windows would be a map keyed by type.
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
these off right after a match, so two can land together; a read-then-write would let both
see the same stale `granted_at` and pay out twice.
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
forward, a client that retries in a loop would never become eligible.
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of
the day" is per activity, so a player who moves from Soccer to Paintball is owed another
reward while a second Soccer match inside the hour is not.
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
instead of hitting the conflict, and the cooldown would never apply. Migration
`0013_reward_status_gift_context.sql` rebuilds the table (SQLite can't add a column to a
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
before it keep counting.
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
`progression` and the box is the wrapper the client shows for it — no item, every item field
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
failure can't leave a box promising XP nobody was credited.
- **One flat amount for every reward type**, matching the one flat cooldown they share.
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
the same shape the per-type cooldown would take.
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
by the hourly cooldown rather than cleared in one match.
- **The response stays `[]`.** It's what the client already accepts, and the reward is
delivered as a box, so there's nothing to put in the body. The reference answers its own
(different) flow with `{ error, success, value: null }`, not a list of rewards.
- **An on-cooldown ask pays nothing** — no XP, no box, no frame. That's the whole point of
getting eligibility right first: a client that retries in a loop must not mint boxes.
**Progression (`progression`) is shared.** `econ` writes it here; `api` reads it back for
`GET /api/players/v{1,2}/progression/…`. It lives in `@repo/domain` for that reason, the
same split as gift boxes. A player with no row reads as level 1 / 0 XP, so a GET never
inserts.
**Levelling spends the XP.** `xp` is progress into the current level, not a lifetime total:
`addXp` adds the grant, then walks the ladder in `LEVEL_REQUIRED_XP`, subtracting each
level's cost while it's covered — so a big enough grant can cross several levels at once.
The ladder steps 10 → 20 → 45 → 115 → 360 → 1080 every ten levels and stops at 50, so the
first level costs 10 XP and the last costs a hundred times that.
That table is copied from the `LevelProgressionMaps` the client is served in
`apps/api/static/api-config-v2.json`, and **both sides have to agree** or the bar fills to a
different mark than the level-up fires at; an `api` test asserts they stay identical.
It is also the real game's curve, checked against Rec Room's own published level chart —
cumulative XP to finish a level: 170 by 10, 620 by 20, 1,770 by 30, 5,370 by 40, 16,170 by 50. Nearly flat to level 20, then a knee at 3040 and a steep climb to the cap; a third of
the whole grind sits in the last ten levels. A test pins those milestones, since per-level
costs are easy to edit one at a time and hard to eyeball as a curve.
**Every level pays out a reward**, from Rec Room's published level-reward table
(`LEVEL_REWARDS` in `@repo/domain`) — per level, not per band:
| Levels | Reward |
| ---------------- | ------------------------------------------ |
| 1, 3, 5, 6, 7, 9 | Consumable |
| 2, 4, 8, 10 21 | 2-Star Clothing (rarity 10) |
| 22 30 | 3-Star on even levels, 2-Star between |
| 31 39 | 3-Star, with 4-Star at 31 and 35 |
| 40 49 | 4-Star Clothing (rarity 30) |
| 50 | 5-Star Clothing (rarity 50) — the only one |
**One reward per level crossed** — a grant spanning several levels pays each of them. In
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
arrives as a gift box announced like any other (`Level 2!`).
- **"Clothing" is why the roll passes `avatarItemsOnly`** — the prize has to be something the
player can wear and be seen in, never an equipment skin for a weapon they may not own.
- **Consumable levels don't roll a rarity.** The table names no star tier for them, and
consumables stack, so there's no ownership filter either — a second Confetti Cannon is a
fine prize. It's picked as a concrete drop rather than through the query path.
- **This table is not the served config's `GiftRarity`.** That one is a coarse per-band tier
(flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap) with no notion of consumables, and
the two disagree — level 15 is 2-Star in the published table and 20 in the config. We grant
from the published table; the config is left as captured, so the drift test asserts only
the XP costs. If the client previews an upcoming reward from `GiftRarity`, aligning the two
is an edit to the static config.
- The reference server carries the config data and never reads it: granting anything for a
level is ours.
**The client is told, or it shows nothing.** A grant pushes `PlayerProgressionLevelUpdate`
(`{ PlayerId, Level, XP }`) — without it the bar sits still until something else refreshes
it, which is what "levelling does nothing" looks like from the game. `api`'s
`GET /api/players/v1/progression/:id` pushes the same frame on read, as the reference does,
so a client that just connected gets its bar right.
**Not ported:** the reference's `request` doesn't grant at all — it offers **three** drops,
pushes a `RewardSelectionReceived` frame and waits for `POST /api/gamerewards/v1/select` to
grant the one the player picked. We grant on request instead, so there is no selection state
and no `/select`. It also caps activity XP per day (`daily_xp_ledgers`); the hourly cooldown
is our cap.
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected.
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | ---------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
## Known gaps
- Gifting to another player grants the item and box but does not notify the recipient — the
reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
a `Gift`), and `pushGiftReceived` is now sitting right there to do it.
- `buyItem` grants avatar-item, equipment, consumable and query (box) drops; currency/xp
drops aren't granted.
- A query drop rolls uniformly across the tier and can't run at a rarity sf3 doesn't
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
storefronts, which the ASSETS binding can't enumerate.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores.
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
reference caps activity XP per day in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
level section); we grant from the table and leave the config as captured, so a client that
previews an upcoming reward would preview the config's answer, not ours.
+15
View File
@@ -0,0 +1,15 @@
-- Owned equipment, owned by the `econ` worker. Like avatar items (own-once, one row
-- per (account, item)) rather than consumables (which stack): equipment is a boolean
-- unlock keyed by its `EquipmentModificationGuid` (the gift-drop's equipment guid), so
-- re-buying the same skin is a no-op rather than a duplicate row. Granted at purchase
-- time by `/api/storefronts/v2/buyItem` (when the gift-drop carries an
-- `EquipmentModificationGuid`) and read back by `/api/equipment/v2/getUnlocked`; `data`
-- is the rendered unlocked-equipment DTO. Kept in sync with EQUIPMENT_SCHEMA_DDL in
-- src/equipment-db.ts.
CREATE TABLE IF NOT EXISTS equipment (
account_id INTEGER NOT NULL,
equipment_modification_guid TEXT NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (account_id, equipment_modification_guid)
);
@@ -0,0 +1,24 @@
-- Rewrite the stored unlocked-equipment DTOs onto the shape the client actually reads.
-- Rows written before this used `EquipmentModificationGuid`/`EquipmentPrefabName` (the
-- gift-drop's prefixed names, carried straight over at grant time) and had no
-- `Favorited`/`PlatformMask`. The live endpoint serves the unprefixed
-- `ModificationGuid`/`PrefabName` plus both of those, and the entries the client PUTs
-- back to `/api/equipment/v1/update` use the unprefixed names too — so an un-rewritten
-- row renders with a blank prefab and can never be favourited (the update matches on a
-- guid the row's `data` no longer spells the same way).
--
-- The `data` column is the DTO verbatim, so the fix is a JSON rewrite in place; the row
-- key (`equipment_modification_guid`) is unchanged. Guarded on the old key being
-- present, which also makes it a no-op on re-run.
UPDATE equipment
SET data = json_object(
'ModificationGuid', json_extract(data, '$.EquipmentModificationGuid'),
'PrefabName', json_extract(data, '$.EquipmentPrefabName'),
'FriendlyName', json_extract(data, '$.FriendlyName'),
'Tooltip', json_extract(data, '$.Tooltip'),
'Rarity', json_extract(data, '$.Rarity'),
'PlatformMask', -1,
'Favorited', json('false')
)
WHERE json_extract(data, '$.EquipmentModificationGuid') IS NOT NULL;
@@ -0,0 +1,18 @@
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
-- inventions a player has bought from the invention store. Written at purchase time by
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
-- second purchase is a no-op rather than a duplicate row.
--
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
CREATE TABLE IF NOT EXISTS inventory_invention (
account_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
acquired_at TEXT NOT NULL,
PRIMARY KEY (account_id, invention_id)
);
@@ -0,0 +1,25 @@
-- Weekly-challenge progress, owned by the `econ` worker. One row per (account,
-- challenge): the client evaluates a challenge's rule tree locally and posts its verdict
-- to `/api/challenge/v2/updateProgress`, which upserts here; `/api/challenge/v2/getCurrent`
-- reads the rows back to stamp each challenge's per-player `Complete`.
--
-- Only the completion flag is stored. The `Config` rule tree posted alongside it is the
-- challenge's definition (static/weekly-challenge.json, identical for every player) plus
-- the client's running count in `cc`; the server evaluates none of it, so a per-player copy
-- would just be a staler duplicate of the catalog.
--
-- `challenge_map_id` is the rotation the report belongs to. It is not part of the key, but
-- it scopes reads and resets the row when a challenge id comes back in a later rotation:
-- ids are only unique within one. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
-- src/challenge-db.ts.
CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id)
);
CREATE INDEX IF NOT EXISTS idx_challenge_status_account_map ON challenge_status (account_id, challenge_map_id);
@@ -0,0 +1,20 @@
-- Game-reward eligibility, owned by the `econ` worker. One row per (account, reward type):
-- the client asks for a reward whenever it thinks one is due (`POST
-- /api/gamerewards/v1/request` with `rewardType`/`Message`), so this table is what decides
-- whether one is actually owed and keeps a repeat ask from paying out twice.
--
-- `granted_at` is when the type was last claimed and `grant_count` how many times it has
-- been; the claim is a conditional upsert, so the check and the write are one atomic
-- statement (the client can fire two requests at once after a match).
--
-- The reward TYPE is the whole key. The client also sends a `giftContext` (the activity,
-- e.g. `Soccer`), deliberately not keyed on: one cooldown per type, shared across
-- activities. Kept in sync with REWARD_STATUS_SCHEMA_DDL in src/reward-db.ts.
CREATE TABLE IF NOT EXISTS reward_status (
account_id INTEGER NOT NULL,
reward_type TEXT NOT NULL,
granted_at TEXT NOT NULL,
grant_count INTEGER NOT NULL,
PRIMARY KEY (account_id, reward_type)
);
@@ -0,0 +1,20 @@
-- Weekly-challenge gift grants, owned by the `econ` worker. One row per (account,
-- rotation), written when the last challenge of a rotation is reported complete on
-- `/api/challenge/v2/updateProgress` and the rotation's `Gift` is handed out.
--
-- The table exists only to make that grant happen ONCE. The client reports progress
-- repeatedly, so every report that arrives with the set already finished would otherwise
-- mint another copy of the reward; the insert is the gate, and it conflicts on the second
-- report instead of paying out again.
--
-- Keyed by rotation as well as account so a new week's set can be finished and rewarded on
-- its own — `challenge_map_id` is the rotation, matching `challenge_status`. There is no
-- `granted` flag: the row's existence IS the grant. Kept in sync with
-- CHALLENGE_GIFT_SCHEMA_DDL in src/challenge-db.ts.
CREATE TABLE IF NOT EXISTS challenge_gift (
account_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
granted_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_map_id)
);
+21
View File
@@ -0,0 +1,21 @@
-- Player progression (level + XP), owned by the `econ` worker as the writer, but shared:
-- `econ` pays XP out (game rewards) and `api` reads it back for
-- `GET /api/players/v{1,2}/progression/…`, so the helpers live in @repo/domain rather than
-- in either worker. Same split as `received_gift`.
--
-- One row per account, created on the first grant. A missing row means "nothing earned
-- yet", which is the level-1/0-XP default the progression endpoints already served — so
-- reads fall back to it instead of inserting on a GET.
--
-- `level` is stored rather than derived: the reference server levels a player up by
-- subtracting the tier's RequiredXp from the running XP, using thresholds from a config we
-- don't have (configv2.json's LevelProgressionMaps). Until those numbers exist XP
-- accumulates and everyone stays level 1; the column is here so turning the curve on later
-- is a write, not a migration. Kept in sync with PROGRESSION_SCHEMA_DDL in
-- packages/domain/src/progression-db.ts.
CREATE TABLE IF NOT EXISTS progression (
account_id INTEGER PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0
);
@@ -0,0 +1,31 @@
-- Widen the game-reward cooldown key to include the activity the reward came from.
--
-- The client posts a `giftContext` alongside the type (`rewardType=PostGameActivity&
-- giftContext=Soccer`), which migration 0010 deliberately dropped: one cooldown per type,
-- shared across activities. That means the first activity of the day pays once no matter
-- how many different activities a player runs. Keying on (type, context) instead gives
-- each activity its own cooldown, so a different activity pays again while the same one
-- stays on cooldown.
--
-- SQLite can't add a column to a primary key, so the table is rebuilt and the rows copied
-- across. Existing rows have no context and take `''` — NOT the NULL that would read more
-- naturally, because SQLite allows (and does not dedupe) NULLs in a non-INTEGER primary
-- key, which would let the upsert insert a second unkeyed row instead of updating the
-- first and pay out every time. Asks that carry no `giftContext` land on that same `''`
-- bucket, so a pre-migration cooldown keeps counting.
CREATE TABLE reward_status_new (
account_id INTEGER NOT NULL,
reward_type TEXT NOT NULL,
gift_context TEXT NOT NULL,
granted_at TEXT NOT NULL,
grant_count INTEGER NOT NULL,
PRIMARY KEY (account_id, reward_type, gift_context)
);
INSERT INTO reward_status_new (account_id, reward_type, gift_context, granted_at, grant_count)
SELECT account_id, reward_type, '', granted_at, grant_count FROM reward_status;
DROP TABLE reward_status;
ALTER TABLE reward_status_new RENAME TO reward_status;
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+20 -4
View File
@@ -1,3 +1,5 @@
import { BalancePlatform } from '../../notify/src/notification-payloads'
/**
* Currency balances on the shared `recflare` D1 database.
*
@@ -13,7 +15,8 @@
*/
/**
* The currencies the client knows about (its `CurrencyType` enum). The client sends
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
* `GKPEKOLBBJL` which lists every member below except `RoomInventoryItem`). The client sends
* these ints in the balance/storefront paths `/api/storefronts/v4/balance/2` is
* RecCenterTokens so the values are fixed by the client, not by us.
*
@@ -90,10 +93,23 @@ export function startingBalances(
}
/**
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
* per-platform wallets (real RecNet did, for platform-purchased tokens).
* The ONE balance bucket this server uses: `NonPurchasedNotUsableInP2P` (-2).
*
* The client keys a balance by `(CurrencyType, Platform)` and shows the SUM of the buckets,
* so which Platform a balance is reported under is not cosmetic it is the bucket's
* identity. Everything we hand out is minted rather than bought, and we track no
* per-platform wallets (real RecNet did, for tokens paid for on each store), so one
* account-wide bucket per currency answers for all of them.
*
* Every surface that names the bucket must name THIS one: the balance DTO's `Platform`, the
* `BalanceType` the storefront HTTP bodies echo, and the `Platform` on every
* `StorefrontBalance*` socket frame. Naming a second one there invents a balance the client
* adds to the real total see the frame rule in econ.app.ts.
*
* The enum itself lives in the notify worker's `notification-payloads.ts`, recovered from
* the client's decoder, rather than being duplicated here.
*/
export const ALL_PLATFORMS = -2
export const ALL_PLATFORMS: BalancePlatform = BalancePlatform.NonPurchasedNotUsableInP2P
/** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */
export const BALANCE_SCHEMA_DDL: string[] = [
+150
View File
@@ -0,0 +1,150 @@
/**
* Weekly-challenge progress on the shared `recflare` D1 database one row per
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`.
*
* Only the completion flag is stored, not the `Config` rule tree the client posts with it.
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and
* is identical for everyone), decorated with the client's running count in `cc`; the
* server evaluates none of it, so persisting a per-player copy would only be a second,
* staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar.
*
* Completion LATCHES within a rotation: the client reports progress repeatedly, and a
* report that arrives with the challenge no longer complete (a fresh session, a reordered
* retry) must not un-finish something already finished. A report carrying a different
* `ChallengeMapId` is a new rotation and REPLACES the row instead challenge ids are only
* unique within a rotation, so a challenge that returns in a later week would otherwise
* start out already complete on the old week's row.
*
* Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the
* same `updateProgress` call that reaches the threshold. That payout is gated by a
* second table here, `challenge_gift` one row per (account, rotation), claimed once.
*
* The `econ` worker owns both tables and their migrations
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql).
*/
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id)
)`,
]
/** One challenge's progress as the client reports it. */
export interface ChallengeProgress {
challengeMapId: number
challengeId: number
complete: boolean
}
/**
* Record a progress report and return the completion the row now holds which is what the
* response must echo, since it isn't always what was posted: within a rotation `complete`
* only ever goes false true (see the latching note above), so a `false` report against a
* finished challenge answers `true`.
*
* SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the
* `CASE` can compare the stored `challenge_map_id` with the incoming one while the same
* statement overwrites it.
*/
export async function recordChallengeProgress(
db: D1Database,
accountId: number,
progress: ChallengeProgress
): Promise<boolean> {
const row = await db
.prepare(
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (account_id, challenge_id) DO UPDATE SET
complete = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN MAX(challenge_status.complete, excluded.complete)
ELSE excluded.complete
END,
challenge_map_id = excluded.challenge_map_id,
updated_at = excluded.updated_at
RETURNING complete`
)
.bind(
accountId,
progress.challengeId,
progress.challengeMapId,
progress.complete ? 1 : 0,
new Date().toISOString()
)
.first<{ complete: number }>()
return row?.complete === 1
}
/**
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
* so a stale row from an earlier week same challenge id, different `challenge_map_id`
* doesn't show up pre-completed before the client has reported anything against it.
*
* Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the
* challenges in static/weekly-challenge.json appear here three of the five a week
* publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
*/
export async function getCompletedChallengeIds(
db: D1Database,
accountId: number,
challengeMapId: number
): Promise<Set<number>> {
const { results } = await db
.prepare(
`SELECT challenge_id FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
)
.bind(accountId, challengeMapId)
.all<{ challenge_id: number }>()
return new Set(results.map((r) => r.challenge_id))
}
/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
export const CHALLENGE_GIFT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_gift (
account_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
granted_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_map_id)
)`,
]
/**
* Take the one gift a rotation owes a player, returning whether this call is the one that
* got it `false` means it was already handed out and the caller must grant nothing.
*
* The client keeps reporting progress after the set is finished, so "has this been paid?"
* has to be asked and answered in ONE statement: a read-then-insert would let two reports
* that land together both see no row and both pay out. `ON CONFLICT … DO NOTHING` with
* `RETURNING` gives us that the second insert matches the existing row, writes nothing
* and returns nothing.
*
* The gate is deliberately at-most-once: the row is claimed BEFORE the items are granted,
* so a failure mid-grant loses the reward rather than risking a second one. It is a faucet,
* and a stuck one is easier to notice and re-grant by hand than a leaking one.
*/
export async function claimChallengeGift(
db: D1Database,
accountId: number,
challengeMapId: number,
now: Date = new Date()
): Promise<boolean> {
const row = await db
.prepare(
`INSERT INTO challenge_gift (account_id, challenge_map_id, granted_at)
VALUES (?1, ?2, ?3)
ON CONFLICT (account_id, challenge_map_id) DO NOTHING
RETURNING granted_at`
)
.bind(accountId, challengeMapId, now.toISOString())
.first<{ granted_at: string }>()
return row !== null
}

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