mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
Compare commits
185 Commits
0.0.1
...
mono-updates
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e0f090d18 | |||
| 66c09806f9 | |||
| d12806625d | |||
| d129900762 | |||
| 3aad586153 | |||
| 385e10bd55 | |||
| 8e0e92b449 | |||
| 8364a0b5f6 | |||
| d3838fb590 | |||
| 36c30a396c | |||
| da27b7e797 | |||
| 42e1fb4ab7 | |||
| ca8d40c4ec | |||
| 1afa9b7ac3 | |||
| c5ea04b39d | |||
| c2adc1ffbb | |||
| 4fb1c901b4 | |||
| efbd7936db | |||
| 7df783302b | |||
| 21024c7852 | |||
| a012b5165a | |||
| 3e8f0b3e56 | |||
| 6622302a56 | |||
| 566b212675 | |||
| 4b17b007e9 | |||
| 9164df98a9 | |||
| 7c43f2a1f3 | |||
| af64327fea | |||
| 4109317f0e | |||
| 208c1fe772 | |||
| 0b33e0b46f | |||
| 927c6757bb | |||
| aa6fdaf4b2 | |||
| 3022b3b566 | |||
| e564d3c839 | |||
| 30cf83a47d | |||
| 793b2ad37a | |||
| 6c7a634cb6 | |||
| 5c5988c730 | |||
| 6bbdf989b9 | |||
| 7fbaad1fd8 | |||
| d461961e54 | |||
| c6ec993e2d | |||
| 3d846ef2ad | |||
| 7e62f2b53c | |||
| a30d70076e | |||
| 37489d05dc | |||
| 60505a2519 | |||
| a3d9fdb8bf | |||
| df2d2af75b | |||
| c2d36009a3 | |||
| 4e578f7771 | |||
| 7bbbac6dc9 | |||
| 1e45afbcee | |||
| 9bb43f7b9c | |||
| 8bd76a4bae | |||
| 3dd6d6420b | |||
| 368ca252c1 | |||
| 51364e482c | |||
| 565d9b1aea | |||
| aeff7d50cd | |||
| 880c6ab2dc | |||
| f185ef97df | |||
| cc43d57172 | |||
| 8b804eaa33 | |||
| 6b7acc9435 | |||
| 93a46871de | |||
| 4111bc49aa | |||
| f6561f1ec9 | |||
| bc96a6245b | |||
| a73dec7c13 | |||
| ae3bef4cc4 | |||
| 1f615bab4f | |||
| a986d012f5 | |||
| b82a5e1dc0 | |||
| 6bfd4d9e50 | |||
| 079c889ccb | |||
| 9f4ce07aca | |||
| dfb1e9ab21 | |||
| 1d08ed8296 | |||
| f3e2ab422c | |||
| aa304dbede | |||
| 10eb89ac12 | |||
| 65611c15d8 | |||
| d6a0e3e6a6 | |||
| 7d300fa836 | |||
| 73bb7c4609 | |||
| dbc6d15ef5 | |||
| db003d54ef | |||
| 05b56e698e | |||
| dee7497fe5 | |||
| 12f6d7ab61 | |||
| 8d1539de03 | |||
| 7e0c26a100 | |||
| f94877347c | |||
| 23bc159c28 | |||
| 70df3cb6cb | |||
| 03b1c59f0d | |||
| d298977790 | |||
| 821bf54b9b | |||
| 108b061019 | |||
| af2a2a0683 | |||
| 339a91735b | |||
| a46f6db9d7 | |||
| b3f1d04823 | |||
| 55cb769de9 | |||
| 4c6b9679e4 | |||
| 6a910b27bd | |||
| 9c8bf7087c | |||
| d5ccad51d3 | |||
| a28b9b4561 | |||
| 8c773da137 | |||
| d5e3d3946e | |||
| beed8451d4 | |||
| 273d62ed80 | |||
| a5136d2bfa | |||
| 460839458c | |||
| 27c45792b8 | |||
| 568717bb53 | |||
| 5ed9e765a5 | |||
| cfc29cb175 | |||
| 82f011c0b0 | |||
| f39db8a15e | |||
| 95386c5de3 | |||
| a400de4a8b | |||
| bceb8f6a4f | |||
| 725540a95b | |||
| 2f6a40279d | |||
| 7ab171b646 | |||
| 23b78104e8 | |||
| 68b98665b2 | |||
| 881663a4fe | |||
| 7ca7aa0666 | |||
| 539e6150b0 | |||
| df618af435 | |||
| ec324558a0 | |||
| 40c38d7a18 | |||
| c853cc1c6f | |||
| 5df06ea168 | |||
| c80a25bd24 | |||
| 2a043d0042 | |||
| 56f7695c71 | |||
| 430825d72e | |||
| 7f497fcee0 | |||
| d56fe3a276 | |||
| dd3bcf6868 | |||
| 9cb1135a12 | |||
| cfde2cebf4 | |||
| 086441f6f5 | |||
| 41fa8b9979 | |||
| 0f301e5788 | |||
| 08cf44991d | |||
| 64bfc9f851 | |||
| 21f7eff384 | |||
| 019e7ade01 | |||
| 7429ba4536 | |||
| 30bb6a131c | |||
| 9ab4199bd5 | |||
| aebd4ca630 | |||
| 86edf0ba66 | |||
| 9eb9476270 | |||
| a4ddef2cf7 | |||
| 52cd808e0a | |||
| 4cef0bdf5b | |||
| 355b459dc9 | |||
| 3319a5d91a | |||
| bbd702faf1 | |||
| 9065bf5e54 | |||
| b77389012e | |||
| 2f734b41ae | |||
| 2ff6526834 | |||
| 710031b2a3 | |||
| f02a75aed4 | |||
| ed155c163c | |||
| 03871dcdd8 | |||
| d86a34fa73 | |||
| bbd38d4aa6 | |||
| 17f60808a7 | |||
| 916e42c33c | |||
| 5345bd0517 | |||
| 489ac04574 | |||
| 0061dc046b | |||
| 4612144880 | |||
| c33919bc67 | |||
| 8314e54439 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Admin CLI
|
||||
|
||||
Operator tools for accounts on the shared `recflare` D1 database, exposed as an
|
||||
`admin` command group on the repo's `runx` CLI. Each command shells out to
|
||||
`wrangler d1 execute recflare` — no running worker or auth token needed.
|
||||
|
||||
Run from anywhere in the repo:
|
||||
|
||||
```sh
|
||||
bun runx admin <command> [options]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `set-password` — set (or replace) an account's login password
|
||||
|
||||
```sh
|
||||
bun runx admin set-password --account 1
|
||||
bun runx admin set-password --username alice --remote
|
||||
```
|
||||
|
||||
The new password is taken from `--password <pw>`, else from piped stdin, else
|
||||
prompted interactively.
|
||||
|
||||
```sh
|
||||
# interactive (prompts, hidden)
|
||||
bun runx admin set-password --account 1
|
||||
|
||||
# non-interactive / scripted
|
||||
echo "s3cret-pw" | bun runx admin set-password --account 1
|
||||
bun runx admin set-password --account 1 --password "s3cret-pw"
|
||||
```
|
||||
|
||||
### `clear-password` — remove an account's password
|
||||
|
||||
Leaves the account with no login credential (only platform login).
|
||||
|
||||
```sh
|
||||
bun runx admin clear-password --username alice
|
||||
```
|
||||
|
||||
### `grant-developer` / `grant-moderator` — grant or revoke a role
|
||||
|
||||
Both are off by default; only these commands set them. A granted role backs its
|
||||
`GET /role/<role>/:id` lookup **and** rides in the login token's `role` claim, so it
|
||||
takes effect on the account's next login or token refresh.
|
||||
|
||||
```sh
|
||||
bun runx admin grant-developer --account 1
|
||||
bun runx admin grant-developer --account 1 --revoke
|
||||
bun runx admin grant-moderator --username alice --remote
|
||||
```
|
||||
|
||||
### `lookup` — print an account
|
||||
|
||||
```sh
|
||||
bun runx admin lookup --account 1
|
||||
bun runx admin lookup --username alice
|
||||
```
|
||||
|
||||
Prints id, username, platform, platform id, created/last-login times, and whether
|
||||
the account has a password, the developer role, and the moderator role.
|
||||
|
||||
## Options
|
||||
|
||||
### Selecting an account
|
||||
|
||||
Every command targets exactly one account, by **either**:
|
||||
|
||||
- `--account <id>` — numeric account id
|
||||
- `--username <name>` — username (case-insensitive)
|
||||
|
||||
### Choosing the database
|
||||
|
||||
- `--local` — the local dev database (**the default**)
|
||||
- `--remote` — the deployed (production) database
|
||||
|
||||
Passing both is an error. `--remote` requires `RECFLARE_D1` in the gitignored root
|
||||
`.env` (see `.env.example`) and a wrangler login with access to the account.
|
||||
|
||||
## Notes
|
||||
|
||||
- Password hashing matches the auth worker exactly (PBKDF2-SHA256), so a password
|
||||
set here verifies at login.
|
||||
- A command that matches no account exits non-zero with `no account found for …`.
|
||||
- Local writes target `apps/auth`'s dev D1 state; run `bun turbo -F auth migrate -- --local`
|
||||
first if the local database hasn't been migrated yet.
|
||||
+84
-6
@@ -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
|
||||
|
||||
@@ -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')]
|
||||
@@ -108,3 +119,11 @@ update *args:
|
||||
[positional-arguments]
|
||||
runx *args:
|
||||
bun runx "$@"
|
||||
|
||||
# Admin account tools (set-password, clear-password, grant-developer, lookup).
|
||||
# Run `just admin --help` for usage and examples. See CLI.md.
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
admin *args:
|
||||
bun runx admin "$@"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||
|
||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 caller’s 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 caller’s 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 player’s 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 don’t 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 caller’s 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 account’s privacy settings',
|
||||
description: [
|
||||
'Nothing stores per-player privacy yet; the id is echoed and recent history is',
|
||||
'reported visible (a bare `{}` fails the client’s 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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
|
||||
-- tables in this shared database), a report is genuinely columnar, so it gets a
|
||||
-- normal relational table. Owned by the `api` worker; generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per submitted report; nothing updates or dedupes them, so the table is
|
||||
-- an append-only log of what players sent. `reporter_player_id` comes from the
|
||||
-- caller's bearer token, everything else from the form body.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
|
||||
-- reports are what players submit, warnings are what a moderator hands down. Also
|
||||
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
|
||||
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
|
||||
-- token (the endpoint is gated on the `moderator` role); everything else comes
|
||||
-- from the form body. `display_reason` is what the warned player is shown,
|
||||
-- `moderator_note` is internal.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Player-event storage (scheduled events: a room, a window of time, and the
|
||||
-- settings the event runs under). Like the image/invention/rooms/accounts tables
|
||||
-- in this shared database, an event is a single JSON blob in the `data` column,
|
||||
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
|
||||
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
|
||||
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
|
||||
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
|
||||
-- browse query filters finished events in SQL on that.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-event RSVPs: one row per player per event, recording how they answered
|
||||
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
|
||||
-- one is genuinely columnar — like the relationship/report tables — so it's a
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
-- An event's creator gets a Going row at create time — that's why a fresh event's
|
||||
-- AttendeeCount is 1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Break the two visibility flags out of the invention JSON blob into queryable
|
||||
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
|
||||
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
|
||||
-- the per-room list ask for "published and not hidden" — so they move together.
|
||||
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
|
||||
-- would need rewriting existing rows), which is what we want anyway: the value stays
|
||||
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
|
||||
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
|
||||
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
|
||||
-- replaces did. This is a rename, not a behaviour change.
|
||||
--
|
||||
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
|
||||
-- invention is published and not hidden), so an index on them would be read past rather
|
||||
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
|
||||
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
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 the cross-worker RPC stub is fully typed.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||
@@ -18,6 +21,12 @@ 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>
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
/**
|
||||
* Default avatar items for `GET /api/avatar/v4/items`.
|
||||
* Stored as `[AvatarItemDesc, FriendlyName, Rarity?]`
|
||||
* tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`,
|
||||
* and `Rarity` defaults to `0`.
|
||||
*/
|
||||
export interface AvatarItem {
|
||||
AvatarItemType: number
|
||||
AvatarItemDesc: string
|
||||
PlatformMask: number
|
||||
FriendlyName: string
|
||||
Tooltip: string
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
type Entry = readonly [desc: string, friendlyName: string, rarity?: number]
|
||||
|
||||
const ENTRIES: readonly Entry[] = [
|
||||
['5d13a7a2-8213-40e6-90a6-efdd76a3fdcb,,,', 'Flowing Hair'],
|
||||
['1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,', 'Afro Hair'],
|
||||
['d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,', 'Large Afro Hair'],
|
||||
['e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,', 'Long Wavy Hair'],
|
||||
['7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,', 'Short Wavy Hair'],
|
||||
['eb9611c6-bb50-41a2-93e9-7f959815a846,,,', 'Dreads Long Hair'],
|
||||
['1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,', 'Ponytail Hair'],
|
||||
['a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,', 'Undercut Long Hair'],
|
||||
['0753d7a4-8247-4fca-a6fc-359c26086140,,,', 'Fonzie Hair'],
|
||||
['77d3c585-4928-4471-a425-89036efe7299,,,', 'Spiky Hair'],
|
||||
['92302d9d-c527-418c-ac5d-1fa869727505,,,', 'Part Hair'],
|
||||
['f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,', 'Undercut Short Hair'],
|
||||
['b148cb1e-df81-442f-aea6-ab1727aad00e,,,', 'Chunky Afro Hair'],
|
||||
['e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,', 'Bob with Bangs Hair'],
|
||||
['880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,', 'Bob Hair'],
|
||||
['21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,', 'Lori Hair'],
|
||||
['193a3bf9-abc0-4d78-8d63-92046908b1c5,,,', 'Emo Hair'],
|
||||
['79b90274-6eec-4664-acfb-4a123334661e,,,', 'Pig Tails Hair'],
|
||||
['da4e7b34-2095-4a9e-801e-4f409039e0dd,,,', 'Buzz Cut Hair'],
|
||||
['9d9fadb6-97eb-480e-a224-4e0179082071,,,', 'Meatball Buns Hair'],
|
||||
['d8280c0c-d803-4513-be10-a0ba96d8821e,,,', 'Flowhawk Hair'],
|
||||
['e286863c-2967-4d00-b837-b49487b9484a,,,', 'Fauxhawk Hair'],
|
||||
['2cb4f372-3372-4583-8b57-c4e3988e3c28,,,', 'Punky Hair'],
|
||||
['06306723-ca20-4aa6-b7b3-917113f41ac3,,,', 'Cat-Eye Glasses (Red)'],
|
||||
['c70005d5-6276-4a98-acb3-6a77bc19379a,,,', 'Glasses (Teal)'],
|
||||
['8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,', 'Beard (Close)'],
|
||||
['cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,', 'Beard (Thick)'],
|
||||
['c6c08eb5-381a-4193-9722-80da95d62abe,,,', 'Business Tie (Black)'],
|
||||
['4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,', 'Rec Room Tank Top (Orange)'],
|
||||
['d0a9262f-5504-46a7-bb10-7507503db58e,,,', 'Rec Room Shirt (Crew Neck, White)'],
|
||||
[
|
||||
'd0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,',
|
||||
'Rec Room T-Shirt (Crew Neck, Orange)',
|
||||
],
|
||||
['2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,', 'Collared Shirt (Plaid, Blue)'],
|
||||
['7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,', 'T-Shirt'],
|
||||
['8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,', 'Rec Room Shirt (V-Neck, White)'],
|
||||
[
|
||||
'8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,',
|
||||
'Rec Room T-Shirt (V-Neck, Orange)',
|
||||
],
|
||||
['21caa68e-c3fa-474c-af5e-af1e742b7a60,,,', 'Tennis Skirt (Blue)'],
|
||||
[
|
||||
'21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,',
|
||||
'Tennis Skirt (Yellow)',
|
||||
],
|
||||
[
|
||||
'21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,',
|
||||
'Tennis Skirt (Red)',
|
||||
],
|
||||
['2296ed0d-df56-4d46-b33a-aae9230a47fc,,,', 'Zipper Dress (Yellow)'],
|
||||
['ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,', 'Wristbands (White)'],
|
||||
['71921831-ba6f-408b-a00e-2fd97663636f,,,', 'Wrist Tape (White)'],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,',
|
||||
'Collared Shirt (Denim)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,',
|
||||
'Collared Shirt (Argyle)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,',
|
||||
'Collared Shirt (Flowers, Green)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,',
|
||||
'Tank Top (Camo)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,',
|
||||
'Tank Top (Rainbow)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,d2a692e6-e1a9-4cfe-8154-10b52be7f8c8,',
|
||||
'Jersey (Orange)',
|
||||
10,
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,',
|
||||
'Tank Top (Dots)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,',
|
||||
'Tank Top (Zebra)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Red)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Cream)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Black)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Orange, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Pink, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Blue, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,',
|
||||
'Headband (Red, White, Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Black)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Orange)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Pink)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Red)',
|
||||
],
|
||||
['fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,', 'Top Bun Hair'],
|
||||
['de0ac50d-2adb-4114-bd2e-68953b13d706,,,', 'Blazer (Blue, White)'],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Black, White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Grey, Black)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Grey, White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Green, Black)',
|
||||
],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Orange)',
|
||||
],
|
||||
['9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,', 'Button Top (Pink)'],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Tan)',
|
||||
],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Yellow)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Blue)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Green)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Purple)',
|
||||
],
|
||||
['6d815b35-6f68-4ed4-817d-70f141e1a571,,,', 'Collared Dress (Red)'],
|
||||
['241506f6-bf88-4b46-b5fe-513a225421f4,,,', 'Half Up Hair'],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (Blue)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (Green)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (Purple)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (White)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Light Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Green)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Gold)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Black)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Blue)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Orange)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Red)',
|
||||
],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (Purple)',
|
||||
],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Green)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Red)',
|
||||
],
|
||||
['40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,', 'Headband (White)'],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Yellow)',
|
||||
],
|
||||
['24a240f4-1574-420b-b898-a7e91f170759,,,', 'Back Bun Hair'],
|
||||
['c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,', 'High Pony Hair'],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Light Blue)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Black)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Orange)',
|
||||
],
|
||||
['14ef6b00-debf-4a85-9755-b4d37df496d3,,,', 'Baseball Cap (White)'],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Yellow)',
|
||||
],
|
||||
['896c2491-2f96-4986-9cbd-b3b31ef5d8c5,,,', 'Equestrian Coat (Black)'],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,55901f12-d5b5-4fa8-b4c8-e479689ee39d,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Blue)',
|
||||
],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,4828b50c-95b6-466a-bb25-514891d78202,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Grey)',
|
||||
],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,d6823e01-69f0-4f85-b94a-74894356a2cf,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Maroon)',
|
||||
],
|
||||
['09177621-9ecd-4f6a-b6a5-64490139141d,,,', 'Flat Top Hair'],
|
||||
['95ab7a7c-c35d-4da5-9955-0921064470b6,,,', 'Gekko Hair'],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Grey)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Light Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Orange)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Pink)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Red, White)',
|
||||
],
|
||||
['62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,', 'Slim Blazer (Teal)'],
|
||||
[
|
||||
'62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,',
|
||||
'Slim Blazer (Green)',
|
||||
],
|
||||
[
|
||||
'62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,',
|
||||
'Slim Blazer (Blue)',
|
||||
],
|
||||
['8b9f1413-e786-4a30-946c-9292f207875a,,,', 'Pulp Hair'],
|
||||
['5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,', 'Rec Room Sash'],
|
||||
['1a71064b-794f-40fa-9109-8ad36602b6e1,,,', 'Shagg Hair'],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Blue)',
|
||||
],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Green)',
|
||||
],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Orange)',
|
||||
],
|
||||
['84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,', 'Stoll Dress (Pink)'],
|
||||
[
|
||||
'71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wrist Tape (Black)',
|
||||
],
|
||||
[
|
||||
'71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wrist Tape (Cream)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Grey)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Cream)',
|
||||
],
|
||||
['6b9e022c-0b68-48fd-8eca-da8573c18900,,,', 'Long Scarf (Red)'],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Blue)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Yellow)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Pink)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Red)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Black, Cream)',
|
||||
],
|
||||
['0088603e-ec3b-4478-8694-e6fb1989b3f2,,,', 'Angled Bob Hair'],
|
||||
['ffea7a65-613f-4835-921e-6dd15f357b7e,,,', 'Long Bangs Hair'],
|
||||
['45f5e714-8a5f-4385-a97f-675066167011,,,', 'Seventies Stache'],
|
||||
['9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,', 'Thick Goatee'],
|
||||
['a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,', 'Bald Top Hair'],
|
||||
['CTcrvbo3OEepIV4oW8bx4w,,,', 'Receding Hair'],
|
||||
['-twtjyBdQ02EAdOfBGTiEw,,,', 'Van Dyke Beard'],
|
||||
['45eaab67-19c2-4601-8f80-3565a4dceba4,,,', 'Pompadour Hair'],
|
||||
['c855dcc3-96cb-470d-b159-d37a025a47d1,,,', 'Dutch Braid Hair'],
|
||||
['d7730a9e-78a1-4356-bc09-6b066615850b,,,', 'Afro Updo Hair'],
|
||||
['8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,', 'Pencil Bun Hair'],
|
||||
['5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,', 'Cornrows Hair'],
|
||||
[
|
||||
'b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,',
|
||||
'Rec Room Hoodie - Pride (Rainbow Pride)',
|
||||
],
|
||||
['fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,', 'Winged Hat - Pride (Rainbow Pride)'],
|
||||
[
|
||||
'b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,',
|
||||
'Rec Room Hoodie - Pride (Trans Pride)',
|
||||
],
|
||||
[
|
||||
'fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,',
|
||||
'Winged Hat - Pride (Trans Pride)',
|
||||
],
|
||||
['88b6ddeb-a455-460d-91d9-a4569ef6903c,,,', 'Square Earrings '],
|
||||
['0abb6b08-20ce-444f-879e-0d1344df096c,,,', 'Round Earrings'],
|
||||
['9b5bde11-7408-4798-9fcb-c7ec175444df,,,', 'Hoop Earrings'],
|
||||
]
|
||||
|
||||
export const DEFAULT_AVATAR_ITEMS: readonly AvatarItem[] = ENTRIES.map(
|
||||
([AvatarItemDesc, FriendlyName, rarity]) => ({
|
||||
AvatarItemType: 0,
|
||||
AvatarItemDesc,
|
||||
PlatformMask: -1,
|
||||
FriendlyName,
|
||||
Tooltip: '',
|
||||
Rarity: rarity ?? 0,
|
||||
})
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 caller‘s own flag'),
|
||||
Ignored: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
Muted: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
|
||||
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
|
||||
* taken from the bearer token.
|
||||
*/
|
||||
export const SendMessageRequest = z.object({
|
||||
ToPlayerId: z.string().describe('Account id of the recipient'),
|
||||
Type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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 worker‘s 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 don‘t 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('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
|
||||
imageName: z.string().optional(),
|
||||
instantiationCost: z.int().optional(),
|
||||
lightsCost: z.int().optional(),
|
||||
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 doesn’t pin a subroom'),
|
||||
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
|
||||
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
|
||||
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
|
||||
State: z.int().describe('0 = scheduled'),
|
||||
Accessibility: z.int(),
|
||||
IsMultiInstance: z.boolean(),
|
||||
SupportMultiInstanceRoomChat: z.boolean(),
|
||||
DefaultBroadcastPermissions: z.int(),
|
||||
CanRequestBroadcastPermissions: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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 event’s 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 player’s first response'
|
||||
),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t 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 Can’t 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 RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
|
||||
'entry shape has not been observed yet'
|
||||
),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
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('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
|
||||
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
|
||||
RoomInstanceType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
* moderator is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateWarningRequest = z.object({
|
||||
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
|
||||
DisplayReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
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(),
|
||||
})
|
||||
@@ -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>()
|
||||
}
|
||||
+720
-227
@@ -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,207 +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`)
|
||||
// live in the `econ` worker, which the client calls on the econ host — not here.
|
||||
// Only the gift generate/consume actions remain on this worker.
|
||||
// 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` worker’s 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,
|
||||
})
|
||||
})
|
||||
.post('/api/avatar/v2/gifts/consume', 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 giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||||
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
|
||||
// No DB → gift can never be found.
|
||||
return c.json({ success: false, error: 'Gift not found' }, 404)
|
||||
})
|
||||
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)
|
||||
)
|
||||
|
||||
// The featured custom-avatar-item feed. No curated items yet → an empty list.
|
||||
.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',
|
||||
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([])
|
||||
)
|
||||
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
|
||||
|
||||
// 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 creator’s 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` worker’s `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 ' +
|
||||
'caller’s. An invention is theirs if they created it or acquired it; an id with no ' +
|
||||
'invention behind it is not owned. Price and permission don’t 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 room’s inventions',
|
||||
description: 'Published inventions created in that room, newest first.',
|
||||
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The room’s 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 caller’s 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 invention’s 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', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||
stringQuery(
|
||||
'permission',
|
||||
'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 caller’s 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 caller’s 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 invention’s 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 caller’s 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
|
||||
@@ -255,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 invention’s tags',
|
||||
description:
|
||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||
'only.\n\n' +
|
||||
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
|
||||
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
|
||||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||
responses: {
|
||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s 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 invention’s 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 invention’s 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 screen’s 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 caller’s 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 caller’s 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` —
|
||||
@@ -325,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 invention’s metadata. The data file itself is uploaded separately ' +
|
||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||||
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
|
||||
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
|
||||
'characters (description).\n\n' +
|
||||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||
'until they call `v3/publish`.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Unparseable body, 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
@@ -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 client’s 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 client’s 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 client’s `?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({})
|
||||
)
|
||||
|
||||
@@ -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 caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. `Created` is served ' +
|
||||
'from the event table, soonest first.\n\n' +
|
||||
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
|
||||
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isn’t known ' +
|
||||
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
|
||||
'or the event plus the answer, which is the useful thing to render. Serving the ' +
|
||||
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
|
||||
'response is observed.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
// recent-activity data we don't keep, and the client renders no trending row for null.
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen — the event categories the ' +
|
||||
'client offers. Static: the same set regardless of what is stored. ' +
|
||||
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
|
||||
'and the client renders no trending row for null.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
|
||||
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
|
||||
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
|
||||
'means an empty shelf, not every event.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The clubs’ events') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. The cursor is always empty: a club’s event ' +
|
||||
'list is small enough to serve in one page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'The club’s events, in a single page') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const events = await getEventsByClubs(c.env.DB, [clubId])
|
||||
return c.json({ ContinuationToken: '', Events: events })
|
||||
}
|
||||
)
|
||||
|
||||
// Live player-event search (the "happening now" browse query) — events that have
|
||||
// started and not yet finished. A bare array, like the multi-club feed.
|
||||
.get(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Live player events',
|
||||
description:
|
||||
'The "happening now" row on the player-events browse screen: events that have ' +
|
||||
'started and not yet ended, soonest first. A bare array.',
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
|
||||
}),
|
||||
async (c) => c.json(await getLiveEvents(c.env.DB))
|
||||
)
|
||||
|
||||
// Event search — the browse query. Text is matched term by term against name and
|
||||
// description; finished events are left out (this backs a browse screen).
|
||||
.get(
|
||||
'/api/playerevents/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Search player events',
|
||||
description:
|
||||
'The browse query on the player-events screen, 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 Can’t go. Responding again replaces the previous answer; there is one row per ' +
|
||||
'player per event, and a decline is recorded rather than deleted so the client can ' +
|
||||
'show a player what they said.\n\n' +
|
||||
'Only Going counts toward the event’s `AttendeeCount`, which is recomputed from ' +
|
||||
'the RSVP table on every response. Anyone may respond, the creator included — ' +
|
||||
'they are already Going from create, and nothing stops them declining their own ' +
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
|
||||
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
const type = Number(body.Type)
|
||||
// Both are rejected rather than defaulted: an unrecognized answer stored as
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
|
||||
}
|
||||
)
|
||||
|
||||
// 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 row’s ' +
|
||||
'`reported_player_id` is the event’s 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 report’s 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 can’t render.\n\n' +
|
||||
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
|
||||
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
|
||||
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
|
||||
'the bare event the read endpoints serve.\n\n' +
|
||||
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
|
||||
'the event in its camelCase notification projection. A hub failure is logged and ' +
|
||||
'swallowed — the event is already stored by then.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The created event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event, 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 can’t blank out the ' +
|
||||
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
|
||||
'clear it.\n\n' +
|
||||
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
|
||||
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
|
||||
'an unknown event is 404. Answers the same envelope as create.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The updated event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||
const existing = await getEventById(c.env.DB, eventId)
|
||||
if (existing === null) return c.body(null, 404)
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(toEventResult(updated!))
|
||||
}
|
||||
)
|
||||
|
||||
// An event's guest list — every RSVP row, whatever the answer.
|
||||
.get(
|
||||
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'An event’s 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 creator’s Going row.',
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
responses: { 200: json(PlayerEventResponseDto.array(), 'The event’s 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 didn’t ask for details shouldn’t 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) })
|
||||
}
|
||||
)
|
||||
+144
-37
@@ -1,55 +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',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'An activity’s 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 room’s 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 client’s 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)
|
||||
)
|
||||
|
||||
+394
-131
@@ -1,27 +1,48 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
createImage,
|
||||
deleteImage,
|
||||
getCheeredImageIds,
|
||||
getImageByName,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
} from '../images-db'
|
||||
SavedImageType,
|
||||
setImageCheer,
|
||||
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'
|
||||
|
||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
||||
const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
||||
const typeFolder: Record<number, string> = {
|
||||
[SavedImageType.None]: 'none',
|
||||
@@ -34,147 +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 account’s ' +
|
||||
'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'
|
||||
// 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',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Delete one of the caller’s 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 caller’s image'),
|
||||
404: { description: 'No image by that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// 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' },
|
||||
})
|
||||
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)
|
||||
|
||||
// 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()
|
||||
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)
|
||||
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
// 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 })
|
||||
})
|
||||
)
|
||||
|
||||
// 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 room’s 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 room’s 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 player’s photos',
|
||||
description:
|
||||
'The public images that player has taken, newest first. Serves the client’s ' +
|
||||
'`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 player’s 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 player’s 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 player’s 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 player’s photo feed',
|
||||
description:
|
||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s 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 images (Accessibility
|
||||
// 0 or 1) across all rooms, newest first, each joined to its creator's username
|
||||
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
|
||||
// short (2-minute) cache hint the client refreshes against.
|
||||
.get('/api/images/v1/slideshow', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
})
|
||||
// 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.
|
||||
// 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 creator’s 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. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
// 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',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Cheer or un-cheer a photo',
|
||||
description:
|
||||
'Persists the caller’s cheer and resyncs the image’s `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 })
|
||||
}
|
||||
)
|
||||
|
||||
// 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',
|
||||
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) }))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 player’s 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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 service’s `{ success, error }` envelope, where `error` is an ' +
|
||||
'empty string rather than null. The rejected branch uses the same envelope so ' +
|
||||
'the client only ever parses one shape.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
|
||||
if (reportedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
|
||||
}
|
||||
|
||||
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
|
||||
const roomId = asInt(formField(body, c, 'RoomId'))
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: formField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(formField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
'/api/playerwarnings',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Issue a player warning',
|
||||
description:
|
||||
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
|
||||
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
|
||||
'yet.\n\n' +
|
||||
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
|
||||
'per account by the operator, see the admin CLI’s `grant-moderator` / ' +
|
||||
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
|
||||
'is the caller, NOT a body field.\n\n' +
|
||||
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
|
||||
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
|
||||
'internal and never surfaced to them.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
|
||||
'an empty string rather than null — including on the rejected branches, so there ' +
|
||||
'is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
|
||||
if (warnedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
await createWarning(c.env.DB, {
|
||||
moderatorPlayerId: moderatorId,
|
||||
warnedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
displayReason: formField(body, c, 'DisplayReason') ?? null,
|
||||
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
.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([])
|
||||
)
|
||||
|
||||
@@ -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 player’s reputation',
|
||||
description:
|
||||
'The cheer counters shown on a player’s 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 player’s reputation') },
|
||||
}),
|
||||
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get(
|
||||
'/api/players/v1/progression/:id',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s 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 client’s bar.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ProgressionDto, 'The player’s 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
@@ -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 caller’s 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 room’s 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 caller’s 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 room’s 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)
|
||||
})
|
||||
)
|
||||
|
||||
+577
-73
@@ -1,18 +1,142 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
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'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<Response> {
|
||||
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
||||
await notifyRelationship(c, playerId, rel)
|
||||
return c.json({ Success: true, Message: '' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the other player's id from a relationship-mutation request. The exact wire
|
||||
* shape is still TBD, so this is liberal: it accepts `playerId`/`id` as a query
|
||||
@@ -39,94 +163,474 @@ 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 caller’s 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 caller’s 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 caller’s favorited/ignored/muted flags.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RelationshipDto.array(), 'The caller’s 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` worker’s ' +
|
||||
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
|
||||
'account and it goes to one player. The hub queues it when the recipient is ' +
|
||||
'offline, so it arrives on their next connect.\n\n' +
|
||||
'Nothing is persisted here — there is no message store, the notification is the ' +
|
||||
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
|
||||
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
|
||||
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
|
||||
'empty.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
|
||||
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
|
||||
'with `success: false` — with no store behind it, a swallowed error would be a ' +
|
||||
'silently dropped message.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SendMessageRequest, 'The message'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromPlayerId = await authedId(c)
|
||||
if (fromPlayerId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
|
||||
if (Number.isNaN(toPlayerId)) {
|
||||
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
// The Message the notification carries. Mirrors the coach message's shape with
|
||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||
// drops only null/undefined from the frame).
|
||||
const 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 caller’s 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 send’s 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 (target arrives as `PlayerId` in the POST body).
|
||||
// These set a per-player flag on the *caller's* side of the relationship row,
|
||||
// creating a bare (None) row when the pair aren't otherwise related — so you can
|
||||
// ignore/mute someone you've never friended. Auth-gated. Returns the resulting
|
||||
// relationship from the caller's point of view.
|
||||
.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 c.json(await setRelationshipFlag(c.env.DB, id, target, 'ignored', true))
|
||||
})
|
||||
.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 c.json(await setRelationshipFlag(c.env.DB, id, target, 'muted', true))
|
||||
})
|
||||
// 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
|
||||
// *caller's* side of the relationship row, creating a bare (None) row when the
|
||||
// pair aren't otherwise related — so you can ignore/mute someone you've never
|
||||
// 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',
|
||||
flagToggle(
|
||||
'Ignore a player',
|
||||
'Sets the caller’s `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 caller’s `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 caller’s `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 caller’s `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.
|
||||
.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 c.json(await setRelationshipFlag(c.env.DB, 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 c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', false))
|
||||
})
|
||||
// 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',
|
||||
flagToggle(
|
||||
'Favorite a player',
|
||||
'Sets the caller’s `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 caller’s `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 caller’s 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([])
|
||||
)
|
||||
|
||||
@@ -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 { 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,28 +241,13 @@ 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`)
|
||||
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)
|
||||
expect(await res.json()).toEqual({
|
||||
PinnedFilters: [],
|
||||
PopularFilters: [],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
})
|
||||
|
||||
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: [] })
|
||||
const words = (await res.json()) as Array<{ Id: number; Difficulty: number; EN_US: string }>
|
||||
expect(Array.isArray(words)).toBe(true)
|
||||
expect(words.length).toBeGreaterThan(0)
|
||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
@@ -235,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 () => {
|
||||
@@ -265,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',
|
||||
@@ -295,6 +425,18 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -315,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 () => {
|
||||
@@ -388,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'
|
||||
@@ -420,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 caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
const save = async (sub: string, name: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const mine = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as SavedInvention[]
|
||||
}
|
||||
|
||||
const bought = await save('6100', 'bought-invention')
|
||||
const own = await save('6101', 'own-invention')
|
||||
await grantInvention(env.DB, 6101, bought.InventionId)
|
||||
|
||||
// Newest first, whichever set it came from: 6101 saved theirs after buying.
|
||||
const list = await mine('6101')
|
||||
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
|
||||
// A bought invention is still the creator's — it is listed, not re-attributed.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
|
||||
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
|
||||
|
||||
// The seller's own list is unaffected by the sale.
|
||||
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
|
||||
|
||||
// An ownership row pointing at an invention that no longer exists just drops out.
|
||||
await grantInvention(env.DB, 6101, 999_888)
|
||||
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
|
||||
own.InventionId,
|
||||
bought.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -451,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 3–24 characters of letters, digits, spaces, dashes and colons.
|
||||
expect((await save({ name: 'ab' })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
|
||||
expect((await save({ name: 'Rocket!' })).status).toBe(400)
|
||||
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
|
||||
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
|
||||
expect(ok.status).toBe(200)
|
||||
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
|
||||
|
||||
// The rejection carries the player-facing sentence, not a code.
|
||||
const short = await save({ name: 'ab' })
|
||||
expect((await short.json()) as { error: string }).toEqual({
|
||||
error: 'Invention names must be at least 3 characters.',
|
||||
})
|
||||
|
||||
// A description is prose: any characters, at most 512 of them.
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
|
||||
expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => {
|
||||
// The real client names an unnamed invention after the moment it was saved
|
||||
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
|
||||
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
|
||||
// game makes — this test is what would catch that.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -530,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)
|
||||
@@ -677,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 room’s published inventions', async () => {
|
||||
// Two inventions created in room 76, one of them still a draft.
|
||||
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
||||
@@ -729,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' },
|
||||
@@ -740,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`
|
||||
)
|
||||
@@ -749,6 +1067,7 @@ describe('public endpoints', () => {
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
BlobHash: await base64Sha256(data),
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
@@ -771,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',
|
||||
@@ -812,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)
|
||||
@@ -832,6 +1199,42 @@ describe('public endpoints', () => {
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/update takes the permission picker’s 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',
|
||||
@@ -923,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,
|
||||
@@ -966,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 () => {
|
||||
@@ -1010,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> => {
|
||||
@@ -1044,6 +1724,7 @@ describe('images', () => {
|
||||
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
|
||||
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
@@ -1053,8 +1734,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Keyed by <type>/<date>/<uuid>.<ext> (the type folder mirrors the CDN layout).
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
/^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
)
|
||||
|
||||
// The object is in the shared bucket under that key.
|
||||
@@ -1072,10 +1754,7 @@ describe('images', () => {
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
|
||||
// No token → 401.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
|
||||
|
||||
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
|
||||
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
@@ -1097,9 +1776,8 @@ describe('images', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
// No token — the slideshow is public.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Images: Array<Record<string, unknown>>
|
||||
@@ -1119,26 +1797,116 @@ describe('images', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
|
||||
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
|
||||
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
|
||||
// without the cap a single anonymous request could pull the whole image table through
|
||||
// the two joins behind it.
|
||||
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
|
||||
// 120 public ShareCamera photos — more than both the default and the cap.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
|
||||
}
|
||||
const feed = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { Images: unknown[] }).Images.length
|
||||
}
|
||||
|
||||
expect(await feed('')).toBe(10)
|
||||
expect(await feed('?take=25')).toBe(25)
|
||||
expect(await feed('?take=500')).toBe(100)
|
||||
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
|
||||
expect(await feed('?take=0')).toBe(10)
|
||||
expect(await feed('?take=-5')).toBe(10)
|
||||
expect(await feed('?take=lots')).toBe(10)
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||
// Seed an image to cheer.
|
||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
|
||||
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
body: cheerBody,
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
// With a token → accepted.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
|
||||
const cheer = async (cheerVal: boolean, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: cheerVal }),
|
||||
})
|
||||
const cheerCount = async (): Promise<number> => {
|
||||
const row = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
return (JSON.parse(row!.data) as { CheerCount: number }).CheerCount
|
||||
}
|
||||
|
||||
// Account 42 cheers → CheerCount syncs to 1 (a real integer, not 1.0).
|
||||
expect((await cheer(true)).status).toBe(200)
|
||||
const rawAfter = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
expect(rawAfter!.data).toContain('"CheerCount":1')
|
||||
expect(rawAfter!.data).not.toContain('"CheerCount":1.0')
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Re-cheering is idempotent on the count.
|
||||
await cheer(true)
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Un-cheer → count back to 0.
|
||||
await cheer(false)
|
||||
expect(await cheerCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 })
|
||||
const other = 999999
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}`)).status
|
||||
).toBe(401)
|
||||
|
||||
const bulk = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}&id=${other}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
).json()) as Array<{ SavedImageId: number; IsCheered: boolean }>
|
||||
|
||||
// Before cheering: one entry per requested id, in order, all false.
|
||||
expect(await bulk('42')).toEqual([
|
||||
{ SavedImageId: img.Id, IsCheered: false },
|
||||
{ SavedImageId: other, IsCheered: false },
|
||||
])
|
||||
|
||||
// Account 42 cheers the image.
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: true }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The cheerer sees it cheered; a different player does not.
|
||||
expect((await bulk('42')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(true)
|
||||
expect((await bulk('43')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(false)
|
||||
|
||||
// No ids → empty array.
|
||||
const empty = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
@@ -1200,8 +1968,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Type 4 → the `profile/` type folder, then <date>/<uuid>.<ext>.
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
/^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
)
|
||||
|
||||
// The account row now points its profileImage at the uploaded key.
|
||||
@@ -1211,6 +1980,62 @@ describe('images', () => {
|
||||
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
|
||||
})
|
||||
|
||||
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
|
||||
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
|
||||
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
Id: 8100,
|
||||
Type: 1,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName,
|
||||
Description: null,
|
||||
PlayerId: 42, // owned by the default bearer account
|
||||
TaggedPlayerIds: [],
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 1,
|
||||
CommentCount: 0,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO image_interaction (player_id, saved_image_id, cheered) VALUES (99, 8100, 1)'
|
||||
).run()
|
||||
|
||||
const del = (headers: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ ImageName }),
|
||||
})
|
||||
|
||||
// No token → 401; a different account → 403 (still present afterwards).
|
||||
expect((await del({})).status).toBe(401)
|
||||
expect((await del(await bearer('43'))).status).toBe(403)
|
||||
expect(await getImageByName(env.DB, ImageName)).not.toBeNull()
|
||||
|
||||
// Unknown image → 404.
|
||||
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
|
||||
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
|
||||
// Owner → 200, and the row, its cheers, and the R2 object are all gone.
|
||||
expect((await del(await bearer('42'))).status).toBe(200)
|
||||
expect(await getImageByName(env.DB, ImageName)).toBeNull()
|
||||
expect(await env.IMAGES.get(ImageName)).toBeNull()
|
||||
const cheers = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = 8100'
|
||||
).first<{ n: number }>()
|
||||
expect(cheers!.n).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
|
||||
@@ -1323,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(
|
||||
@@ -1371,6 +2220,60 @@ describe('relationships', () => {
|
||||
return (await res.json()) as Rel[]
|
||||
}
|
||||
|
||||
// Standard ack the flag endpoints (favorite/ignore/mute + inverses) now return —
|
||||
// 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) {
|
||||
return (await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `PlayerId=${playerId}`,
|
||||
})
|
||||
).json()) as { Success: boolean; Message: string }
|
||||
}
|
||||
|
||||
// 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,
|
||||
target_favorited, target_ignored, target_muted
|
||||
FROM relationship
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId)
|
||||
.first()) as Record<string, number> | null
|
||||
if (!row) return null
|
||||
const isRequester = row.requester_id === playerId
|
||||
return {
|
||||
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||
}
|
||||
}
|
||||
|
||||
test('GET /api/relationships/v2/get is auth-gated', async () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
|
||||
})
|
||||
@@ -1418,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 caller’s 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 () => {
|
||||
@@ -1450,64 +2370,57 @@ describe('relationships', () => {
|
||||
|
||||
test('v1 ignore/mute set the caller’s own side of the relationship', async () => {
|
||||
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
|
||||
// POST the real client shape: form body `PlayerId=<id>`.
|
||||
const flag = async (path: string, sub: string, playerId: number) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `PlayerId=${playerId}`,
|
||||
})
|
||||
).json()) as FullRel
|
||||
|
||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged.
|
||||
expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
RelationshipType: 0,
|
||||
Ignored: 1,
|
||||
Muted: 0,
|
||||
})
|
||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side
|
||||
// flagged. The response is now just the ack; the flag is verified on the row.
|
||||
expect(await ackFlag('/api/relationships/v1/ignore', '700', 701)).toEqual(ACK)
|
||||
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 0 })
|
||||
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
|
||||
expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
Ignored: 1,
|
||||
Muted: 1,
|
||||
})
|
||||
expect(await ackFlag('/api/relationships/v1/mute', '700', 701)).toEqual(ACK)
|
||||
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||
|
||||
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
|
||||
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
|
||||
expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({
|
||||
PlayerID: 710,
|
||||
RelationshipType: 2, // 711 sees 710's request as Received
|
||||
Ignored: 1,
|
||||
})
|
||||
expect(await ackFlag('/api/relationships/v1/ignore', '711', 710)).toEqual(ACK)
|
||||
// 711 sees 710's request as Received (2) with their own Ignored set.
|
||||
expect((await relationships('711')) as unknown as FullRel[]).toEqual([
|
||||
expect.objectContaining({ PlayerID: 710, RelationshipType: 2, Ignored: 1 }),
|
||||
])
|
||||
// 710's own side is untouched — the requester never ignored anyone.
|
||||
const view710 = (await relationships('710')) as unknown as FullRel[]
|
||||
expect(view710).toEqual([
|
||||
expect((await relationships('710')) as unknown as FullRel[]).toEqual([
|
||||
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
|
||||
])
|
||||
})
|
||||
|
||||
test('v1 unignore/unmute clear the caller’s own flags independently', async () => {
|
||||
// 800 ignores and mutes 801 (bare None row, both flags on the caller's side).
|
||||
await ackFlag('/api/relationships/v1/ignore', '800', 801)
|
||||
await ackFlag('/api/relationships/v1/mute', '800', 801)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||
// unignore clears only Ignored; the mute is left in place.
|
||||
expect(await ackFlag('/api/relationships/v1/unignore', '800', 801)).toEqual(ACK)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 1 })
|
||||
// unmute then clears Muted too.
|
||||
expect(await ackFlag('/api/relationships/v1/unmute', '800', 801)).toEqual(ACK)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 0 })
|
||||
})
|
||||
|
||||
test('v1 favorite/unfavorite toggle the caller’s own side, leaving the friendship intact', async () => {
|
||||
// 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`.
|
||||
await mutate('/api/relationships/v2/addfriend', '720', 721)
|
||||
expect(
|
||||
(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()) as Rel
|
||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 1 })
|
||||
|
||||
expect(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()).toEqual(ACK)
|
||||
// 720's own side is favorited; the friendship is intact.
|
||||
expect(await relationships('720')).toEqual([
|
||||
{ PlayerID: 721, RelationshipType: 3, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
// Favoriting is one-sided: 721 does not see themselves as having favorited 720.
|
||||
expect(await relationships('721')).toEqual([
|
||||
{ PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
|
||||
// Unfavorite clears the flag but keeps the friendship.
|
||||
expect(
|
||||
(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()) as Rel
|
||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 0 })
|
||||
expect(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()).toEqual(ACK)
|
||||
expect(await relationships('720')).toEqual([
|
||||
{ PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
@@ -1515,14 +2428,1483 @@ describe('relationships', () => {
|
||||
|
||||
test('favoriting a player you have no relationship with is allowed', async () => {
|
||||
// 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()) as Rel
|
||||
).toMatchObject({ PlayerID: 731, RelationshipType: 0, Favorited: 1 })
|
||||
// A None row is not reported as a relationship by v2/get.
|
||||
expect(await relationships('730')).toEqual([])
|
||||
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
|
||||
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
||||
// 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 () => {
|
||||
expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('a flag change pushes a RelationshipChanged notification with the relationship', async () => {
|
||||
// The relationship detail now rides a hub notification instead of the response.
|
||||
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
|
||||
await ackFlag('/api/relationships/v1/favorite', '750', 751)
|
||||
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
|
||||
const last = (await res.json()) as {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { PlayerID: number; Favorited: number; RelationshipType: number }
|
||||
}
|
||||
expect(last.playerId).toBe(750) // sent to the caller
|
||||
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 caller’s own events, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
|
||||
|
||||
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
Responses: unknown[]
|
||||
}
|
||||
const ids = mine.Created.map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(upcoming.PlayerEventId)
|
||||
// 43 created that one, not 42.
|
||||
expect(ids).not.toContain(liveEvent.PlayerEventId)
|
||||
// Finished events stay in the creator's own list — only the browse queries drop them.
|
||||
expect(ids).toContain(pastEvent.PlayerEventId)
|
||||
// Nothing records an RSVP yet.
|
||||
expect(mine.Responses).toEqual([])
|
||||
|
||||
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
}
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// The creator is Going from create, which is where the initial 1 comes from.
|
||||
expect(event.AttendeeCount).toBe(1)
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// 43 says Going → 2 attendees, and the envelope carries the updated event.
|
||||
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(2)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
|
||||
event_id: id,
|
||||
player_id: 43,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
// Changing the answer REPLACES it — one row per player, not a second RSVP.
|
||||
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
|
||||
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
|
||||
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
|
||||
|
||||
// Interested is a maybe — recorded, but not counted.
|
||||
await respond({ PlayerEventId: id, Type: 1 }, '43')
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// And the count sticks on the stored event, not just the response.
|
||||
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
|
||||
expect(fetched.AttendeeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6162 @@
|
||||
[
|
||||
{
|
||||
"Id": 1,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "David Bowie"
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Guitar"
|
||||
},
|
||||
{
|
||||
"Id": 3,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hipster"
|
||||
},
|
||||
{
|
||||
"Id": 4,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boxing"
|
||||
},
|
||||
{
|
||||
"Id": 5,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alien"
|
||||
},
|
||||
{
|
||||
"Id": 6,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Car Crash"
|
||||
},
|
||||
{
|
||||
"Id": 7,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pole Vault"
|
||||
},
|
||||
{
|
||||
"Id": 8,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spaghetti"
|
||||
},
|
||||
{
|
||||
"Id": 9,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Titanic"
|
||||
},
|
||||
{
|
||||
"Id": 10,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Video Game"
|
||||
},
|
||||
{
|
||||
"Id": 11,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moon Landing"
|
||||
},
|
||||
{
|
||||
"Id": 12,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Avatar"
|
||||
},
|
||||
{
|
||||
"Id": 13,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coal Miner"
|
||||
},
|
||||
{
|
||||
"Id": 14,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Castaway"
|
||||
},
|
||||
{
|
||||
"Id": 15,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "League of Legends"
|
||||
},
|
||||
{
|
||||
"Id": 16,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minecraft"
|
||||
},
|
||||
{
|
||||
"Id": 17,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pirate"
|
||||
},
|
||||
{
|
||||
"Id": 18,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Submarine"
|
||||
},
|
||||
{
|
||||
"Id": 19,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Luke Skywalker"
|
||||
},
|
||||
{
|
||||
"Id": 20,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Princess Leia"
|
||||
},
|
||||
{
|
||||
"Id": 21,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spiderman"
|
||||
},
|
||||
{
|
||||
"Id": 22,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Superman"
|
||||
},
|
||||
{
|
||||
"Id": 23,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wrestling"
|
||||
},
|
||||
{
|
||||
"Id": 24,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Campfire"
|
||||
},
|
||||
{
|
||||
"Id": 25,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Milking a Cow"
|
||||
},
|
||||
{
|
||||
"Id": 26,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mario"
|
||||
},
|
||||
{
|
||||
"Id": 27,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Wizard of Oz"
|
||||
},
|
||||
{
|
||||
"Id": 28,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walking Stick"
|
||||
},
|
||||
{
|
||||
"Id": 29,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Prison"
|
||||
},
|
||||
{
|
||||
"Id": 30,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cereal"
|
||||
},
|
||||
{
|
||||
"Id": 31,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shot Put"
|
||||
},
|
||||
{
|
||||
"Id": 32,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tsunami"
|
||||
},
|
||||
{
|
||||
"Id": 33,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bulldozer"
|
||||
},
|
||||
{
|
||||
"Id": 34,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Javelin Throw"
|
||||
},
|
||||
{
|
||||
"Id": 35,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Espresso"
|
||||
},
|
||||
{
|
||||
"Id": 36,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Aroma"
|
||||
},
|
||||
{
|
||||
"Id": 37,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Salt and Pepper"
|
||||
},
|
||||
{
|
||||
"Id": 38,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toast"
|
||||
},
|
||||
{
|
||||
"Id": 39,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ostrich"
|
||||
},
|
||||
{
|
||||
"Id": 40,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "False Teeth"
|
||||
},
|
||||
{
|
||||
"Id": 41,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lumber Jack"
|
||||
},
|
||||
{
|
||||
"Id": 42,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vacuum"
|
||||
},
|
||||
{
|
||||
"Id": 43,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Heavy"
|
||||
},
|
||||
{
|
||||
"Id": 44,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pet"
|
||||
},
|
||||
{
|
||||
"Id": 45,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fan"
|
||||
},
|
||||
{
|
||||
"Id": 46,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saxophone"
|
||||
},
|
||||
{
|
||||
"Id": 47,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antlers"
|
||||
},
|
||||
{
|
||||
"Id": 48,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jumping Jack "
|
||||
},
|
||||
{
|
||||
"Id": 49,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shark "
|
||||
},
|
||||
{
|
||||
"Id": 50,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alligator "
|
||||
},
|
||||
{
|
||||
"Id": 51,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chair "
|
||||
},
|
||||
{
|
||||
"Id": 52,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robot "
|
||||
},
|
||||
{
|
||||
"Id": 53,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scissors "
|
||||
},
|
||||
{
|
||||
"Id": 54,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheek "
|
||||
},
|
||||
{
|
||||
"Id": 55,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jump "
|
||||
},
|
||||
{
|
||||
"Id": 56,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Cream Cone "
|
||||
},
|
||||
{
|
||||
"Id": 57,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plane "
|
||||
},
|
||||
{
|
||||
"Id": 58,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basketball "
|
||||
},
|
||||
{
|
||||
"Id": 59,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camera "
|
||||
},
|
||||
{
|
||||
"Id": 60,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eat "
|
||||
},
|
||||
{
|
||||
"Id": 62,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blink "
|
||||
},
|
||||
{
|
||||
"Id": 63,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Balloon "
|
||||
},
|
||||
{
|
||||
"Id": 64,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Book"
|
||||
},
|
||||
{
|
||||
"Id": 65,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glasses "
|
||||
},
|
||||
{
|
||||
"Id": 66,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sneeze "
|
||||
},
|
||||
{
|
||||
"Id": 67,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouth "
|
||||
},
|
||||
{
|
||||
"Id": 68,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Football "
|
||||
},
|
||||
{
|
||||
"Id": 69,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Telephone "
|
||||
},
|
||||
{
|
||||
"Id": 70,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tail "
|
||||
},
|
||||
{
|
||||
"Id": 71,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turtle"
|
||||
},
|
||||
{
|
||||
"Id": 72,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baby "
|
||||
},
|
||||
{
|
||||
"Id": 73,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ear "
|
||||
},
|
||||
{
|
||||
"Id": 74,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monkey "
|
||||
},
|
||||
{
|
||||
"Id": 75,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hopscotch "
|
||||
},
|
||||
{
|
||||
"Id": 76,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ping Pong "
|
||||
},
|
||||
{
|
||||
"Id": 77,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowball "
|
||||
},
|
||||
{
|
||||
"Id": 78,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Roof "
|
||||
},
|
||||
{
|
||||
"Id": 79,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fly "
|
||||
},
|
||||
{
|
||||
"Id": 80,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fang "
|
||||
},
|
||||
{
|
||||
"Id": 81,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piano "
|
||||
},
|
||||
{
|
||||
"Id": 82,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lipstick "
|
||||
},
|
||||
{
|
||||
"Id": 83,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hula Hoop "
|
||||
},
|
||||
{
|
||||
"Id": 84,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Penguin "
|
||||
},
|
||||
{
|
||||
"Id": 85,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banana Peel "
|
||||
},
|
||||
{
|
||||
"Id": 86,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whisper "
|
||||
},
|
||||
{
|
||||
"Id": 87,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Popsicle "
|
||||
},
|
||||
{
|
||||
"Id": 88,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frankenstein "
|
||||
},
|
||||
{
|
||||
"Id": 89,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earthquake "
|
||||
},
|
||||
{
|
||||
"Id": 90,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Road "
|
||||
},
|
||||
{
|
||||
"Id": 91,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alarm Clock "
|
||||
},
|
||||
{
|
||||
"Id": 92,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dog Leash "
|
||||
},
|
||||
{
|
||||
"Id": 93,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chop "
|
||||
},
|
||||
{
|
||||
"Id": 94,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pajamas "
|
||||
},
|
||||
{
|
||||
"Id": 95,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slam Dunk "
|
||||
},
|
||||
{
|
||||
"Id": 96,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seashell "
|
||||
},
|
||||
{
|
||||
"Id": 97,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jog "
|
||||
},
|
||||
{
|
||||
"Id": 98,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seesaw "
|
||||
},
|
||||
{
|
||||
"Id": 99,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nap "
|
||||
},
|
||||
{
|
||||
"Id": 100,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheerleader "
|
||||
},
|
||||
{
|
||||
"Id": 101,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blind "
|
||||
},
|
||||
{
|
||||
"Id": 102,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beg "
|
||||
},
|
||||
{
|
||||
"Id": 103,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "CPR"
|
||||
},
|
||||
{
|
||||
"Id": 104,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shopping Cart "
|
||||
},
|
||||
{
|
||||
"Id": 105,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Limbo "
|
||||
},
|
||||
{
|
||||
"Id": 106,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Newspaper "
|
||||
},
|
||||
{
|
||||
"Id": 107,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Twist "
|
||||
},
|
||||
{
|
||||
"Id": 108,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rhinoceros "
|
||||
},
|
||||
{
|
||||
"Id": 109,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cow "
|
||||
},
|
||||
{
|
||||
"Id": 110,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tickle "
|
||||
},
|
||||
{
|
||||
"Id": 111,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fetch "
|
||||
},
|
||||
{
|
||||
"Id": 112,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Violin "
|
||||
},
|
||||
{
|
||||
"Id": 113,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cage "
|
||||
},
|
||||
{
|
||||
"Id": 114,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cello "
|
||||
},
|
||||
{
|
||||
"Id": 115,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Braid "
|
||||
},
|
||||
{
|
||||
"Id": 116,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trumpet "
|
||||
},
|
||||
{
|
||||
"Id": 117,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mop "
|
||||
},
|
||||
{
|
||||
"Id": 118,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shovel"
|
||||
},
|
||||
{
|
||||
"Id": 119,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cash "
|
||||
},
|
||||
{
|
||||
"Id": 120,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soap "
|
||||
},
|
||||
{
|
||||
"Id": 121,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wink "
|
||||
},
|
||||
{
|
||||
"Id": 122,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree "
|
||||
},
|
||||
{
|
||||
"Id": 124,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Windmill "
|
||||
},
|
||||
{
|
||||
"Id": 125,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Party "
|
||||
},
|
||||
{
|
||||
"Id": 126,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lawn Mower "
|
||||
},
|
||||
{
|
||||
"Id": 127,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wig "
|
||||
},
|
||||
{
|
||||
"Id": 128,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Handcuffs "
|
||||
},
|
||||
{
|
||||
"Id": 129,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunburn "
|
||||
},
|
||||
{
|
||||
"Id": 130,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hair Dryer "
|
||||
},
|
||||
{
|
||||
"Id": 131,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shadow "
|
||||
},
|
||||
{
|
||||
"Id": 132,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sip "
|
||||
},
|
||||
{
|
||||
"Id": 133,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bathroom "
|
||||
},
|
||||
{
|
||||
"Id": 134,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scale "
|
||||
},
|
||||
{
|
||||
"Id": 135,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cowboy "
|
||||
},
|
||||
{
|
||||
"Id": 136,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doghouse "
|
||||
},
|
||||
{
|
||||
"Id": 137,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knot "
|
||||
},
|
||||
{
|
||||
"Id": 138,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Owl "
|
||||
},
|
||||
{
|
||||
"Id": 139,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wrench "
|
||||
},
|
||||
{
|
||||
"Id": 140,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Story "
|
||||
},
|
||||
{
|
||||
"Id": 141,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whisk "
|
||||
},
|
||||
{
|
||||
"Id": 142,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spine "
|
||||
},
|
||||
{
|
||||
"Id": 143,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Artist "
|
||||
},
|
||||
{
|
||||
"Id": 144,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gingerbread Man "
|
||||
},
|
||||
{
|
||||
"Id": 145,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spider Web "
|
||||
},
|
||||
{
|
||||
"Id": 146,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lung "
|
||||
},
|
||||
{
|
||||
"Id": 147,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Washing Machine "
|
||||
},
|
||||
{
|
||||
"Id": 148,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Torch "
|
||||
},
|
||||
{
|
||||
"Id": 149,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eraser "
|
||||
},
|
||||
{
|
||||
"Id": 150,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouse Trap "
|
||||
},
|
||||
{
|
||||
"Id": 151,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swordfish "
|
||||
},
|
||||
{
|
||||
"Id": 152,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flamingo "
|
||||
},
|
||||
{
|
||||
"Id": 153,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cast "
|
||||
},
|
||||
{
|
||||
"Id": 154,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Honk "
|
||||
},
|
||||
{
|
||||
"Id": 155,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mirror "
|
||||
},
|
||||
{
|
||||
"Id": 156,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Volcano"
|
||||
},
|
||||
{
|
||||
"Id": 157,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flag "
|
||||
},
|
||||
{
|
||||
"Id": 158,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shipwreck "
|
||||
},
|
||||
{
|
||||
"Id": 159,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Olympics "
|
||||
},
|
||||
{
|
||||
"Id": 160,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Satellite "
|
||||
},
|
||||
{
|
||||
"Id": 161,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Evolution "
|
||||
},
|
||||
{
|
||||
"Id": 162,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pendulum"
|
||||
},
|
||||
{
|
||||
"Id": 163,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Mighty Ducks "
|
||||
},
|
||||
{
|
||||
"Id": 164,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Indiana Jones "
|
||||
},
|
||||
{
|
||||
"Id": 165,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angels in the Outfield "
|
||||
},
|
||||
{
|
||||
"Id": 166,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "E.T. "
|
||||
},
|
||||
{
|
||||
"Id": 167,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dumbo "
|
||||
},
|
||||
{
|
||||
"Id": 168,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Batman "
|
||||
},
|
||||
{
|
||||
"Id": 169,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mary Poppins "
|
||||
},
|
||||
{
|
||||
"Id": 170,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star Wars "
|
||||
},
|
||||
{
|
||||
"Id": 171,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "James Bond "
|
||||
},
|
||||
{
|
||||
"Id": 172,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Lion King"
|
||||
},
|
||||
{
|
||||
"Id": 173,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Matrix"
|
||||
},
|
||||
{
|
||||
"Id": 174,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Godzilla"
|
||||
},
|
||||
{
|
||||
"Id": 175,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Golf"
|
||||
},
|
||||
{
|
||||
"Id": 176,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Headphones"
|
||||
},
|
||||
{
|
||||
"Id": 177,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Necktie"
|
||||
},
|
||||
{
|
||||
"Id": 178,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintbrush"
|
||||
},
|
||||
{
|
||||
"Id": 179,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mime"
|
||||
},
|
||||
{
|
||||
"Id": 180,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rubber Ducky "
|
||||
},
|
||||
{
|
||||
"Id": 181,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yolk "
|
||||
},
|
||||
{
|
||||
"Id": 182,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Exercise "
|
||||
},
|
||||
{
|
||||
"Id": 183,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dizzy "
|
||||
},
|
||||
{
|
||||
"Id": 184,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barber "
|
||||
},
|
||||
{
|
||||
"Id": 185,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jazz "
|
||||
},
|
||||
{
|
||||
"Id": 186,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Download "
|
||||
},
|
||||
{
|
||||
"Id": 187,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ski Goggles "
|
||||
},
|
||||
{
|
||||
"Id": 188,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Oar "
|
||||
},
|
||||
{
|
||||
"Id": 189,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shampoo "
|
||||
},
|
||||
{
|
||||
"Id": 190,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Think "
|
||||
},
|
||||
{
|
||||
"Id": 191,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Darts "
|
||||
},
|
||||
{
|
||||
"Id": 192,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shower Curtain "
|
||||
},
|
||||
{
|
||||
"Id": 193,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Extension Cord "
|
||||
},
|
||||
{
|
||||
"Id": 194,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Birthday "
|
||||
},
|
||||
{
|
||||
"Id": 195,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pocket "
|
||||
},
|
||||
{
|
||||
"Id": 196,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zipper "
|
||||
},
|
||||
{
|
||||
"Id": 197,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fiddle "
|
||||
},
|
||||
{
|
||||
"Id": 198,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pail "
|
||||
},
|
||||
{
|
||||
"Id": 199,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baguette "
|
||||
},
|
||||
{
|
||||
"Id": 200,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rib "
|
||||
},
|
||||
{
|
||||
"Id": 201,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sushi "
|
||||
},
|
||||
{
|
||||
"Id": 202,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ceiling Fan "
|
||||
},
|
||||
{
|
||||
"Id": 204,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disco"
|
||||
},
|
||||
{
|
||||
"Id": 205,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Castle"
|
||||
},
|
||||
{
|
||||
"Id": 206,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Post Office "
|
||||
},
|
||||
{
|
||||
"Id": 207,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chess "
|
||||
},
|
||||
{
|
||||
"Id": 208,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Puppet "
|
||||
},
|
||||
{
|
||||
"Id": 209,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kangaroo"
|
||||
},
|
||||
{
|
||||
"Id": 210,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dentist"
|
||||
},
|
||||
{
|
||||
"Id": 211,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bear Trap"
|
||||
},
|
||||
{
|
||||
"Id": 212,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cabin "
|
||||
},
|
||||
{
|
||||
"Id": 213,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cruise Ship "
|
||||
},
|
||||
{
|
||||
"Id": 214,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saturn"
|
||||
},
|
||||
{
|
||||
"Id": 215,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "N64"
|
||||
},
|
||||
{
|
||||
"Id": 216,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dream "
|
||||
},
|
||||
{
|
||||
"Id": 217,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Recycle "
|
||||
},
|
||||
{
|
||||
"Id": 218,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Raft "
|
||||
},
|
||||
{
|
||||
"Id": 219,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gold "
|
||||
},
|
||||
{
|
||||
"Id": 220,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plank "
|
||||
},
|
||||
{
|
||||
"Id": 221,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cliff "
|
||||
},
|
||||
{
|
||||
"Id": 222,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cape"
|
||||
},
|
||||
{
|
||||
"Id": 223,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Safe"
|
||||
},
|
||||
{
|
||||
"Id": 224,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Picnic "
|
||||
},
|
||||
{
|
||||
"Id": 225,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boa Constrictor "
|
||||
},
|
||||
{
|
||||
"Id": 227,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hurdle "
|
||||
},
|
||||
{
|
||||
"Id": 229,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Loveseat "
|
||||
},
|
||||
{
|
||||
"Id": 230,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Harry Potter"
|
||||
},
|
||||
{
|
||||
"Id": 231,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Cool Runnings"
|
||||
},
|
||||
{
|
||||
"Id": 232,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bobsled "
|
||||
},
|
||||
{
|
||||
"Id": 233,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hot Tub "
|
||||
},
|
||||
{
|
||||
"Id": 234,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Firefighter"
|
||||
},
|
||||
{
|
||||
"Id": 235,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cell Phone"
|
||||
},
|
||||
{
|
||||
"Id": 236,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coach"
|
||||
},
|
||||
{
|
||||
"Id": 237,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moth"
|
||||
},
|
||||
{
|
||||
"Id": 238,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wooly Mammoth "
|
||||
},
|
||||
{
|
||||
"Id": 239,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bonfire"
|
||||
},
|
||||
{
|
||||
"Id": 240,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Laptop"
|
||||
},
|
||||
{
|
||||
"Id": 241,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bicycle"
|
||||
},
|
||||
{
|
||||
"Id": 242,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Subway"
|
||||
},
|
||||
{
|
||||
"Id": 243,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cat"
|
||||
},
|
||||
{
|
||||
"Id": 244,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ant"
|
||||
},
|
||||
{
|
||||
"Id": 245,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zebra"
|
||||
},
|
||||
{
|
||||
"Id": 246,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pizza"
|
||||
},
|
||||
{
|
||||
"Id": 247,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball Bat"
|
||||
},
|
||||
{
|
||||
"Id": 248,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Car"
|
||||
},
|
||||
{
|
||||
"Id": 249,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pig"
|
||||
},
|
||||
{
|
||||
"Id": 250,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bull"
|
||||
},
|
||||
{
|
||||
"Id": 251,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Calculator"
|
||||
},
|
||||
{
|
||||
"Id": 252,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Octopus"
|
||||
},
|
||||
{
|
||||
"Id": 253,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheese"
|
||||
},
|
||||
{
|
||||
"Id": 254,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fish Tank"
|
||||
},
|
||||
{
|
||||
"Id": 255,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Starfish"
|
||||
},
|
||||
{
|
||||
"Id": 256,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eggs"
|
||||
},
|
||||
{
|
||||
"Id": 257,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toothbrush"
|
||||
},
|
||||
{
|
||||
"Id": 258,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lemon"
|
||||
},
|
||||
{
|
||||
"Id": 259,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Computer"
|
||||
},
|
||||
{
|
||||
"Id": 260,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Present"
|
||||
},
|
||||
{
|
||||
"Id": 261,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Key"
|
||||
},
|
||||
{
|
||||
"Id": 262,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silverware"
|
||||
},
|
||||
{
|
||||
"Id": 263,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Water"
|
||||
},
|
||||
{
|
||||
"Id": 264,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broken Heart"
|
||||
},
|
||||
{
|
||||
"Id": 265,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Love"
|
||||
},
|
||||
{
|
||||
"Id": 266,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silly String"
|
||||
},
|
||||
{
|
||||
"Id": 267,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Checkers"
|
||||
},
|
||||
{
|
||||
"Id": 268,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gumball Machine"
|
||||
},
|
||||
{
|
||||
"Id": 269,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "City"
|
||||
},
|
||||
{
|
||||
"Id": 270,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spider"
|
||||
},
|
||||
{
|
||||
"Id": 271,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wagon"
|
||||
},
|
||||
{
|
||||
"Id": 272,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tackle"
|
||||
},
|
||||
{
|
||||
"Id": 273,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Princess"
|
||||
},
|
||||
{
|
||||
"Id": 274,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crown"
|
||||
},
|
||||
{
|
||||
"Id": 275,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mermaid"
|
||||
},
|
||||
{
|
||||
"Id": 276,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mushroom"
|
||||
},
|
||||
{
|
||||
"Id": 278,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mountain"
|
||||
},
|
||||
{
|
||||
"Id": 279,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brush"
|
||||
},
|
||||
{
|
||||
"Id": 280,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Airport"
|
||||
},
|
||||
{
|
||||
"Id": 281,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bridge"
|
||||
},
|
||||
{
|
||||
"Id": 282,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lamp"
|
||||
},
|
||||
{
|
||||
"Id": 283,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skate"
|
||||
},
|
||||
{
|
||||
"Id": 284,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tank"
|
||||
},
|
||||
{
|
||||
"Id": 285,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kitten"
|
||||
},
|
||||
{
|
||||
"Id": 286,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grapes"
|
||||
},
|
||||
{
|
||||
"Id": 287,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knight"
|
||||
},
|
||||
{
|
||||
"Id": 288,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cave"
|
||||
},
|
||||
{
|
||||
"Id": 289,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eagle"
|
||||
},
|
||||
{
|
||||
"Id": 290,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rope"
|
||||
},
|
||||
{
|
||||
"Id": 291,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Farmer"
|
||||
},
|
||||
{
|
||||
"Id": 292,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sidewalk"
|
||||
},
|
||||
{
|
||||
"Id": 293,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Comb"
|
||||
},
|
||||
{
|
||||
"Id": 294,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rake"
|
||||
},
|
||||
{
|
||||
"Id": 295,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swiss Cheese"
|
||||
},
|
||||
{
|
||||
"Id": 296,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pool"
|
||||
},
|
||||
{
|
||||
"Id": 297,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Door"
|
||||
},
|
||||
{
|
||||
"Id": 298,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skateboard"
|
||||
},
|
||||
{
|
||||
"Id": 299,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rose"
|
||||
},
|
||||
{
|
||||
"Id": 300,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rabbit"
|
||||
},
|
||||
{
|
||||
"Id": 301,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nest"
|
||||
},
|
||||
{
|
||||
"Id": 302,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Root"
|
||||
},
|
||||
{
|
||||
"Id": 303,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bride"
|
||||
},
|
||||
{
|
||||
"Id": 305,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horn"
|
||||
},
|
||||
{
|
||||
"Id": 306,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Island"
|
||||
},
|
||||
{
|
||||
"Id": 307,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paw"
|
||||
},
|
||||
{
|
||||
"Id": 308,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rowboat"
|
||||
},
|
||||
{
|
||||
"Id": 309,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tie"
|
||||
},
|
||||
{
|
||||
"Id": 310,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Radio"
|
||||
},
|
||||
{
|
||||
"Id": 311,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Handle"
|
||||
},
|
||||
{
|
||||
"Id": 313,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rain"
|
||||
},
|
||||
{
|
||||
"Id": 314,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elevator"
|
||||
},
|
||||
{
|
||||
"Id": 315,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saddle"
|
||||
},
|
||||
{
|
||||
"Id": 316,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Upside Down"
|
||||
},
|
||||
{
|
||||
"Id": 317,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elephant"
|
||||
},
|
||||
{
|
||||
"Id": 318,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball"
|
||||
},
|
||||
{
|
||||
"Id": 319,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ship"
|
||||
},
|
||||
{
|
||||
"Id": 320,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hot Dog"
|
||||
},
|
||||
{
|
||||
"Id": 321,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Building"
|
||||
},
|
||||
{
|
||||
"Id": 322,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crab"
|
||||
},
|
||||
{
|
||||
"Id": 323,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bathtub"
|
||||
},
|
||||
{
|
||||
"Id": 324,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alarm"
|
||||
},
|
||||
{
|
||||
"Id": 325,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Golf Ball"
|
||||
},
|
||||
{
|
||||
"Id": 326,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree House"
|
||||
},
|
||||
{
|
||||
"Id": 327,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Battleship"
|
||||
},
|
||||
{
|
||||
"Id": 328,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Birdhouse"
|
||||
},
|
||||
{
|
||||
"Id": 329,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yo–Yo"
|
||||
},
|
||||
{
|
||||
"Id": 330,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Belt"
|
||||
},
|
||||
{
|
||||
"Id": 331,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candy"
|
||||
},
|
||||
{
|
||||
"Id": 332,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fishing"
|
||||
},
|
||||
{
|
||||
"Id": 333,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drink"
|
||||
},
|
||||
{
|
||||
"Id": 334,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Globe"
|
||||
},
|
||||
{
|
||||
"Id": 335,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garage"
|
||||
},
|
||||
{
|
||||
"Id": 336,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blizzard"
|
||||
},
|
||||
{
|
||||
"Id": 337,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bumblebee"
|
||||
},
|
||||
{
|
||||
"Id": 338,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thorn"
|
||||
},
|
||||
{
|
||||
"Id": 339,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leapfrog"
|
||||
},
|
||||
{
|
||||
"Id": 340,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mother"
|
||||
},
|
||||
{
|
||||
"Id": 341,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Father"
|
||||
},
|
||||
{
|
||||
"Id": 342,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dog"
|
||||
},
|
||||
{
|
||||
"Id": 343,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Deodorant"
|
||||
},
|
||||
{
|
||||
"Id": 344,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chocolate"
|
||||
},
|
||||
{
|
||||
"Id": 345,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cup"
|
||||
},
|
||||
{
|
||||
"Id": 346,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Church"
|
||||
},
|
||||
{
|
||||
"Id": 347,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spray"
|
||||
},
|
||||
{
|
||||
"Id": 348,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grass"
|
||||
},
|
||||
{
|
||||
"Id": 349,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tissue"
|
||||
},
|
||||
{
|
||||
"Id": 350,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chicken"
|
||||
},
|
||||
{
|
||||
"Id": 351,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Song"
|
||||
},
|
||||
{
|
||||
"Id": 352,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Forest"
|
||||
},
|
||||
{
|
||||
"Id": 353,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pillow"
|
||||
},
|
||||
{
|
||||
"Id": 354,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plant"
|
||||
},
|
||||
{
|
||||
"Id": 355,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bed"
|
||||
},
|
||||
{
|
||||
"Id": 356,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Notebook"
|
||||
},
|
||||
{
|
||||
"Id": 357,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ruler"
|
||||
},
|
||||
{
|
||||
"Id": 358,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Speaker"
|
||||
},
|
||||
{
|
||||
"Id": 359,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stool"
|
||||
},
|
||||
{
|
||||
"Id": 361,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crutch"
|
||||
},
|
||||
{
|
||||
"Id": 362,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butterfly"
|
||||
},
|
||||
{
|
||||
"Id": 363,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cane"
|
||||
},
|
||||
{
|
||||
"Id": 364,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flower"
|
||||
},
|
||||
{
|
||||
"Id": 365,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walker"
|
||||
},
|
||||
{
|
||||
"Id": 367,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gas"
|
||||
},
|
||||
{
|
||||
"Id": 368,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Perfume"
|
||||
},
|
||||
{
|
||||
"Id": 369,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Corn"
|
||||
},
|
||||
{
|
||||
"Id": 370,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time"
|
||||
},
|
||||
{
|
||||
"Id": 371,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jack and the Bean Stalk"
|
||||
},
|
||||
{
|
||||
"Id": 372,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "United States"
|
||||
},
|
||||
{
|
||||
"Id": 373,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Directions"
|
||||
},
|
||||
{
|
||||
"Id": 375,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Apple"
|
||||
},
|
||||
{
|
||||
"Id": 376,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hat"
|
||||
},
|
||||
{
|
||||
"Id": 377,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jacket"
|
||||
},
|
||||
{
|
||||
"Id": 378,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poison"
|
||||
},
|
||||
{
|
||||
"Id": 379,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shirt"
|
||||
},
|
||||
{
|
||||
"Id": 380,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jeans"
|
||||
},
|
||||
{
|
||||
"Id": 381,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angry"
|
||||
},
|
||||
{
|
||||
"Id": 382,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ankle"
|
||||
},
|
||||
{
|
||||
"Id": 383,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time"
|
||||
},
|
||||
{
|
||||
"Id": 384,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cry"
|
||||
},
|
||||
{
|
||||
"Id": 385,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cake"
|
||||
},
|
||||
{
|
||||
"Id": 386,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watering Can"
|
||||
},
|
||||
{
|
||||
"Id": 387,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boat"
|
||||
},
|
||||
{
|
||||
"Id": 389,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star Trek"
|
||||
},
|
||||
{
|
||||
"Id": 391,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pinocchio"
|
||||
},
|
||||
{
|
||||
"Id": 392,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "WALL-E"
|
||||
},
|
||||
{
|
||||
"Id": 393,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Balcony"
|
||||
},
|
||||
{
|
||||
"Id": 394,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Lemonade Stand"
|
||||
},
|
||||
{
|
||||
"Id": 395,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Juggle"
|
||||
},
|
||||
{
|
||||
"Id": 396,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Knitting"
|
||||
},
|
||||
{
|
||||
"Id": 397,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Double-sided Tape"
|
||||
},
|
||||
{
|
||||
"Id": 398,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chainsaw"
|
||||
},
|
||||
{
|
||||
"Id": 399,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Barista"
|
||||
},
|
||||
{
|
||||
"Id": 400,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Old Faithful"
|
||||
},
|
||||
{
|
||||
"Id": 401,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Road Rage"
|
||||
},
|
||||
{
|
||||
"Id": 402,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Assembly Line"
|
||||
},
|
||||
{
|
||||
"Id": 403,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Horsepower"
|
||||
},
|
||||
{
|
||||
"Id": 404,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Amputation"
|
||||
},
|
||||
{
|
||||
"Id": 405,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Rage Quit"
|
||||
},
|
||||
{
|
||||
"Id": 407,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "University"
|
||||
},
|
||||
{
|
||||
"Id": 408,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Committee"
|
||||
},
|
||||
{
|
||||
"Id": 410,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Hedge Trimmer"
|
||||
},
|
||||
{
|
||||
"Id": 411,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Thermostat"
|
||||
},
|
||||
{
|
||||
"Id": 412,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Duel"
|
||||
},
|
||||
{
|
||||
"Id": 413,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shooting Star"
|
||||
},
|
||||
{
|
||||
"Id": 415,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pogo Stick"
|
||||
},
|
||||
{
|
||||
"Id": 417,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Katniss Everdeen"
|
||||
},
|
||||
{
|
||||
"Id": 418,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocket Man"
|
||||
},
|
||||
{
|
||||
"Id": 419,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monster Truck"
|
||||
},
|
||||
{
|
||||
"Id": 420,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "King Kong"
|
||||
},
|
||||
{
|
||||
"Id": 422,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vampire"
|
||||
},
|
||||
{
|
||||
"Id": 423,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peter Pan"
|
||||
},
|
||||
{
|
||||
"Id": 424,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Captain Hook"
|
||||
},
|
||||
{
|
||||
"Id": 425,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Three Little Pigs"
|
||||
},
|
||||
{
|
||||
"Id": 426,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Big Bad Wolf"
|
||||
},
|
||||
{
|
||||
"Id": 427,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ninja"
|
||||
},
|
||||
{
|
||||
"Id": 428,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scarecrow"
|
||||
},
|
||||
{
|
||||
"Id": 429,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tin Man"
|
||||
},
|
||||
{
|
||||
"Id": 430,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Devil"
|
||||
},
|
||||
{
|
||||
"Id": 432,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Darth Vader"
|
||||
},
|
||||
{
|
||||
"Id": 433,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wolverine"
|
||||
},
|
||||
{
|
||||
"Id": 434,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cyclops"
|
||||
},
|
||||
{
|
||||
"Id": 435,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Medusa"
|
||||
},
|
||||
{
|
||||
"Id": 436,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drums"
|
||||
},
|
||||
{
|
||||
"Id": 437,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dice"
|
||||
},
|
||||
{
|
||||
"Id": 438,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Police Officer"
|
||||
},
|
||||
{
|
||||
"Id": 439,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Singer"
|
||||
},
|
||||
{
|
||||
"Id": 440,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Judge"
|
||||
},
|
||||
{
|
||||
"Id": 442,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gandalf"
|
||||
},
|
||||
{
|
||||
"Id": 443,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ghost"
|
||||
},
|
||||
{
|
||||
"Id": 444,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dragon"
|
||||
},
|
||||
{
|
||||
"Id": 445,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Santa Claus"
|
||||
},
|
||||
{
|
||||
"Id": 446,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tooth Fairy"
|
||||
},
|
||||
{
|
||||
"Id": 447,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hospital"
|
||||
},
|
||||
{
|
||||
"Id": 451,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mickey Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 452,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cat in the Hat"
|
||||
},
|
||||
{
|
||||
"Id": 453,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doormat"
|
||||
},
|
||||
{
|
||||
"Id": 454,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Windowsill"
|
||||
},
|
||||
{
|
||||
"Id": 455,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trapeze"
|
||||
},
|
||||
{
|
||||
"Id": 456,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Unicycle"
|
||||
},
|
||||
{
|
||||
"Id": 457,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "SCUBA"
|
||||
},
|
||||
{
|
||||
"Id": 458,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snorkle"
|
||||
},
|
||||
{
|
||||
"Id": 460,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tiger"
|
||||
},
|
||||
{
|
||||
"Id": 461,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Feather"
|
||||
},
|
||||
{
|
||||
"Id": 462,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Racket"
|
||||
},
|
||||
{
|
||||
"Id": 463,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Build"
|
||||
},
|
||||
{
|
||||
"Id": 464,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Weightlifter"
|
||||
},
|
||||
{
|
||||
"Id": 465,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Emoji"
|
||||
},
|
||||
{
|
||||
"Id": 466,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plug"
|
||||
},
|
||||
{
|
||||
"Id": 467,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lightning Bolt"
|
||||
},
|
||||
{
|
||||
"Id": 468,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zeus"
|
||||
},
|
||||
{
|
||||
"Id": 469,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caterpillar"
|
||||
},
|
||||
{
|
||||
"Id": 470,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tornado"
|
||||
},
|
||||
{
|
||||
"Id": 471,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cupid"
|
||||
},
|
||||
{
|
||||
"Id": 472,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Climb"
|
||||
},
|
||||
{
|
||||
"Id": 473,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monkey Bars"
|
||||
},
|
||||
{
|
||||
"Id": 474,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slide"
|
||||
},
|
||||
{
|
||||
"Id": 475,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Microscope"
|
||||
},
|
||||
{
|
||||
"Id": 477,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Breakfast"
|
||||
},
|
||||
{
|
||||
"Id": 478,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Parachute"
|
||||
},
|
||||
{
|
||||
"Id": 479,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Helicopter"
|
||||
},
|
||||
{
|
||||
"Id": 480,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jump Rope"
|
||||
},
|
||||
{
|
||||
"Id": 481,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beauty and the Beast"
|
||||
},
|
||||
{
|
||||
"Id": 482,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mad Hatter"
|
||||
},
|
||||
{
|
||||
"Id": 483,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Queen of Hearts"
|
||||
},
|
||||
{
|
||||
"Id": 484,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Unicorn"
|
||||
},
|
||||
{
|
||||
"Id": 485,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tow Truck"
|
||||
},
|
||||
{
|
||||
"Id": 486,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fountain"
|
||||
},
|
||||
{
|
||||
"Id": 487,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drop Anchor"
|
||||
},
|
||||
{
|
||||
"Id": 488,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowman"
|
||||
},
|
||||
{
|
||||
"Id": 489,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moose"
|
||||
},
|
||||
{
|
||||
"Id": 490,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gnome"
|
||||
},
|
||||
{
|
||||
"Id": 491,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rainbow"
|
||||
},
|
||||
{
|
||||
"Id": 492,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pork Chop"
|
||||
},
|
||||
{
|
||||
"Id": 493,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jewelry"
|
||||
},
|
||||
{
|
||||
"Id": 494,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Throne"
|
||||
},
|
||||
{
|
||||
"Id": 495,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Witch"
|
||||
},
|
||||
{
|
||||
"Id": 496,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "UFO"
|
||||
},
|
||||
{
|
||||
"Id": 497,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Three Musketeers"
|
||||
},
|
||||
{
|
||||
"Id": 498,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pie"
|
||||
},
|
||||
{
|
||||
"Id": 499,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brain"
|
||||
},
|
||||
{
|
||||
"Id": 500,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snail"
|
||||
},
|
||||
{
|
||||
"Id": 501,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robin Hood"
|
||||
},
|
||||
{
|
||||
"Id": 502,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stegosaurus"
|
||||
},
|
||||
{
|
||||
"Id": 503,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Willy Wonka"
|
||||
},
|
||||
{
|
||||
"Id": 504,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mechanic"
|
||||
},
|
||||
{
|
||||
"Id": 505,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coconut"
|
||||
},
|
||||
{
|
||||
"Id": 506,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cookie Monster"
|
||||
},
|
||||
{
|
||||
"Id": 507,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Giraffe"
|
||||
},
|
||||
{
|
||||
"Id": 508,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skull"
|
||||
},
|
||||
{
|
||||
"Id": 509,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "DJ"
|
||||
},
|
||||
{
|
||||
"Id": 510,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Joust"
|
||||
},
|
||||
{
|
||||
"Id": 511,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Telescope"
|
||||
},
|
||||
{
|
||||
"Id": 512,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beehive"
|
||||
},
|
||||
{
|
||||
"Id": 513,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mummy"
|
||||
},
|
||||
{
|
||||
"Id": 514,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nail Polish"
|
||||
},
|
||||
{
|
||||
"Id": 515,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scorpion"
|
||||
},
|
||||
{
|
||||
"Id": 516,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swing"
|
||||
},
|
||||
{
|
||||
"Id": 517,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boomerang"
|
||||
},
|
||||
{
|
||||
"Id": 518,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "South America"
|
||||
},
|
||||
{
|
||||
"Id": 519,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Asia"
|
||||
},
|
||||
{
|
||||
"Id": 520,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Africa"
|
||||
},
|
||||
{
|
||||
"Id": 521,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Australia"
|
||||
},
|
||||
{
|
||||
"Id": 522,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antarctica"
|
||||
},
|
||||
{
|
||||
"Id": 523,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whale"
|
||||
},
|
||||
{
|
||||
"Id": 524,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dolphin"
|
||||
},
|
||||
{
|
||||
"Id": 525,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Narwhal"
|
||||
},
|
||||
{
|
||||
"Id": 526,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disc Golf"
|
||||
},
|
||||
{
|
||||
"Id": 527,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plumber"
|
||||
},
|
||||
{
|
||||
"Id": 528,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chef"
|
||||
},
|
||||
{
|
||||
"Id": 529,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walking the Dog"
|
||||
},
|
||||
{
|
||||
"Id": 530,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fairy Tale"
|
||||
},
|
||||
{
|
||||
"Id": 531,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treadmill"
|
||||
},
|
||||
{
|
||||
"Id": 532,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Groundhog"
|
||||
},
|
||||
{
|
||||
"Id": 533,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Porcupine"
|
||||
},
|
||||
{
|
||||
"Id": 534,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Centaur"
|
||||
},
|
||||
{
|
||||
"Id": 535,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soda"
|
||||
},
|
||||
{
|
||||
"Id": 536,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cookies"
|
||||
},
|
||||
{
|
||||
"Id": 537,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tea"
|
||||
},
|
||||
{
|
||||
"Id": 538,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stove"
|
||||
},
|
||||
{
|
||||
"Id": 539,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Oven"
|
||||
},
|
||||
{
|
||||
"Id": 540,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cooking"
|
||||
},
|
||||
{
|
||||
"Id": 541,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soup"
|
||||
},
|
||||
{
|
||||
"Id": 542,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kite"
|
||||
},
|
||||
{
|
||||
"Id": 543,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trombone"
|
||||
},
|
||||
{
|
||||
"Id": 544,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Canoe"
|
||||
},
|
||||
{
|
||||
"Id": 545,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hamburger"
|
||||
},
|
||||
{
|
||||
"Id": 546,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pilot"
|
||||
},
|
||||
{
|
||||
"Id": 547,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caesar"
|
||||
},
|
||||
{
|
||||
"Id": 548,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Humpty Dumpty"
|
||||
},
|
||||
{
|
||||
"Id": 549,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bob Ross"
|
||||
},
|
||||
{
|
||||
"Id": 550,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walrus"
|
||||
},
|
||||
{
|
||||
"Id": 551,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pillow Fight"
|
||||
},
|
||||
{
|
||||
"Id": 552,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Remote"
|
||||
},
|
||||
{
|
||||
"Id": 553,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hanger"
|
||||
},
|
||||
{
|
||||
"Id": 554,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thermometer"
|
||||
},
|
||||
{
|
||||
"Id": 555,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Polka Dots"
|
||||
},
|
||||
{
|
||||
"Id": 556,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Contract"
|
||||
},
|
||||
{
|
||||
"Id": 557,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Signature"
|
||||
},
|
||||
{
|
||||
"Id": 558,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surgeon"
|
||||
},
|
||||
{
|
||||
"Id": 559,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sleeve"
|
||||
},
|
||||
{
|
||||
"Id": 560,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stapler"
|
||||
},
|
||||
{
|
||||
"Id": 561,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chicken Pox"
|
||||
},
|
||||
{
|
||||
"Id": 562,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sherlock Holmes"
|
||||
},
|
||||
{
|
||||
"Id": 563,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sponge"
|
||||
},
|
||||
{
|
||||
"Id": 564,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Umpire"
|
||||
},
|
||||
{
|
||||
"Id": 565,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pearl"
|
||||
},
|
||||
{
|
||||
"Id": 566,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sewing Machine"
|
||||
},
|
||||
{
|
||||
"Id": 567,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Freezer"
|
||||
},
|
||||
{
|
||||
"Id": 568,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Belly Button"
|
||||
},
|
||||
{
|
||||
"Id": 569,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watermelon"
|
||||
},
|
||||
{
|
||||
"Id": 570,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slot Machine"
|
||||
},
|
||||
{
|
||||
"Id": 571,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wallet"
|
||||
},
|
||||
{
|
||||
"Id": 572,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Keyboard"
|
||||
},
|
||||
{
|
||||
"Id": 573,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chimney"
|
||||
},
|
||||
{
|
||||
"Id": 574,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smoke"
|
||||
},
|
||||
{
|
||||
"Id": 575,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fleas"
|
||||
},
|
||||
{
|
||||
"Id": 576,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banjo"
|
||||
},
|
||||
{
|
||||
"Id": 577,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Razor"
|
||||
},
|
||||
{
|
||||
"Id": 578,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Badminton"
|
||||
},
|
||||
{
|
||||
"Id": 579,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stairs"
|
||||
},
|
||||
{
|
||||
"Id": 580,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dial"
|
||||
},
|
||||
{
|
||||
"Id": 581,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waterski"
|
||||
},
|
||||
{
|
||||
"Id": 582,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lick"
|
||||
},
|
||||
{
|
||||
"Id": 584,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yo-Yo"
|
||||
},
|
||||
{
|
||||
"Id": 585,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bubble"
|
||||
},
|
||||
{
|
||||
"Id": 586,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "High Heels"
|
||||
},
|
||||
{
|
||||
"Id": 587,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Echo"
|
||||
},
|
||||
{
|
||||
"Id": 588,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bandage"
|
||||
},
|
||||
{
|
||||
"Id": 589,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Excalibur"
|
||||
},
|
||||
{
|
||||
"Id": 590,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mosquito"
|
||||
},
|
||||
{
|
||||
"Id": 591,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Envelope"
|
||||
},
|
||||
{
|
||||
"Id": 592,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowl"
|
||||
},
|
||||
{
|
||||
"Id": 593,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowling Pin"
|
||||
},
|
||||
{
|
||||
"Id": 594,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowling Ball"
|
||||
},
|
||||
{
|
||||
"Id": 595,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whistle"
|
||||
},
|
||||
{
|
||||
"Id": 596,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Diamond"
|
||||
},
|
||||
{
|
||||
"Id": 597,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Suspenders"
|
||||
},
|
||||
{
|
||||
"Id": 598,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hairspray"
|
||||
},
|
||||
{
|
||||
"Id": 599,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Calendar"
|
||||
},
|
||||
{
|
||||
"Id": 600,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monk"
|
||||
},
|
||||
{
|
||||
"Id": 601,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Screwdriver"
|
||||
},
|
||||
{
|
||||
"Id": 602,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earring"
|
||||
},
|
||||
{
|
||||
"Id": 603,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fireworks"
|
||||
},
|
||||
{
|
||||
"Id": 604,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waffle"
|
||||
},
|
||||
{
|
||||
"Id": 605,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bell"
|
||||
},
|
||||
{
|
||||
"Id": 606,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drive"
|
||||
},
|
||||
{
|
||||
"Id": 607,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Collar"
|
||||
},
|
||||
{
|
||||
"Id": 608,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Money"
|
||||
},
|
||||
{
|
||||
"Id": 609,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toe"
|
||||
},
|
||||
{
|
||||
"Id": 610,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Balance Beam"
|
||||
},
|
||||
{
|
||||
"Id": 611,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treasure Chest"
|
||||
},
|
||||
{
|
||||
"Id": 612,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sleeping Bag"
|
||||
},
|
||||
{
|
||||
"Id": 613,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tent"
|
||||
},
|
||||
{
|
||||
"Id": 614,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bib"
|
||||
},
|
||||
{
|
||||
"Id": 615,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buffet"
|
||||
},
|
||||
{
|
||||
"Id": 616,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crystal"
|
||||
},
|
||||
{
|
||||
"Id": 617,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cymbals"
|
||||
},
|
||||
{
|
||||
"Id": 618,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nametag"
|
||||
},
|
||||
{
|
||||
"Id": 619,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beak"
|
||||
},
|
||||
{
|
||||
"Id": 620,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pacifier"
|
||||
},
|
||||
{
|
||||
"Id": 621,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Umbrella"
|
||||
},
|
||||
{
|
||||
"Id": 622,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoe"
|
||||
},
|
||||
{
|
||||
"Id": 623,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gorilla"
|
||||
},
|
||||
{
|
||||
"Id": 624,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earmuffs"
|
||||
},
|
||||
{
|
||||
"Id": 625,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Idea"
|
||||
},
|
||||
{
|
||||
"Id": 626,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Finish Line"
|
||||
},
|
||||
{
|
||||
"Id": 627,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angel"
|
||||
},
|
||||
{
|
||||
"Id": 628,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turtleneck"
|
||||
},
|
||||
{
|
||||
"Id": 629,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kiss"
|
||||
},
|
||||
{
|
||||
"Id": 630,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chopsticks"
|
||||
},
|
||||
{
|
||||
"Id": 631,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coffee"
|
||||
},
|
||||
{
|
||||
"Id": 632,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whip"
|
||||
},
|
||||
{
|
||||
"Id": 633,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Apron"
|
||||
},
|
||||
{
|
||||
"Id": 634,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mole"
|
||||
},
|
||||
{
|
||||
"Id": 635,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pour"
|
||||
},
|
||||
{
|
||||
"Id": 636,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Marbles"
|
||||
},
|
||||
{
|
||||
"Id": 637,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cork"
|
||||
},
|
||||
{
|
||||
"Id": 638,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lollipop"
|
||||
},
|
||||
{
|
||||
"Id": 639,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rat"
|
||||
},
|
||||
{
|
||||
"Id": 640,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jackhammer"
|
||||
},
|
||||
{
|
||||
"Id": 641,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bat"
|
||||
},
|
||||
{
|
||||
"Id": 642,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Binoculars"
|
||||
},
|
||||
{
|
||||
"Id": 643,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tattoo"
|
||||
},
|
||||
{
|
||||
"Id": 644,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cards"
|
||||
},
|
||||
{
|
||||
"Id": 645,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bark"
|
||||
},
|
||||
{
|
||||
"Id": 646,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eyelash"
|
||||
},
|
||||
{
|
||||
"Id": 647,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunglasses"
|
||||
},
|
||||
{
|
||||
"Id": 648,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drill"
|
||||
},
|
||||
{
|
||||
"Id": 649,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wash"
|
||||
},
|
||||
{
|
||||
"Id": 650,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Closet"
|
||||
},
|
||||
{
|
||||
"Id": 651,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tennis"
|
||||
},
|
||||
{
|
||||
"Id": 652,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scarf"
|
||||
},
|
||||
{
|
||||
"Id": 653,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antenna"
|
||||
},
|
||||
{
|
||||
"Id": 654,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Aquarium"
|
||||
},
|
||||
{
|
||||
"Id": 655,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Refrigerator"
|
||||
},
|
||||
{
|
||||
"Id": 656,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Muscle"
|
||||
},
|
||||
{
|
||||
"Id": 657,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Iron"
|
||||
},
|
||||
{
|
||||
"Id": 658,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Film"
|
||||
},
|
||||
{
|
||||
"Id": 659,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pipe"
|
||||
},
|
||||
{
|
||||
"Id": 660,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blindfold"
|
||||
},
|
||||
{
|
||||
"Id": 661,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stork"
|
||||
},
|
||||
{
|
||||
"Id": 662,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Deer"
|
||||
},
|
||||
{
|
||||
"Id": 663,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bad Breath"
|
||||
},
|
||||
{
|
||||
"Id": 664,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garden"
|
||||
},
|
||||
{
|
||||
"Id": 665,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tug of War"
|
||||
},
|
||||
{
|
||||
"Id": 666,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sandwich"
|
||||
},
|
||||
{
|
||||
"Id": 667,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glove"
|
||||
},
|
||||
{
|
||||
"Id": 668,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fence"
|
||||
},
|
||||
{
|
||||
"Id": 669,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Heartburn"
|
||||
},
|
||||
{
|
||||
"Id": 670,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moustache"
|
||||
},
|
||||
{
|
||||
"Id": 671,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hose"
|
||||
},
|
||||
{
|
||||
"Id": 672,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hypnotize"
|
||||
},
|
||||
{
|
||||
"Id": 673,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gong"
|
||||
},
|
||||
{
|
||||
"Id": 674,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hammer"
|
||||
},
|
||||
{
|
||||
"Id": 675,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mailbox"
|
||||
},
|
||||
{
|
||||
"Id": 676,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Luggage"
|
||||
},
|
||||
{
|
||||
"Id": 677,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carrot"
|
||||
},
|
||||
{
|
||||
"Id": 678,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seatbelt"
|
||||
},
|
||||
{
|
||||
"Id": 679,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Clown"
|
||||
},
|
||||
{
|
||||
"Id": 680,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slingshot"
|
||||
},
|
||||
{
|
||||
"Id": 681,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jukebox"
|
||||
},
|
||||
{
|
||||
"Id": 682,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skeleton"
|
||||
},
|
||||
{
|
||||
"Id": 683,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bait"
|
||||
},
|
||||
{
|
||||
"Id": 684,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ribbon"
|
||||
},
|
||||
{
|
||||
"Id": 685,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Attic"
|
||||
},
|
||||
{
|
||||
"Id": 686,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Branch"
|
||||
},
|
||||
{
|
||||
"Id": 687,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candy Cane"
|
||||
},
|
||||
{
|
||||
"Id": 688,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rug"
|
||||
},
|
||||
{
|
||||
"Id": 689,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hourglass"
|
||||
},
|
||||
{
|
||||
"Id": 690,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stethoscope"
|
||||
},
|
||||
{
|
||||
"Id": 691,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Potholder"
|
||||
},
|
||||
{
|
||||
"Id": 692,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pumpkin"
|
||||
},
|
||||
{
|
||||
"Id": 693,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lasso"
|
||||
},
|
||||
{
|
||||
"Id": 694,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cannon"
|
||||
},
|
||||
{
|
||||
"Id": 695,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Harp"
|
||||
},
|
||||
{
|
||||
"Id": 696,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tambourine"
|
||||
},
|
||||
{
|
||||
"Id": 697,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poison Ivy"
|
||||
},
|
||||
{
|
||||
"Id": 698,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowflake"
|
||||
},
|
||||
{
|
||||
"Id": 699,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Punching Bag"
|
||||
},
|
||||
{
|
||||
"Id": 700,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Puzzle"
|
||||
},
|
||||
{
|
||||
"Id": 701,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broom"
|
||||
},
|
||||
{
|
||||
"Id": 702,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earth"
|
||||
},
|
||||
{
|
||||
"Id": 703,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sky"
|
||||
},
|
||||
{
|
||||
"Id": 704,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Statue"
|
||||
},
|
||||
{
|
||||
"Id": 705,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doll"
|
||||
},
|
||||
{
|
||||
"Id": 706,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Necklace"
|
||||
},
|
||||
{
|
||||
"Id": 707,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Popcorn"
|
||||
},
|
||||
{
|
||||
"Id": 708,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lock"
|
||||
},
|
||||
{
|
||||
"Id": 709,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sofa"
|
||||
},
|
||||
{
|
||||
"Id": 710,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magnifying Glass"
|
||||
},
|
||||
{
|
||||
"Id": 711,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surf"
|
||||
},
|
||||
{
|
||||
"Id": 712,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magic"
|
||||
},
|
||||
{
|
||||
"Id": 713,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walkie-Talkie"
|
||||
},
|
||||
{
|
||||
"Id": 714,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Floss"
|
||||
},
|
||||
{
|
||||
"Id": 715,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knife"
|
||||
},
|
||||
{
|
||||
"Id": 716,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bread"
|
||||
},
|
||||
{
|
||||
"Id": 717,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thumbtack"
|
||||
},
|
||||
{
|
||||
"Id": 718,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lighter"
|
||||
},
|
||||
{
|
||||
"Id": 719,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teapot"
|
||||
},
|
||||
{
|
||||
"Id": 720,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Curls"
|
||||
},
|
||||
{
|
||||
"Id": 721,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Periscope"
|
||||
},
|
||||
{
|
||||
"Id": 722,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fingerprint"
|
||||
},
|
||||
{
|
||||
"Id": 723,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mask"
|
||||
},
|
||||
{
|
||||
"Id": 724,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tape Measure"
|
||||
},
|
||||
{
|
||||
"Id": 725,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scrub"
|
||||
},
|
||||
{
|
||||
"Id": 726,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Motorcycle"
|
||||
},
|
||||
{
|
||||
"Id": 727,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fur"
|
||||
},
|
||||
{
|
||||
"Id": 728,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toothpaste"
|
||||
},
|
||||
{
|
||||
"Id": 729,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stilts"
|
||||
},
|
||||
{
|
||||
"Id": 730,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Straw"
|
||||
},
|
||||
{
|
||||
"Id": 731,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doorknob"
|
||||
},
|
||||
{
|
||||
"Id": 732,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Train"
|
||||
},
|
||||
{
|
||||
"Id": 733,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ukulele"
|
||||
},
|
||||
{
|
||||
"Id": 734,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nerd"
|
||||
},
|
||||
{
|
||||
"Id": 735,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bucket"
|
||||
},
|
||||
{
|
||||
"Id": 736,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Conductor"
|
||||
},
|
||||
{
|
||||
"Id": 737,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Palace"
|
||||
},
|
||||
{
|
||||
"Id": 738,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Giant"
|
||||
},
|
||||
{
|
||||
"Id": 739,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goblin"
|
||||
},
|
||||
{
|
||||
"Id": 740,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ketchup"
|
||||
},
|
||||
{
|
||||
"Id": 741,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Taxi"
|
||||
},
|
||||
{
|
||||
"Id": 742,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gravestone"
|
||||
},
|
||||
{
|
||||
"Id": 743,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Croquet"
|
||||
},
|
||||
{
|
||||
"Id": 744,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tarzan"
|
||||
},
|
||||
{
|
||||
"Id": 745,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ballerina"
|
||||
},
|
||||
{
|
||||
"Id": 746,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Volleyball"
|
||||
},
|
||||
{
|
||||
"Id": 747,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Braces"
|
||||
},
|
||||
{
|
||||
"Id": 748,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fork"
|
||||
},
|
||||
{
|
||||
"Id": 749,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bubble Gum"
|
||||
},
|
||||
{
|
||||
"Id": 750,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Worm"
|
||||
},
|
||||
{
|
||||
"Id": 751,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ladder"
|
||||
},
|
||||
{
|
||||
"Id": 752,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dynamite"
|
||||
},
|
||||
{
|
||||
"Id": 753,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flashlight"
|
||||
},
|
||||
{
|
||||
"Id": 754,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basement"
|
||||
},
|
||||
{
|
||||
"Id": 755,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blanket"
|
||||
},
|
||||
{
|
||||
"Id": 756,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tire"
|
||||
},
|
||||
{
|
||||
"Id": 757,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whiskers"
|
||||
},
|
||||
{
|
||||
"Id": 758,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Freckle"
|
||||
},
|
||||
{
|
||||
"Id": 759,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Backpack"
|
||||
},
|
||||
{
|
||||
"Id": 760,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candle"
|
||||
},
|
||||
{
|
||||
"Id": 761,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jockey"
|
||||
},
|
||||
{
|
||||
"Id": 762,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stamp"
|
||||
},
|
||||
{
|
||||
"Id": 763,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Television"
|
||||
},
|
||||
{
|
||||
"Id": 764,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Xylophone"
|
||||
},
|
||||
{
|
||||
"Id": 765,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Helmet"
|
||||
},
|
||||
{
|
||||
"Id": 766,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nightmare"
|
||||
},
|
||||
{
|
||||
"Id": 767,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Map"
|
||||
},
|
||||
{
|
||||
"Id": 768,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Accordion"
|
||||
},
|
||||
{
|
||||
"Id": 770,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butter"
|
||||
},
|
||||
{
|
||||
"Id": 771,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tripod"
|
||||
},
|
||||
{
|
||||
"Id": 772,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spoon"
|
||||
},
|
||||
{
|
||||
"Id": 773,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Microphone"
|
||||
},
|
||||
{
|
||||
"Id": 774,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mascara"
|
||||
},
|
||||
{
|
||||
"Id": 775,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sideburns"
|
||||
},
|
||||
{
|
||||
"Id": 776,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wheelchair"
|
||||
},
|
||||
{
|
||||
"Id": 777,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pinwheel"
|
||||
},
|
||||
{
|
||||
"Id": 778,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Badge"
|
||||
},
|
||||
{
|
||||
"Id": 779,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brick"
|
||||
},
|
||||
{
|
||||
"Id": 780,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trash Can"
|
||||
},
|
||||
{
|
||||
"Id": 781,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hydrant"
|
||||
},
|
||||
{
|
||||
"Id": 782,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Axe"
|
||||
},
|
||||
{
|
||||
"Id": 783,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swan"
|
||||
},
|
||||
{
|
||||
"Id": 784,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blood"
|
||||
},
|
||||
{
|
||||
"Id": 785,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electricity"
|
||||
},
|
||||
{
|
||||
"Id": 786,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caveman"
|
||||
},
|
||||
{
|
||||
"Id": 787,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camel"
|
||||
},
|
||||
{
|
||||
"Id": 788,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Santa"
|
||||
},
|
||||
{
|
||||
"Id": 789,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shooting Star"
|
||||
},
|
||||
{
|
||||
"Id": 790,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gift"
|
||||
},
|
||||
{
|
||||
"Id": 794,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball"
|
||||
},
|
||||
{
|
||||
"Id": 795,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Strawberry"
|
||||
},
|
||||
{
|
||||
"Id": 797,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piano"
|
||||
},
|
||||
{
|
||||
"Id": 798,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Paparazzi"
|
||||
},
|
||||
{
|
||||
"Id": 799,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tricycle"
|
||||
},
|
||||
{
|
||||
"Id": 800,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire Truck"
|
||||
},
|
||||
{
|
||||
"Id": 801,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ambulance"
|
||||
},
|
||||
{
|
||||
"Id": 802,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crane"
|
||||
},
|
||||
{
|
||||
"Id": 803,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "C3PO"
|
||||
},
|
||||
{
|
||||
"Id": 804,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "USS Enterprise"
|
||||
},
|
||||
{
|
||||
"Id": 805,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spock"
|
||||
},
|
||||
{
|
||||
"Id": 806,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Captain America"
|
||||
},
|
||||
{
|
||||
"Id": 807,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Hulk"
|
||||
},
|
||||
{
|
||||
"Id": 808,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hawkeye"
|
||||
},
|
||||
{
|
||||
"Id": 809,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thor"
|
||||
},
|
||||
{
|
||||
"Id": 810,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Iron Man"
|
||||
},
|
||||
{
|
||||
"Id": 812,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Race Car"
|
||||
},
|
||||
{
|
||||
"Id": 813,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basketball Hoop"
|
||||
},
|
||||
{
|
||||
"Id": 814,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gazebo"
|
||||
},
|
||||
{
|
||||
"Id": 815,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waterfall"
|
||||
},
|
||||
{
|
||||
"Id": 816,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Armadillo"
|
||||
},
|
||||
{
|
||||
"Id": 817,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Anteater"
|
||||
},
|
||||
{
|
||||
"Id": 818,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elbow"
|
||||
},
|
||||
{
|
||||
"Id": 819,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knee"
|
||||
},
|
||||
{
|
||||
"Id": 820,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cut"
|
||||
},
|
||||
{
|
||||
"Id": 821,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Top"
|
||||
},
|
||||
{
|
||||
"Id": 822,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Maze"
|
||||
},
|
||||
{
|
||||
"Id": 823,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Light Bulb"
|
||||
},
|
||||
{
|
||||
"Id": 824,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kermit the Frog"
|
||||
},
|
||||
{
|
||||
"Id": 825,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "North America"
|
||||
},
|
||||
{
|
||||
"Id": 826,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Europe"
|
||||
},
|
||||
{
|
||||
"Id": 827,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Construction Worker"
|
||||
},
|
||||
{
|
||||
"Id": 828,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doctor"
|
||||
},
|
||||
{
|
||||
"Id": 829,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheetah"
|
||||
},
|
||||
{
|
||||
"Id": 830,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ram"
|
||||
},
|
||||
{
|
||||
"Id": 831,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robe"
|
||||
},
|
||||
{
|
||||
"Id": 832,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peas"
|
||||
},
|
||||
{
|
||||
"Id": 833,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bow Tie"
|
||||
},
|
||||
{
|
||||
"Id": 834,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Samurai"
|
||||
},
|
||||
{
|
||||
"Id": 835,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Viking"
|
||||
},
|
||||
{
|
||||
"Id": 836,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lighthouse"
|
||||
},
|
||||
{
|
||||
"Id": 837,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunscreen"
|
||||
},
|
||||
{
|
||||
"Id": 838,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Archer"
|
||||
},
|
||||
{
|
||||
"Id": 839,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leaf"
|
||||
},
|
||||
{
|
||||
"Id": 840,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Acorn"
|
||||
},
|
||||
{
|
||||
"Id": 841,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Squirrel"
|
||||
},
|
||||
{
|
||||
"Id": 842,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meatball"
|
||||
},
|
||||
{
|
||||
"Id": 843,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eyebrow"
|
||||
},
|
||||
{
|
||||
"Id": 844,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eye"
|
||||
},
|
||||
{
|
||||
"Id": 845,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dish"
|
||||
},
|
||||
{
|
||||
"Id": 846,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frog"
|
||||
},
|
||||
{
|
||||
"Id": 847,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lizard"
|
||||
},
|
||||
{
|
||||
"Id": 848,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Game of Thrones"
|
||||
},
|
||||
{
|
||||
"Id": 849,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hulk"
|
||||
},
|
||||
{
|
||||
"Id": 850,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Black Panther"
|
||||
},
|
||||
{
|
||||
"Id": 851,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soccer"
|
||||
},
|
||||
{
|
||||
"Id": 852,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tetris"
|
||||
},
|
||||
{
|
||||
"Id": 853,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zelda"
|
||||
},
|
||||
{
|
||||
"Id": 854,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trampoline"
|
||||
},
|
||||
{
|
||||
"Id": 855,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Roller Coaster"
|
||||
},
|
||||
{
|
||||
"Id": 856,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ferris Wheel"
|
||||
},
|
||||
{
|
||||
"Id": 857,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pin"
|
||||
},
|
||||
{
|
||||
"Id": 858,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baker"
|
||||
},
|
||||
{
|
||||
"Id": 859,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Restaurant"
|
||||
},
|
||||
{
|
||||
"Id": 860,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bank"
|
||||
},
|
||||
{
|
||||
"Id": 861,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "School"
|
||||
},
|
||||
{
|
||||
"Id": 862,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocket"
|
||||
},
|
||||
{
|
||||
"Id": 863,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jupiter"
|
||||
},
|
||||
{
|
||||
"Id": 864,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eclipse"
|
||||
},
|
||||
{
|
||||
"Id": 865,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moon"
|
||||
},
|
||||
{
|
||||
"Id": 866,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sun"
|
||||
},
|
||||
{
|
||||
"Id": 867,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Comet"
|
||||
},
|
||||
{
|
||||
"Id": 868,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "VR"
|
||||
},
|
||||
{
|
||||
"Id": 869,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fidget Spinner"
|
||||
},
|
||||
{
|
||||
"Id": 870,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Laser"
|
||||
},
|
||||
{
|
||||
"Id": 871,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elf"
|
||||
},
|
||||
{
|
||||
"Id": 872,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zombie"
|
||||
},
|
||||
{
|
||||
"Id": 873,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pegasus"
|
||||
},
|
||||
{
|
||||
"Id": 874,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Noodles"
|
||||
},
|
||||
{
|
||||
"Id": 875,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "French Fries"
|
||||
},
|
||||
{
|
||||
"Id": 876,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Break"
|
||||
},
|
||||
{
|
||||
"Id": 877,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smash"
|
||||
},
|
||||
{
|
||||
"Id": 878,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pop"
|
||||
},
|
||||
{
|
||||
"Id": 879,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Orange"
|
||||
},
|
||||
{
|
||||
"Id": 880,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pear"
|
||||
},
|
||||
{
|
||||
"Id": 881,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Potato"
|
||||
},
|
||||
{
|
||||
"Id": 882,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tomato"
|
||||
},
|
||||
{
|
||||
"Id": 883,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broccoli"
|
||||
},
|
||||
{
|
||||
"Id": 884,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eggplant"
|
||||
},
|
||||
{
|
||||
"Id": 885,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Donut"
|
||||
},
|
||||
{
|
||||
"Id": 886,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bagel"
|
||||
},
|
||||
{
|
||||
"Id": 887,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seed"
|
||||
},
|
||||
{
|
||||
"Id": 888,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Salad"
|
||||
},
|
||||
{
|
||||
"Id": 889,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fried Chicken"
|
||||
},
|
||||
{
|
||||
"Id": 890,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bacon"
|
||||
},
|
||||
{
|
||||
"Id": 891,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seal"
|
||||
},
|
||||
{
|
||||
"Id": 892,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Polar Bear"
|
||||
},
|
||||
{
|
||||
"Id": 893,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Centipede"
|
||||
},
|
||||
{
|
||||
"Id": 894,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ladybug"
|
||||
},
|
||||
{
|
||||
"Id": 895,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grasshopper"
|
||||
},
|
||||
{
|
||||
"Id": 896,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shrimp"
|
||||
},
|
||||
{
|
||||
"Id": 897,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electric Eel"
|
||||
},
|
||||
{
|
||||
"Id": 898,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lobster"
|
||||
},
|
||||
{
|
||||
"Id": 899,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hammerhead Shark"
|
||||
},
|
||||
{
|
||||
"Id": 900,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peacock"
|
||||
},
|
||||
{
|
||||
"Id": 901,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dove"
|
||||
},
|
||||
{
|
||||
"Id": 902,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Raven"
|
||||
},
|
||||
{
|
||||
"Id": 903,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rattlesnake"
|
||||
},
|
||||
{
|
||||
"Id": 904,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cobra"
|
||||
},
|
||||
{
|
||||
"Id": 905,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fox"
|
||||
},
|
||||
{
|
||||
"Id": 906,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frozen"
|
||||
},
|
||||
{
|
||||
"Id": 907,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lion"
|
||||
},
|
||||
{
|
||||
"Id": 908,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Actor"
|
||||
},
|
||||
{
|
||||
"Id": 909,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Add"
|
||||
},
|
||||
{
|
||||
"Id": 910,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alice in Wonderland"
|
||||
},
|
||||
{
|
||||
"Id": 911,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Anchor"
|
||||
},
|
||||
{
|
||||
"Id": 912,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Army"
|
||||
},
|
||||
{
|
||||
"Id": 913,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Art"
|
||||
},
|
||||
{
|
||||
"Id": 914,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Astronaut"
|
||||
},
|
||||
{
|
||||
"Id": 915,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ball"
|
||||
},
|
||||
{
|
||||
"Id": 916,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ballet"
|
||||
},
|
||||
{
|
||||
"Id": 917,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banana"
|
||||
},
|
||||
{
|
||||
"Id": 918,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barbeque"
|
||||
},
|
||||
{
|
||||
"Id": 919,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barbie"
|
||||
},
|
||||
{
|
||||
"Id": 920,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barn"
|
||||
},
|
||||
{
|
||||
"Id": 921,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bart Simpson"
|
||||
},
|
||||
{
|
||||
"Id": 922,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basket"
|
||||
},
|
||||
{
|
||||
"Id": 923,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Battery"
|
||||
},
|
||||
{
|
||||
"Id": 924,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beach"
|
||||
},
|
||||
{
|
||||
"Id": 925,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beard"
|
||||
},
|
||||
{
|
||||
"Id": 926,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bedbug"
|
||||
},
|
||||
{
|
||||
"Id": 927,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beetle"
|
||||
},
|
||||
{
|
||||
"Id": 928,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Big Dipper"
|
||||
},
|
||||
{
|
||||
"Id": 929,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bikini"
|
||||
},
|
||||
{
|
||||
"Id": 930,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bite"
|
||||
},
|
||||
{
|
||||
"Id": 931,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blacksmith"
|
||||
},
|
||||
{
|
||||
"Id": 932,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blimp"
|
||||
},
|
||||
{
|
||||
"Id": 933,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Block"
|
||||
},
|
||||
{
|
||||
"Id": 934,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bluetooth"
|
||||
},
|
||||
{
|
||||
"Id": 935,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Board Game"
|
||||
},
|
||||
{
|
||||
"Id": 936,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bone"
|
||||
},
|
||||
{
|
||||
"Id": 937,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bonnet"
|
||||
},
|
||||
{
|
||||
"Id": 938,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bookend"
|
||||
},
|
||||
{
|
||||
"Id": 939,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bottle"
|
||||
},
|
||||
{
|
||||
"Id": 940,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bounce"
|
||||
},
|
||||
{
|
||||
"Id": 941,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bow"
|
||||
},
|
||||
{
|
||||
"Id": 942,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Box"
|
||||
},
|
||||
{
|
||||
"Id": 943,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boyfriend"
|
||||
},
|
||||
{
|
||||
"Id": 944,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bugs Bunny"
|
||||
},
|
||||
{
|
||||
"Id": 945,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bunk Bed"
|
||||
},
|
||||
{
|
||||
"Id": 946,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Burrito"
|
||||
},
|
||||
{
|
||||
"Id": 947,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butcher"
|
||||
},
|
||||
{
|
||||
"Id": 948,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Button"
|
||||
},
|
||||
{
|
||||
"Id": 949,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buzz Lightyear"
|
||||
},
|
||||
{
|
||||
"Id": 950,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camp"
|
||||
},
|
||||
{
|
||||
"Id": 951,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carpenter"
|
||||
},
|
||||
{
|
||||
"Id": 952,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carry"
|
||||
},
|
||||
{
|
||||
"Id": 953,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cashier"
|
||||
},
|
||||
{
|
||||
"Id": 954,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "CD"
|
||||
},
|
||||
{
|
||||
"Id": 955,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cell"
|
||||
},
|
||||
{
|
||||
"Id": 956,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chain"
|
||||
},
|
||||
{
|
||||
"Id": 957,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chandelier"
|
||||
},
|
||||
{
|
||||
"Id": 958,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chart"
|
||||
},
|
||||
{
|
||||
"Id": 959,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheeseburger"
|
||||
},
|
||||
{
|
||||
"Id": 960,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chemistry"
|
||||
},
|
||||
{
|
||||
"Id": 961,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chocolate Chip"
|
||||
},
|
||||
{
|
||||
"Id": 962,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cinderella"
|
||||
},
|
||||
{
|
||||
"Id": 963,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Circuit"
|
||||
},
|
||||
{
|
||||
"Id": 964,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Circus"
|
||||
},
|
||||
{
|
||||
"Id": 965,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Close"
|
||||
},
|
||||
{
|
||||
"Id": 966,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cloud"
|
||||
},
|
||||
{
|
||||
"Id": 967,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coat"
|
||||
},
|
||||
{
|
||||
"Id": 968,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cockroach"
|
||||
},
|
||||
{
|
||||
"Id": 969,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Color"
|
||||
},
|
||||
{
|
||||
"Id": 970,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Compass"
|
||||
},
|
||||
{
|
||||
"Id": 971,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Constellation"
|
||||
},
|
||||
{
|
||||
"Id": 972,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Copy"
|
||||
},
|
||||
{
|
||||
"Id": 973,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Corndog"
|
||||
},
|
||||
{
|
||||
"Id": 974,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Count Dracula"
|
||||
},
|
||||
{
|
||||
"Id": 975,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Country"
|
||||
},
|
||||
{
|
||||
"Id": 976,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cucumber"
|
||||
},
|
||||
{
|
||||
"Id": 977,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cupcake"
|
||||
},
|
||||
{
|
||||
"Id": 978,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Curtains"
|
||||
},
|
||||
{
|
||||
"Id": 979,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cymbal"
|
||||
},
|
||||
{
|
||||
"Id": 980,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Daffy Duck"
|
||||
},
|
||||
{
|
||||
"Id": 981,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dance"
|
||||
},
|
||||
{
|
||||
"Id": 982,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dimple"
|
||||
},
|
||||
{
|
||||
"Id": 983,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dinner"
|
||||
},
|
||||
{
|
||||
"Id": 984,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dinosaur"
|
||||
},
|
||||
{
|
||||
"Id": 985,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disk"
|
||||
},
|
||||
{
|
||||
"Id": 986,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dodge"
|
||||
},
|
||||
{
|
||||
"Id": 987,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Door Knob"
|
||||
},
|
||||
{
|
||||
"Id": 988,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dragonfly"
|
||||
},
|
||||
{
|
||||
"Id": 989,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drain"
|
||||
},
|
||||
{
|
||||
"Id": 990,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drawbridge"
|
||||
},
|
||||
{
|
||||
"Id": 991,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dress"
|
||||
},
|
||||
{
|
||||
"Id": 992,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Duck"
|
||||
},
|
||||
{
|
||||
"Id": 993,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ears"
|
||||
},
|
||||
{
|
||||
"Id": 994,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Edge"
|
||||
},
|
||||
{
|
||||
"Id": 995,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eel"
|
||||
},
|
||||
{
|
||||
"Id": 996,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eeyore"
|
||||
},
|
||||
{
|
||||
"Id": 997,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eiffel Tower"
|
||||
},
|
||||
{
|
||||
"Id": 998,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Einstein"
|
||||
},
|
||||
{
|
||||
"Id": 999,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elmo"
|
||||
},
|
||||
{
|
||||
"Id": 1000,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fall"
|
||||
},
|
||||
{
|
||||
"Id": 1001,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Family"
|
||||
},
|
||||
{
|
||||
"Id": 1002,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Faucet"
|
||||
},
|
||||
{
|
||||
"Id": 1003,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Feast"
|
||||
},
|
||||
{
|
||||
"Id": 1004,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Finger"
|
||||
},
|
||||
{
|
||||
"Id": 1005,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire"
|
||||
},
|
||||
{
|
||||
"Id": 1006,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire Hydrant"
|
||||
},
|
||||
{
|
||||
"Id": 1007,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fireplace"
|
||||
},
|
||||
{
|
||||
"Id": 1008,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fish"
|
||||
},
|
||||
{
|
||||
"Id": 1009,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fishing Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1010,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flag Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1011,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flood"
|
||||
},
|
||||
{
|
||||
"Id": 1012,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flute"
|
||||
},
|
||||
{
|
||||
"Id": 1013,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Foot"
|
||||
},
|
||||
{
|
||||
"Id": 1014,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frame"
|
||||
},
|
||||
{
|
||||
"Id": 1015,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frisbee"
|
||||
},
|
||||
{
|
||||
"Id": 1016,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fruit"
|
||||
},
|
||||
{
|
||||
"Id": 1017,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Full Moon"
|
||||
},
|
||||
{
|
||||
"Id": 1018,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Funnel"
|
||||
},
|
||||
{
|
||||
"Id": 1019,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Galaxy"
|
||||
},
|
||||
{
|
||||
"Id": 1020,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Game Boy"
|
||||
},
|
||||
{
|
||||
"Id": 1021,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garbage"
|
||||
},
|
||||
{
|
||||
"Id": 1022,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gate"
|
||||
},
|
||||
{
|
||||
"Id": 1023,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ghostbusters"
|
||||
},
|
||||
{
|
||||
"Id": 1024,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Girlfriend"
|
||||
},
|
||||
{
|
||||
"Id": 1025,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glue"
|
||||
},
|
||||
{
|
||||
"Id": 1026,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goal"
|
||||
},
|
||||
{
|
||||
"Id": 1027,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goldfish"
|
||||
},
|
||||
{
|
||||
"Id": 1028,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goofy"
|
||||
},
|
||||
{
|
||||
"Id": 1029,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Graduation"
|
||||
},
|
||||
{
|
||||
"Id": 1030,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grandma"
|
||||
},
|
||||
{
|
||||
"Id": 1031,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Graph"
|
||||
},
|
||||
{
|
||||
"Id": 1032,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ground"
|
||||
},
|
||||
{
|
||||
"Id": 1033,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hair"
|
||||
},
|
||||
{
|
||||
"Id": 1034,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Haircut"
|
||||
},
|
||||
{
|
||||
"Id": 1035,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Halo"
|
||||
},
|
||||
{
|
||||
"Id": 1036,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Headband"
|
||||
},
|
||||
{
|
||||
"Id": 1037,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Highchair"
|
||||
},
|
||||
{
|
||||
"Id": 1038,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hockey"
|
||||
},
|
||||
{
|
||||
"Id": 1039,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Honey"
|
||||
},
|
||||
{
|
||||
"Id": 1040,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horns"
|
||||
},
|
||||
{
|
||||
"Id": 1041,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horse"
|
||||
},
|
||||
{
|
||||
"Id": 1042,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hour"
|
||||
},
|
||||
{
|
||||
"Id": 1043,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "House"
|
||||
},
|
||||
{
|
||||
"Id": 1044,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Houseboat"
|
||||
},
|
||||
{
|
||||
"Id": 1045,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hug"
|
||||
},
|
||||
{
|
||||
"Id": 1046,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice"
|
||||
},
|
||||
{
|
||||
"Id": 1047,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Cube"
|
||||
},
|
||||
{
|
||||
"Id": 1048,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Fishing"
|
||||
},
|
||||
{
|
||||
"Id": 1049,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Igloo"
|
||||
},
|
||||
{
|
||||
"Id": 1050,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Invitation"
|
||||
},
|
||||
{
|
||||
"Id": 1051,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ironing Board"
|
||||
},
|
||||
{
|
||||
"Id": 1052,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jelly Bean"
|
||||
},
|
||||
{
|
||||
"Id": 1053,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jumbotron"
|
||||
},
|
||||
{
|
||||
"Id": 1054,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jungle"
|
||||
},
|
||||
{
|
||||
"Id": 1055,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Junk Mail"
|
||||
},
|
||||
{
|
||||
"Id": 1056,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lady Bug"
|
||||
},
|
||||
{
|
||||
"Id": 1057,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lake"
|
||||
},
|
||||
{
|
||||
"Id": 1058,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lamp Post"
|
||||
},
|
||||
{
|
||||
"Id": 1059,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leg"
|
||||
},
|
||||
{
|
||||
"Id": 1060,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lego"
|
||||
},
|
||||
{
|
||||
"Id": 1061,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leprechaun"
|
||||
},
|
||||
{
|
||||
"Id": 1062,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Letter Opener"
|
||||
},
|
||||
{
|
||||
"Id": 1063,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lettuce"
|
||||
},
|
||||
{
|
||||
"Id": 1064,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Librarian"
|
||||
},
|
||||
{
|
||||
"Id": 1065,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Library"
|
||||
},
|
||||
{
|
||||
"Id": 1066,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Light Switch"
|
||||
},
|
||||
{
|
||||
"Id": 1067,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lily Pad"
|
||||
},
|
||||
{
|
||||
"Id": 1068,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lunch"
|
||||
},
|
||||
{
|
||||
"Id": 1069,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lunchbox"
|
||||
},
|
||||
{
|
||||
"Id": 1070,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Macaroni"
|
||||
},
|
||||
{
|
||||
"Id": 1071,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Machine"
|
||||
},
|
||||
{
|
||||
"Id": 1072,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magician"
|
||||
},
|
||||
{
|
||||
"Id": 1073,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magnet"
|
||||
},
|
||||
{
|
||||
"Id": 1074,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mail"
|
||||
},
|
||||
{
|
||||
"Id": 1075,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Makeup"
|
||||
},
|
||||
{
|
||||
"Id": 1076,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meat"
|
||||
},
|
||||
{
|
||||
"Id": 1077,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mess"
|
||||
},
|
||||
{
|
||||
"Id": 1078,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meteor"
|
||||
},
|
||||
{
|
||||
"Id": 1079,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Milk"
|
||||
},
|
||||
{
|
||||
"Id": 1080,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mine Cart"
|
||||
},
|
||||
{
|
||||
"Id": 1081,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minnie Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 1082,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minute"
|
||||
},
|
||||
{
|
||||
"Id": 1083,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moat"
|
||||
},
|
||||
{
|
||||
"Id": 1084,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moby Dick"
|
||||
},
|
||||
{
|
||||
"Id": 1085,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monocle"
|
||||
},
|
||||
{
|
||||
"Id": 1086,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 1087,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mud"
|
||||
},
|
||||
{
|
||||
"Id": 1088,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Music"
|
||||
},
|
||||
{
|
||||
"Id": 1089,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Neck"
|
||||
},
|
||||
{
|
||||
"Id": 1090,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Net"
|
||||
},
|
||||
{
|
||||
"Id": 1091,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Night"
|
||||
},
|
||||
{
|
||||
"Id": 1092,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "North Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1093,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ocean"
|
||||
},
|
||||
{
|
||||
"Id": 1094,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Olive Oil"
|
||||
},
|
||||
{
|
||||
"Id": 1095,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Onion"
|
||||
},
|
||||
{
|
||||
"Id": 1096,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Open"
|
||||
},
|
||||
{
|
||||
"Id": 1097,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Page"
|
||||
},
|
||||
{
|
||||
"Id": 1098,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintball"
|
||||
},
|
||||
{
|
||||
"Id": 1099,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintball Gun"
|
||||
},
|
||||
{
|
||||
"Id": 1100,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pancakes"
|
||||
},
|
||||
{
|
||||
"Id": 1101,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper"
|
||||
},
|
||||
{
|
||||
"Id": 1102,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper Clip"
|
||||
},
|
||||
{
|
||||
"Id": 1103,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper Plane"
|
||||
},
|
||||
{
|
||||
"Id": 1104,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peak"
|
||||
},
|
||||
{
|
||||
"Id": 1105,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peanut"
|
||||
},
|
||||
{
|
||||
"Id": 1106,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pen"
|
||||
},
|
||||
{
|
||||
"Id": 1107,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pencil"
|
||||
},
|
||||
{
|
||||
"Id": 1108,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "People"
|
||||
},
|
||||
{
|
||||
"Id": 1109,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Photograph"
|
||||
},
|
||||
{
|
||||
"Id": 1110,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Photographer"
|
||||
},
|
||||
{
|
||||
"Id": 1111,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pigeon"
|
||||
},
|
||||
{
|
||||
"Id": 1112,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piglet"
|
||||
},
|
||||
{
|
||||
"Id": 1113,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pikachu"
|
||||
},
|
||||
{
|
||||
"Id": 1114,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pine Tree"
|
||||
},
|
||||
{
|
||||
"Id": 1115,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pineapple"
|
||||
},
|
||||
{
|
||||
"Id": 1116,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plankton"
|
||||
},
|
||||
{
|
||||
"Id": 1117,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plate"
|
||||
},
|
||||
{
|
||||
"Id": 1118,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pluto"
|
||||
},
|
||||
{
|
||||
"Id": 1119,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Police"
|
||||
},
|
||||
{
|
||||
"Id": 1120,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pony"
|
||||
},
|
||||
{
|
||||
"Id": 1121,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poodle"
|
||||
},
|
||||
{
|
||||
"Id": 1122,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Portal"
|
||||
},
|
||||
{
|
||||
"Id": 1123,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pound"
|
||||
},
|
||||
{
|
||||
"Id": 1124,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Power Line"
|
||||
},
|
||||
{
|
||||
"Id": 1125,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "President"
|
||||
},
|
||||
{
|
||||
"Id": 1126,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pretzel"
|
||||
},
|
||||
{
|
||||
"Id": 1127,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Printer"
|
||||
},
|
||||
{
|
||||
"Id": 1128,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Purse"
|
||||
},
|
||||
{
|
||||
"Id": 1129,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Push Up"
|
||||
},
|
||||
{
|
||||
"Id": 1130,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Quicksand"
|
||||
},
|
||||
{
|
||||
"Id": 1131,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Race"
|
||||
},
|
||||
{
|
||||
"Id": 1132,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Railroad"
|
||||
},
|
||||
{
|
||||
"Id": 1133,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rapunzel"
|
||||
},
|
||||
{
|
||||
"Id": 1134,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rice"
|
||||
},
|
||||
{
|
||||
"Id": 1135,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ring"
|
||||
},
|
||||
{
|
||||
"Id": 1136,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "River"
|
||||
},
|
||||
{
|
||||
"Id": 1137,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocking Chair"
|
||||
},
|
||||
{
|
||||
"Id": 1138,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rollerblade"
|
||||
},
|
||||
{
|
||||
"Id": 1139,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rollercoaster"
|
||||
},
|
||||
{
|
||||
"Id": 1140,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rolling Pin"
|
||||
},
|
||||
{
|
||||
"Id": 1141,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Root Beer"
|
||||
},
|
||||
{
|
||||
"Id": 1142,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sailboat"
|
||||
},
|
||||
{
|
||||
"Id": 1143,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sand"
|
||||
},
|
||||
{
|
||||
"Id": 1144,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sand Castle"
|
||||
},
|
||||
{
|
||||
"Id": 1145,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sandbox"
|
||||
},
|
||||
{
|
||||
"Id": 1146,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saw"
|
||||
},
|
||||
{
|
||||
"Id": 1147,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scooby-Doo"
|
||||
},
|
||||
{
|
||||
"Id": 1148,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seahorse"
|
||||
},
|
||||
{
|
||||
"Id": 1149,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seat Belt"
|
||||
},
|
||||
{
|
||||
"Id": 1150,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shakespeare"
|
||||
},
|
||||
{
|
||||
"Id": 1151,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sheep"
|
||||
},
|
||||
{
|
||||
"Id": 1152,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoelaces"
|
||||
},
|
||||
{
|
||||
"Id": 1153,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoulder"
|
||||
},
|
||||
{
|
||||
"Id": 1154,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shower"
|
||||
},
|
||||
{
|
||||
"Id": 1155,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silhouette"
|
||||
},
|
||||
{
|
||||
"Id": 1156,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sit-Up"
|
||||
},
|
||||
{
|
||||
"Id": 1157,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skunk"
|
||||
},
|
||||
{
|
||||
"Id": 1158,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smartphone"
|
||||
},
|
||||
{
|
||||
"Id": 1159,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sock"
|
||||
},
|
||||
{
|
||||
"Id": 1160,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Solar Eclipse"
|
||||
},
|
||||
{
|
||||
"Id": 1161,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Solar System"
|
||||
},
|
||||
{
|
||||
"Id": 1162,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sombrero"
|
||||
},
|
||||
{
|
||||
"Id": 1163,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sonic the Hedgehog"
|
||||
},
|
||||
{
|
||||
"Id": 1164,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "South"
|
||||
},
|
||||
{
|
||||
"Id": 1165,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Space"
|
||||
},
|
||||
{
|
||||
"Id": 1166,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spin"
|
||||
},
|
||||
{
|
||||
"Id": 1167,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spongebob Squarepants"
|
||||
},
|
||||
{
|
||||
"Id": 1168,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spring"
|
||||
},
|
||||
{
|
||||
"Id": 1169,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spy"
|
||||
},
|
||||
{
|
||||
"Id": 1170,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Square"
|
||||
},
|
||||
{
|
||||
"Id": 1171,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stable"
|
||||
},
|
||||
{
|
||||
"Id": 1172,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stage"
|
||||
},
|
||||
{
|
||||
"Id": 1173,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star"
|
||||
},
|
||||
{
|
||||
"Id": 1174,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Steak"
|
||||
},
|
||||
{
|
||||
"Id": 1175,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stick"
|
||||
},
|
||||
{
|
||||
"Id": 1176,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stingray"
|
||||
},
|
||||
{
|
||||
"Id": 1177,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stomach"
|
||||
},
|
||||
{
|
||||
"Id": 1178,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Storm"
|
||||
},
|
||||
{
|
||||
"Id": 1179,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Strong"
|
||||
},
|
||||
{
|
||||
"Id": 1180,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Suitcase"
|
||||
},
|
||||
{
|
||||
"Id": 1181,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Summer"
|
||||
},
|
||||
{
|
||||
"Id": 1182,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunflower"
|
||||
},
|
||||
{
|
||||
"Id": 1183,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunshine"
|
||||
},
|
||||
{
|
||||
"Id": 1184,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Superhero"
|
||||
},
|
||||
{
|
||||
"Id": 1185,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surfboard"
|
||||
},
|
||||
{
|
||||
"Id": 1186,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sword"
|
||||
},
|
||||
{
|
||||
"Id": 1187,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tailor"
|
||||
},
|
||||
{
|
||||
"Id": 1188,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tall"
|
||||
},
|
||||
{
|
||||
"Id": 1189,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teacher"
|
||||
},
|
||||
{
|
||||
"Id": 1190,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teepee"
|
||||
},
|
||||
{
|
||||
"Id": 1191,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Text"
|
||||
},
|
||||
{
|
||||
"Id": 1192,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Beatles"
|
||||
},
|
||||
{
|
||||
"Id": 1193,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Grinch"
|
||||
},
|
||||
{
|
||||
"Id": 1194,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tigger"
|
||||
},
|
||||
{
|
||||
"Id": 1195,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time Machine"
|
||||
},
|
||||
{
|
||||
"Id": 1196,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toes"
|
||||
},
|
||||
{
|
||||
"Id": 1197,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tongue"
|
||||
},
|
||||
{
|
||||
"Id": 1198,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Towel"
|
||||
},
|
||||
{
|
||||
"Id": 1199,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tower"
|
||||
},
|
||||
{
|
||||
"Id": 1200,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Traffic Light"
|
||||
},
|
||||
{
|
||||
"Id": 1201,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treasure"
|
||||
},
|
||||
{
|
||||
"Id": 1202,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree Branch"
|
||||
},
|
||||
{
|
||||
"Id": 1203,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree Stump"
|
||||
},
|
||||
{
|
||||
"Id": 1204,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trophy"
|
||||
},
|
||||
{
|
||||
"Id": 1205,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tugboat"
|
||||
},
|
||||
{
|
||||
"Id": 1206,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turkey"
|
||||
},
|
||||
{
|
||||
"Id": 1207,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tweety Bird"
|
||||
},
|
||||
{
|
||||
"Id": 1208,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vase"
|
||||
},
|
||||
{
|
||||
"Id": 1209,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vegetable"
|
||||
},
|
||||
{
|
||||
"Id": 1210,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vest"
|
||||
},
|
||||
{
|
||||
"Id": 1211,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Video"
|
||||
},
|
||||
{
|
||||
"Id": 1212,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Virtual Reality"
|
||||
},
|
||||
{
|
||||
"Id": 1213,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waiter"
|
||||
},
|
||||
{
|
||||
"Id": 1214,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waldo"
|
||||
},
|
||||
{
|
||||
"Id": 1215,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watch"
|
||||
},
|
||||
{
|
||||
"Id": 1216,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Water Bottle"
|
||||
},
|
||||
{
|
||||
"Id": 1217,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wave"
|
||||
},
|
||||
{
|
||||
"Id": 1218,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wax"
|
||||
},
|
||||
{
|
||||
"Id": 1219,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wedding Cake"
|
||||
},
|
||||
{
|
||||
"Id": 1220,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wheelbarrow"
|
||||
},
|
||||
{
|
||||
"Id": 1221,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wifi"
|
||||
},
|
||||
{
|
||||
"Id": 1222,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wind"
|
||||
},
|
||||
{
|
||||
"Id": 1223,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Window"
|
||||
},
|
||||
{
|
||||
"Id": 1224,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wing"
|
||||
},
|
||||
{
|
||||
"Id": 1225,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Winnie the Pooh"
|
||||
},
|
||||
{
|
||||
"Id": 1226,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Winter"
|
||||
},
|
||||
{
|
||||
"Id": 1227,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wonder Woman"
|
||||
},
|
||||
{
|
||||
"Id": 1228,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Woody Woodpecker"
|
||||
},
|
||||
{
|
||||
"Id": 1229,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wreath"
|
||||
},
|
||||
{
|
||||
"Id": 1230,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Writing"
|
||||
},
|
||||
{
|
||||
"Id": 1231,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "X-Ray"
|
||||
},
|
||||
{
|
||||
"Id": 1232,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yard"
|
||||
},
|
||||
{
|
||||
"Id": 1233,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bake"
|
||||
},
|
||||
{
|
||||
"Id": 1234,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Black Hole"
|
||||
},
|
||||
{
|
||||
"Id": 1235,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brainstorm"
|
||||
},
|
||||
{
|
||||
"Id": 1236,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buy"
|
||||
},
|
||||
{
|
||||
"Id": 1237,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Donald Duck"
|
||||
},
|
||||
{
|
||||
"Id": 1238,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "E-mail"
|
||||
},
|
||||
{
|
||||
"Id": 1239,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electrician"
|
||||
},
|
||||
{
|
||||
"Id": 1240,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Explore"
|
||||
},
|
||||
{
|
||||
"Id": 1241,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Internet"
|
||||
},
|
||||
{
|
||||
"Id": 1242,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lab"
|
||||
},
|
||||
{
|
||||
"Id": 1243,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Level"
|
||||
},
|
||||
{
|
||||
"Id": 1244,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Marriage"
|
||||
},
|
||||
{
|
||||
"Id": 1245,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mine"
|
||||
},
|
||||
{
|
||||
"Id": 1246,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Movie"
|
||||
},
|
||||
{
|
||||
"Id": 1247,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Programmer"
|
||||
},
|
||||
{
|
||||
"Id": 1248,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scent"
|
||||
},
|
||||
{
|
||||
"Id": 1249,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sell"
|
||||
},
|
||||
{
|
||||
"Id": 1250,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shaggy"
|
||||
},
|
||||
{
|
||||
"Id": 1251,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shelter"
|
||||
},
|
||||
{
|
||||
"Id": 1252,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shrink"
|
||||
},
|
||||
{
|
||||
"Id": 1253,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teleport"
|
||||
},
|
||||
{
|
||||
"Id": 1254,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "USB"
|
||||
},
|
||||
{
|
||||
"Id": 1255,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Virus"
|
||||
},
|
||||
{
|
||||
"Id": 1256,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wedding"
|
||||
},
|
||||
{
|
||||
"Id": 1257,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Go Kart"
|
||||
},
|
||||
{
|
||||
"Id": 1404,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Theater"
|
||||
},
|
||||
{
|
||||
"Id": 1416,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Lucky Charms"
|
||||
},
|
||||
{
|
||||
"Id": 1419,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Acid Rain"
|
||||
},
|
||||
{
|
||||
"Id": 1462,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Dead Batteries"
|
||||
},
|
||||
{
|
||||
"Id": 1487,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Glass"
|
||||
},
|
||||
{
|
||||
"Id": 1629,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tyrannosaurus Rex"
|
||||
},
|
||||
{
|
||||
"Id": 1679,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mothman"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,44 @@ 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 service exposing the same
|
||||
// NotificationsHub RPC surface — enough for the runtime to start and for
|
||||
// notification sends to no-op.
|
||||
workers: [
|
||||
{
|
||||
name: 'notify',
|
||||
modules: true,
|
||||
compatibilityDate: '2026-06-16',
|
||||
compatibilityFlags: ['nodejs_compat'],
|
||||
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||
// notifyPlayer records every call so tests can assert the notifications the
|
||||
// worker pushed (type + payload). GET 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.sent.push({ playerId, notificationType, data })
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
async fetch(request) {
|
||||
if (request.method === 'DELETE') {
|
||||
this.sent = []
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
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') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
+18
-1
@@ -19,13 +19,30 @@
|
||||
}
|
||||
],
|
||||
// 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
|
||||
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||
"class_name": "NotificationsHub",
|
||||
"script_name": "notify"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
|
||||
+148
-25
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
+948
-301
@@ -1,48 +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'
|
||||
|
||||
import { hashPassword, verifyPassword } from './password'
|
||||
// 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
|
||||
@@ -89,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
|
||||
@@ -107,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: '',
|
||||
@@ -128,7 +185,7 @@ async function placeNewPlayerInOrientation(
|
||||
deviceClass,
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: '20230302',
|
||||
appVersion: GAME_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,51 +194,202 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' | '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(
|
||||
'*',
|
||||
@@ -193,307 +401,746 @@ 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())
|
||||
|
||||
// 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',
|
||||
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
].join(' '),
|
||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||
}),
|
||||
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 account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'The one platform id that is never verified and never linked is `1` on platform `1`',
|
||||
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
|
||||
'build reports it, so it identifies nobody. A password login that carries it still',
|
||||
'succeeds; it simply links nothing, and the player types their password each launch.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant. `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 operator’s `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) {
|
||||
|
||||
// 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: 'invalid_request', error_description: 'account_id or username is required' },
|
||||
400
|
||||
{ error: 'server_error', error_description: 'token signing is not configured' },
|
||||
500
|
||||
)
|
||||
}
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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 ?? ''
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
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=',
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = await generateToken(
|
||||
accountId,
|
||||
platformId,
|
||||
platform,
|
||||
await c.env.JWT_SECRET.get()
|
||||
)
|
||||
// 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. No developer role granted by default.
|
||||
.get('/role/developer/:id', (c) => {
|
||||
// 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', describeRoute(roleLookup('developer')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
return c.json({ success: false })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isDeveloper === true)
|
||||
})
|
||||
|
||||
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
|
||||
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
|
||||
// the token's `role` claim.
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('moderator role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isModerator === true)
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
|
||||
* worker, migration 0007). One row per (platform, platform id, account): the Steam
|
||||
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
|
||||
* 42 as well, and both let that player into that account without a password.
|
||||
*
|
||||
* This table replaced the single `platformId`/`platform` pair on the account blob as
|
||||
* the thing logins are decided from, because that pair could only hold ONE identity —
|
||||
* a player with a PC and a headset had to pick which device got a cached login. The
|
||||
* blob fields are kept as the account's *primary* identity (the first one linked) for
|
||||
* the account DTO and the refresh grant's claims; nothing authorizes off them.
|
||||
*
|
||||
* It is deliberately the ONE source of truth for both halves of a cached login: the
|
||||
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
|
||||
* identity, and the `cached_login` grant asks this table whether the account it was
|
||||
* handed is linked to the identity that was proven. When those two disagreed the
|
||||
* client was offered an account it could never log into — see the regression test.
|
||||
*
|
||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||
* account with no password", so an unproven `platform_id` must never reach it.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
)`,
|
||||
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||
// primary key's leading columns, so no separate index is needed for it.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
|
||||
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
|
||||
// resolves ids it has no platform for.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* The one-time backfill 0007 ran after creating the table: every identity already bound
|
||||
* to an account became a link, so nobody lost their cached login at deploy. It has run;
|
||||
* this exists so a test can still exercise it, which is the only coverage that legacy
|
||||
* blob-bound accounts get a link at all.
|
||||
*
|
||||
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
|
||||
* can carry a platformId with no platform recorded, and back when Steam was the only
|
||||
* verifiable platform an unset one *was* Steam.
|
||||
*
|
||||
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
|
||||
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
|
||||
* against the head schema the tests build. This selects the blob directly instead —
|
||||
* the same values, since the dropped column was DEFINED as
|
||||
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
|
||||
*/
|
||||
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
SELECT
|
||||
account_id,
|
||||
COALESCE(json_extract(data, '$.platform'), 0),
|
||||
json_extract(data, '$.platformId'),
|
||||
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||
FROM account
|
||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||
AND json_extract(data, '$.platformId') <> ''`
|
||||
|
||||
/** One account ↔ platform identity link. */
|
||||
export interface PlatformLink {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
/** ISO-8601 time the link was made. */
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
interface LinkRow {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||
linked_at AS linkedAt FROM platform_account`
|
||||
|
||||
/**
|
||||
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
|
||||
* link. Returns true when this created a new link.
|
||||
*
|
||||
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||
* a verified id from a spoofed one.
|
||||
*/
|
||||
export async function linkPlatformIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||
* isn't). Empty id yields nothing rather than matching every link.
|
||||
*/
|
||||
export async function getLinksForPlatformIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Links for a bare platform id, whatever platform it belongs to. For the bulk
|
||||
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
|
||||
* for the single-id route when the client sends a non-numeric platform.
|
||||
*/
|
||||
export async function getLinksForPlatformId(
|
||||
db: D1Database,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
accountId: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||
.bind(accountId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is linked to this platform identity — the single check the
|
||||
* `cached_login` grant authorizes on. An account with no link for the presented
|
||||
* identity cannot be cached-logged-into and must use a password.
|
||||
*/
|
||||
export async function isPlatformIdentityLinked(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||
)
|
||||
.bind(accountId, platform, platformId)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one platform identity already opens — the count both signup caps
|
||||
* and link caps are enforced against, so an identity can't accumulate accounts by
|
||||
* creating them under the cap and then linking more in.
|
||||
*/
|
||||
export async function countAccountsForPlatformIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<number> {
|
||||
if (platformId === '') return 0
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
|
||||
.bind(platform, platformId)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
+17
-30
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,10 +4,28 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../auth.app'
|
||||
|
||||
import { getAccountsByDeviceId, 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 { hashPassword } from '../../password'
|
||||
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'
|
||||
@@ -25,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()
|
||||
|
||||
@@ -43,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, '/')
|
||||
@@ -97,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`, {
|
||||
@@ -118,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(
|
||||
@@ -135,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(
|
||||
@@ -147,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')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -171,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.
|
||||
@@ -186,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([
|
||||
@@ -199,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 () => {
|
||||
@@ -240,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)
|
||||
@@ -257,9 +515,52 @@ 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')
|
||||
// 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')
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps developer/moderator roles into the token', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 91,
|
||||
username: 'StaffPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
isDeveloper: true,
|
||||
isModerator: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
|
||||
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)
|
||||
@@ -478,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 () => {
|
||||
@@ -496,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
|
||||
@@ -569,14 +1058,347 @@ describe('auth worker routes', () => {
|
||||
expect(await rotate.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id does not grant developer', async () => {
|
||||
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: false })
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id returns a bare true when the account is flagged', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/4242`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id 404s for an unknown player', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/99999`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /role/moderator/:id reflects the isModerator flag as a bare boolean', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
|
||||
.run()
|
||||
const granted = await exports.default.fetch(`${ORIGIN}/role/moderator/4343`)
|
||||
expect(await granted.json()).toBe(true)
|
||||
// An account without the flag (42) is not a moderator.
|
||||
const plain = await exports.default.fetch(`${ORIGIN}/role/moderator/42`)
|
||||
expect(await plain.json()).toBe(false)
|
||||
// Unknown player → 404.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
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 account’s 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 account’s 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 account’s 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 request’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 client’s 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 subroom’s `DataBlob` (see the `rooms`',
|
||||
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
|
||||
'slashes.',
|
||||
'',
|
||||
'A room’s 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 invention’s',
|
||||
'`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 don’t 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
|
||||
|
||||
@@ -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 tip’s 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(),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,142 @@
|
||||
[
|
||||
{
|
||||
"Name": "bf5d8d41a7184e779399ff45a9e8785a",
|
||||
"Title": "Welcome!",
|
||||
"Message": "Thanks for trying RecFlare alpha!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2026-07-11T18:27:25Z"
|
||||
}
|
||||
]
|
||||
{
|
||||
"Name": "bf5d8d41a7184e779399ff45a9e8785a",
|
||||
"Title": "Rec Room Tokens",
|
||||
"Message": "Redeem your Rec Room Tokens for all kinds of fun rewards! You can shop at the Rec Center Merch Booth or the Store section of your Watch Menu.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
{
|
||||
"Name": "3801fdd940334b85a7460ffdda73f9de",
|
||||
"Title": "Become a Star!",
|
||||
"Message": "Use #RecRoom on your Instagram and Twitter posts for a chance to make it onto our Community Board!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
{
|
||||
"Name": "d517b46a3f734db39d77f2a15b105495",
|
||||
"Title": "Find Your Style",
|
||||
"Message": "Personalize your outfit and appearance in your Dorm Room.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-03-02T00:12:59Z"
|
||||
},
|
||||
{
|
||||
"Name": "bc8569de432a4cc4b0e69d1b619921cb",
|
||||
"Title": "Room Cheers",
|
||||
"Message": "Cheer and Favorite any room in the This Room section of your Watch Menu.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:08:56Z"
|
||||
},
|
||||
{
|
||||
"Name": "4a9bec133a26457280eb30887e9442d9",
|
||||
"Title": "Daily Challenges",
|
||||
"Message": "Check out the Challenges section in your watch for fun ways to earn in-game rewards.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:22:44Z"
|
||||
},
|
||||
{
|
||||
"Name": "511adb340964437cbbe4ad382a658b52",
|
||||
"Title": "Welcome to Rec Room",
|
||||
"Message": "Rec Room is a virtual social club where you create and play games with friends. It's a fun and welcoming environment for people from all walks of life!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:28:32Z"
|
||||
},
|
||||
{
|
||||
"Name": "0ec7868f309843158bfb7fd94fd2a80a",
|
||||
"Title": "Join the Rec Room Community!",
|
||||
"Message": "See recroom.com/community for links to Rec Room's YouTube, Instagram, Discord, and MORE!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:24:33Z"
|
||||
},
|
||||
{
|
||||
"Name": "f0c49e1d5ce146638f1cb53f5d4f0bb2",
|
||||
"Title": "Play on the go!",
|
||||
"Message": "Download Rec Room on your iOS device and play anywhere!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 131,
|
||||
"CreatedAt": "2019-12-21T01:24:23Z"
|
||||
},
|
||||
{
|
||||
"Name": "ae71ae948ce041bba799ee8d6ee9a0ba",
|
||||
"Title": "Laser Tag Merch",
|
||||
"Message": "You earn tickets for every game of Laser Tag. Redeem them for awesome Laser Tag gear!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:07:50Z"
|
||||
},
|
||||
{
|
||||
"Name": "169b34a66ed046ba84cb2dbf43562a26",
|
||||
"Title": "We're all on Rec.Net!",
|
||||
"Message": "Log into your Rec.Net profile to stay in touch with your friends any time!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 1,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 239,
|
||||
"CreatedAt": "2019-02-28T18:21:25Z"
|
||||
}
|
||||
]
|
||||
+10
-1
@@ -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/`.
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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 isn’t 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 sender’s own',
|
||||
'`lastReadMessageId` comes back already at the message just posted. Pushes',
|
||||
'ChatMessageReceived to every member, the sender included — the client doesn’t fold the',
|
||||
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
|
||||
'message doesn’t appear until the thread is refetched. Note the hub frame’s `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 caller’s thread list',
|
||||
description: [
|
||||
'Every thread the caller is a member of, newest conversation first — each carrying its',
|
||||
'`latestMessage` 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.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatThreadDto.array(), 'The caller’s 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 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 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 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). 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.',
|
||||
].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 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.',
|
||||
].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 isn’t 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 caller’s 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 caller’s own inbox and',
|
||||
'leaves everyone else’s 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',
|
||||
'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.',
|
||||
].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 caller’s read pointer to the thread’s latest message. The pointer only moves',
|
||||
'forward and never past the thread’s real latest message, so an id the client made up',
|
||||
'can’t strand the thread as permanently read. 404s for a thread the caller isn’t 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',
|
||||
'isn’t 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 thread’s 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 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 `[]`.',
|
||||
].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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 thread’s 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 thread’s `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
|
||||
}
|
||||
@@ -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 <<<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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 club’s id (digits only — a non-numeric id doesn’t 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 player’s 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 player’s 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 player’s home club',
|
||||
description: [
|
||||
'Points the player’s home club at the posted `clubId`. They must already be a member',
|
||||
'of it — you can’t make a club you don’t 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 isn’t 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 player’s 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 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.',
|
||||
].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 caller’s 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 isn’t 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 player’s 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 club’s announcements',
|
||||
description: [
|
||||
'The club’s 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 club’s 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 envelope’s `value` is the new announcement’s id.',
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(AnnouncementRequest, 'The announcement fields'),
|
||||
responses: {
|
||||
200: json(AnnouncementIdEnvelope, 'The new announcement’s 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 caller’s 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 caller’s 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 club’s Creator. Answers the `{ error, success, value }` envelope carrying the new',
|
||||
'club’s full details — not a bare club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateClubRequest, 'The new club’s fields'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The new club’s 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 club’s details',
|
||||
description: [
|
||||
'The client PUTs a form of just the fields it’s 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 club’s 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 can’t 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 club’s 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 club’s full details',
|
||||
description: [
|
||||
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
|
||||
'caller’s 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 club’s 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 club’s 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 club’s 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 club’s 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 club’s 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 club’s 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” doesn’t 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 club’s 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 club’s 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 club’s 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 club’s main image',
|
||||
description: [
|
||||
'Sets the main image from an uploaded image’s `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 image’s name'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s 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 club’s 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 there’s never a blank slot. DELETE ignores any body (so it can’t',
|
||||
'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 client’s 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 (0–2)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: form(ImageNameRequest, 'The uploaded image’s name (PUT only)'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'The index is past the club’s 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 who’d set it. 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. The envelope’s `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 club’s 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 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`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s 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-owner’s “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 can’t mint another Creator (100) or change the club’s 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 club’s 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 can’t change the club’s 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 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).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s membership gone'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The creator can’t 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',
|
||||
'can’t 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 can’t disagree about who’s 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 can’t 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 player’s 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 club’s 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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 club’s 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 caller’s 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 announcement’s 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 club’s 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(),
|
||||
})
|
||||
@@ -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 club’s 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 creator’s 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
A Cloudflare Workers application using Hono
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `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
|
||||
|
||||
### Run in dev mode
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +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'
|
||||
|
||||
@@ -9,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(
|
||||
'*',
|
||||
@@ -23,10 +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',
|
||||
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 client’s `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',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Current purchase campaigns',
|
||||
description: [
|
||||
'Limited-time offers and promos. Always `[]` — none exist, and an empty list is the',
|
||||
'client’s “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',
|
||||
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 client’s “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
|
||||
|
||||
@@ -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 client’s 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'),
|
||||
})
|
||||
@@ -17,4 +17,82 @@ describe('commerce endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
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)
|
||||
const skus = (await res.json()) as Array<{ skuId: number }>
|
||||
expect(Array.isArray(skus)).toBe(true)
|
||||
expect(skus.length).toBeGreaterThan(0)
|
||||
expect(skus[0]).toHaveProperty('skuId')
|
||||
})
|
||||
|
||||
it('GET /purchasecampaign/allcurrent/v2 returns []', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /reminder/currentTokenBundles/v2 returns []', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/reminder/currentTokenBundles/v2`)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
[
|
||||
{
|
||||
"skuId": 178,
|
||||
"name": "500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "0j3qprcooizhw1fw37vbcxj5b.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "TK0007",
|
||||
"appleProductId": "iTK007",
|
||||
"googlePlaySkuId": "tk0007",
|
||||
"picoSkuId": "TK007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14248
|
||||
],
|
||||
"message": "500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 184,
|
||||
"name": "Special Offer 9",
|
||||
"description": "",
|
||||
"imageName": "6lp54v2yy3te4xd4ilbl9gq1f.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "CO1009",
|
||||
"xboxProductId": "43474e39-5143-3035-c04c-5a47484c5b00",
|
||||
"xboxStoreId": "9NGCCQ5LZGHL",
|
||||
"appleProductId": "CO0009",
|
||||
"googlePlaySkuId": "co0009",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 182,
|
||||
"name": "Techwear Samurai Bundle",
|
||||
"description": "Techwear Samurai Bundle",
|
||||
"imageName": "dcsac8vyi7dewkwwh9uobi422.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "TK0007",
|
||||
"appleProductId": "iTK007",
|
||||
"googlePlaySkuId": "tk0007",
|
||||
"picoSkuId": "TK007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Techwear Samurai Bundle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 183,
|
||||
"name": "Maker AI Timed Access One Day",
|
||||
"description": "Grants one day of Maker AI access, timer starts after purchase completion",
|
||||
"imageName": "0b8qpn5ghc8wtwqnnb7fpmcwz.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "MD0001",
|
||||
"xboxProductId": "4a464e39-5835-3053-c046-475a53528300",
|
||||
"xboxStoreId": "9NFJ5XSFGZSR",
|
||||
"appleProductId": "MD0001",
|
||||
"googlePlaySkuId": "md0001",
|
||||
"picoSkuId": "MD0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "One day of Maker AI access granted!"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 185,
|
||||
"name": "Special Offer 10",
|
||||
"description": "",
|
||||
"imageName": "d8sqdpzxny5sgoebk3wfl976c.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "CO0010",
|
||||
"xboxProductId": "48544e39-4647-304e-c033-325642524d00",
|
||||
"xboxStoreId": "9NTHGFN32VBR",
|
||||
"appleProductId": "CO0010",
|
||||
"googlePlaySkuId": "co0010",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 179,
|
||||
"name": "1,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "535sxig13awyltiiu1tpc28em.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "TK0008",
|
||||
"appleProductId": "iTK008",
|
||||
"googlePlaySkuId": "tk0008",
|
||||
"picoSkuId": "TK008",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14249
|
||||
],
|
||||
"message": "1,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 23,
|
||||
"name": "Special Offer 1",
|
||||
"description": "",
|
||||
"imageName": "33q0sd57vofyqyd8559zud6s5.png",
|
||||
"price": 299,
|
||||
"oculusSkuId": "CO0001",
|
||||
"xboxProductId": "57434e39-5137-3051-c04e-3631384e3500",
|
||||
"xboxStoreId": "9NCW7QQN618N",
|
||||
"appleProductId": "CO0001",
|
||||
"googlePlaySkuId": "co0001",
|
||||
"picoSkuId": "CO001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 177,
|
||||
"name": "1,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "bsvmr0bvc8koxe19a731sy2jt.png",
|
||||
"price": 299,
|
||||
"oculusSkuId": "TK0005",
|
||||
"xboxProductId": "56334e39-5348-3050-c035-533647373700",
|
||||
"xboxStoreId": "9N3VHSP5S6G7",
|
||||
"appleProductId": "iTK005",
|
||||
"googlePlaySkuId": "tk0005",
|
||||
"picoSkuId": "TK005",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
4463
|
||||
],
|
||||
"message": "1,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 186,
|
||||
"name": "Special Offer 11",
|
||||
"description": "",
|
||||
"imageName": "5msy6rgvumgv64hi25bpe9zef.png",
|
||||
"price": 399,
|
||||
"oculusSkuId": "CO0011",
|
||||
"xboxProductId": "504c4e39-5051-304a-c058-44525054a000",
|
||||
"xboxStoreId": "9NLPQPJXDRPT",
|
||||
"appleProductId": "CO0011",
|
||||
"googlePlaySkuId": "co0011",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 180,
|
||||
"name": "2,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "89brexfrkyxx7a1fqtb7vimtp.png",
|
||||
"price": 399,
|
||||
"oculusSkuId": "TK0009",
|
||||
"appleProductId": "iTK009",
|
||||
"googlePlaySkuId": "tk0009",
|
||||
"picoSkuId": "TK009",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14250
|
||||
],
|
||||
"message": "2,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 181,
|
||||
"name": "Techwear Samurai Bundle",
|
||||
"description": "Techwear Samurai Bundle",
|
||||
"imageName": "dmrxioogofer7d7djcvvt6l6o.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "TK0001",
|
||||
"psnProductLabel": "TK00010000000000",
|
||||
"psnEntitlementLabel": "TK0001",
|
||||
"xboxProductId": "4e444e39-5651-3046-c044-33484b374700",
|
||||
"xboxStoreId": "9NDNQVFD3HK7",
|
||||
"appleProductId": "iTK001",
|
||||
"googlePlaySkuId": "tk0001",
|
||||
"picoSkuId": "TK001",
|
||||
"nintendoSkuId": "TK0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Techwear Samurai Bundle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 2,
|
||||
"name": "2,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "c8nb2o470fwyyiccr4fuiv4k9.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "TK0001",
|
||||
"psnProductLabel": "TK00010000000000",
|
||||
"psnEntitlementLabel": "TK0001",
|
||||
"xboxProductId": "4e444e39-5651-3046-c044-33484b374700",
|
||||
"xboxStoreId": "9NDNQVFD3HK7",
|
||||
"appleProductId": "iTK001",
|
||||
"googlePlaySkuId": "tk0001",
|
||||
"picoSkuId": "TK001",
|
||||
"nintendoSkuId": "TK0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2632
|
||||
],
|
||||
"message": "2,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 24,
|
||||
"name": "Special Offer 2",
|
||||
"description": "",
|
||||
"imageName": "0snwnkn7l94klxot4hkha93ot.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "CO0002",
|
||||
"xboxProductId": "32574e39-3250-304d-c048-445242363500",
|
||||
"xboxStoreId": "9NW2P2MHDRB6",
|
||||
"appleProductId": "CO0002",
|
||||
"googlePlaySkuId": "co0002",
|
||||
"picoSkuId": "CO002",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 10,
|
||||
"name": "Starter Pack",
|
||||
"description": "5,500 Tokens\r\nPizza, Pop, & a Potion\r\nExclusive White Hoodie",
|
||||
"imageName": "7jeywaubvdew56wlq2bazbeof.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "SP0001",
|
||||
"psnProductLabel": "SP00010000000000",
|
||||
"psnEntitlementLabel": "SP0001",
|
||||
"xboxProductId": "57395039-5332-304b-c032-4239434b2400",
|
||||
"xboxStoreId": "9P9W2SK2B9CK",
|
||||
"appleProductId": "iSP001",
|
||||
"googlePlaySkuId": "sp0001",
|
||||
"isSingleUse": true,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2636,
|
||||
2011,
|
||||
2025,
|
||||
2010,
|
||||
2548
|
||||
],
|
||||
"message": "Starter Pack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 187,
|
||||
"name": "Special Offer 12",
|
||||
"description": "",
|
||||
"imageName": "a5owgnqsbvpw0wm6im1rdtsp3.png",
|
||||
"price": 599,
|
||||
"oculusSkuId": "CO0012",
|
||||
"xboxProductId": "4d474e39-3046-3047-c031-544742311700",
|
||||
"xboxStoreId": "9NGMF0G1TGB1",
|
||||
"appleProductId": "CO0012",
|
||||
"googlePlaySkuId": "co0012",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 188,
|
||||
"name": "Special Offer 13",
|
||||
"description": "",
|
||||
"imageName": "dn4gs1ie40kfhoruy7jmkfey4.png",
|
||||
"price": 699,
|
||||
"oculusSkuId": "CO0013",
|
||||
"xboxProductId": "364e4e39-4334-3044-c04a-385257352600",
|
||||
"xboxStoreId": "9NN64CDJ8RW5",
|
||||
"appleProductId": "CO0013",
|
||||
"googlePlaySkuId": "co0013",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 189,
|
||||
"name": "Special Offer 14",
|
||||
"description": "",
|
||||
"imageName": "3fbwxtc1flb1ngd71l2vudklx.png",
|
||||
"price": 799,
|
||||
"oculusSkuId": "CO0014",
|
||||
"xboxProductId": "43314e39-3235-3039-c050-573158562100",
|
||||
"xboxStoreId": "9N1C529PW1XV",
|
||||
"appleProductId": "CO0014",
|
||||
"googlePlaySkuId": "co0014",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 14"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 11,
|
||||
"name": "Rec Room Plus Membership",
|
||||
"description": "Rec Room Plus Membership",
|
||||
"imageName": "304tyaxvb6d8s3dcef8s3y4pb.png",
|
||||
"price": 799,
|
||||
"oculusSkuId": "PM0001",
|
||||
"psnProductLabel": "PM00010000000000",
|
||||
"psnEntitlementLabel": "PM0001",
|
||||
"xboxProductId": "54465039-534b-3044-c04a-564e32517600",
|
||||
"xboxStoreId": "9PFTKSDJVN2Q",
|
||||
"appleProductId": "iCC001",
|
||||
"googlePlaySkuId": "pm0001",
|
||||
"nintendoSkuId": "a26f976e026d86e1",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 2,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14258
|
||||
],
|
||||
"message": "Rec Room Plus Membership",
|
||||
"subscriptionPurchase": {
|
||||
"type": 0,
|
||||
"level": 0,
|
||||
"period": 0,
|
||||
"isAutoRenewing": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 190,
|
||||
"name": "Special Offer 15",
|
||||
"description": "",
|
||||
"imageName": "du9cs8uv5buib1ajgwbdcibtp.png",
|
||||
"price": 899,
|
||||
"oculusSkuId": "CO0015",
|
||||
"xboxProductId": "36534e39-5435-3047-c031-585a57435d00",
|
||||
"xboxStoreId": "9NS65TG1XZWC",
|
||||
"appleProductId": "CO0015",
|
||||
"googlePlaySkuId": "co0015",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 15"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 25,
|
||||
"name": "Special Offer 3",
|
||||
"description": "",
|
||||
"imageName": "7yr1khevvss841xrwahj6hfn9.png",
|
||||
"price": 999,
|
||||
"oculusSkuId": "CO0003",
|
||||
"xboxProductId": "36344e39-4747-3051-c044-4d3634350000",
|
||||
"xboxStoreId": "9N46GGQDM645",
|
||||
"appleProductId": "CO0003",
|
||||
"googlePlaySkuId": "co0003",
|
||||
"picoSkuId": "CO003",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 5,
|
||||
"name": "5,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "0k5tpvs9p5tp50dfn4tq1j72l.png",
|
||||
"price": 999,
|
||||
"oculusSkuId": "TK0002",
|
||||
"psnProductLabel": "TK00020000000000",
|
||||
"psnEntitlementLabel": "TK0002",
|
||||
"xboxProductId": "44544d39-5153-3030-c044-4a5734576200",
|
||||
"xboxStoreId": "9MTDSQ0DJW4W",
|
||||
"appleProductId": "iTK002",
|
||||
"googlePlaySkuId": "tk0002",
|
||||
"picoSkuId": "TK002",
|
||||
"nintendoSkuId": "TK0002",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2633
|
||||
],
|
||||
"message": "5,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 26,
|
||||
"name": "Special Offer 4",
|
||||
"description": "",
|
||||
"imageName": "ev9ynntwhgonosztxuk6r75vq.png",
|
||||
"price": 1499,
|
||||
"oculusSkuId": "CO0004",
|
||||
"xboxProductId": "52465039-4342-3056-c04e-465847538200",
|
||||
"xboxStoreId": "9PFRBCVNFXGS",
|
||||
"appleProductId": "CO0004",
|
||||
"googlePlaySkuId": "co0004",
|
||||
"picoSkuId": "CO004",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 27,
|
||||
"name": "Special Offer 5",
|
||||
"description": "",
|
||||
"imageName": "3exoho63d3xtnx6z8eiudm3yl.png",
|
||||
"price": 1999,
|
||||
"oculusSkuId": "CO0005",
|
||||
"xboxProductId": "5a514e39-3544-3043-c035-5434324a2700",
|
||||
"xboxStoreId": "9NQZD5C5T42J",
|
||||
"appleProductId": "CO0005",
|
||||
"googlePlaySkuId": "co0005",
|
||||
"picoSkuId": "CO005",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 8,
|
||||
"name": "12,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "2cxy6mghew6po1qyl2hjjp1dz.png",
|
||||
"price": 1999,
|
||||
"oculusSkuId": "TK0003",
|
||||
"psnProductLabel": "TK00030000000000",
|
||||
"psnEntitlementLabel": "TK0003",
|
||||
"xboxProductId": "38434e39-5447-3032-c054-503747433400",
|
||||
"xboxStoreId": "9NC8GT2TP7GC",
|
||||
"appleProductId": "iTK003",
|
||||
"googlePlaySkuId": "tk0003",
|
||||
"picoSkuId": "TK003",
|
||||
"nintendoSkuId": "TK0003",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2634
|
||||
],
|
||||
"message": "12,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 191,
|
||||
"name": "Special Offer 16",
|
||||
"description": "",
|
||||
"imageName": "de614ysh9h08jgt6rm7xtl8g1.png",
|
||||
"price": 2499,
|
||||
"oculusSkuId": "CO0016",
|
||||
"xboxProductId": "47314e39-3557-3043-c052-345233573000",
|
||||
"xboxStoreId": "9N1GW5CR4R3W",
|
||||
"appleProductId": "CO0016",
|
||||
"googlePlaySkuId": "co0016",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 192,
|
||||
"name": "Special Offer 17",
|
||||
"description": "",
|
||||
"imageName": "e2pwk2sqieip5vcbd5bmf5qek.png",
|
||||
"price": 2999,
|
||||
"oculusSkuId": "CO0017",
|
||||
"xboxProductId": "4e465039-464e-3047-c04d-374351334300",
|
||||
"xboxStoreId": "9PFNNFGM7CQ3",
|
||||
"appleProductId": "CO0017",
|
||||
"googlePlaySkuId": "co0017",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 9,
|
||||
"name": "35,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "8okk17qjqs25tavj04fzqzski.png",
|
||||
"price": 4999,
|
||||
"oculusSkuId": "TK0004",
|
||||
"psnProductLabel": "TK00040000000000",
|
||||
"psnEntitlementLabel": "TK0004",
|
||||
"xboxProductId": "36524e39-314d-3034-c039-314b5230f800",
|
||||
"xboxStoreId": "9NR6M1491KR0",
|
||||
"appleProductId": "iTK004",
|
||||
"googlePlaySkuId": "tk0004",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2635
|
||||
],
|
||||
"message": "35,000 Tokens!"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 28,
|
||||
"name": "Special Offer 6",
|
||||
"description": "",
|
||||
"imageName": "9xtn0r4ol3cszw6n3nmdtjqpc.png",
|
||||
"price": 4999,
|
||||
"oculusSkuId": "CO0006",
|
||||
"xboxProductId": "4a395039-4437-3054-c042-4d3847432c00",
|
||||
"xboxStoreId": "9P9J7DTBM8GC",
|
||||
"appleProductId": "CO0006",
|
||||
"googlePlaySkuId": "co0006",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 29,
|
||||
"name": "Special Offer 7",
|
||||
"description": "",
|
||||
"imageName": "brrnvugan0vtdz5ha58xsydsk.png",
|
||||
"price": 6999,
|
||||
"oculusSkuId": "CO0007",
|
||||
"xboxProductId": "42534e39-4230-3048-c047-46504d373700",
|
||||
"xboxStoreId": "9NSB0BHGFPM7",
|
||||
"appleProductId": "CO0007",
|
||||
"googlePlaySkuId": "co0007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 193,
|
||||
"name": "Special Offer 18",
|
||||
"description": "",
|
||||
"imageName": "ct4fpwoznnerrdjwuii3vmlxk.png",
|
||||
"price": 7499,
|
||||
"oculusSkuId": "CO0018",
|
||||
"xboxProductId": "5a485039-4e56-304e-c039-5a3735324e00",
|
||||
"xboxStoreId": "9PHZVNN9Z752",
|
||||
"appleProductId": "CO0018",
|
||||
"googlePlaySkuId": "co0018",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 30,
|
||||
"name": "99.99 Special Offer",
|
||||
"description": "",
|
||||
"imageName": "5gv9upz5ndauzgwgn8x4lohg6.png",
|
||||
"price": 9999,
|
||||
"oculusSkuId": "CO0008",
|
||||
"xboxProductId": "4b4a4e39-4d51-304e-c054-5a444d569d00",
|
||||
"xboxStoreId": "9NJKQMNTZDMV",
|
||||
"appleProductId": "CO0008",
|
||||
"googlePlaySkuId": "co0008",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "$99.99 Special Offer"
|
||||
}
|
||||
}
|
||||
]
|
||||
+455
-43
@@ -1,50 +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: owned
|
||||
items concatenated with the default catalog. No DB binding yet, so owned is
|
||||
empty and this returns 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]`. Pending gifts; `[]` without a DB.
|
||||
- `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]`. `[]` without a DB.
|
||||
- `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/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) |
|
||||
|
||||
- Wire a DB binding and prepend each player's owned `AvatarItems` to
|
||||
`/api/avatar/v4/items`.
|
||||
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 8–40 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 30–40 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.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Received gift boxes, owned by the `econ` worker. One row per box: a box is created
|
||||
-- when a player buys a storefront item (`/api/storefronts/v2/buyItem`) and deleted when
|
||||
-- the client opens it (`/api/avatar/v2/gifts/consume`, on the `api` worker). Opening is
|
||||
-- cosmetic — the item is granted into the `inventory` table at purchase time, so a box
|
||||
-- carries only its rendered content (`data`) for the gift list. Kept in sync with
|
||||
-- RECEIVED_GIFT_SCHEMA_DDL in @repo/domain's gifts-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS received_gift (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Owned avatar items, owned by the `econ` worker. One row per (account, item): the
|
||||
-- items a player has bought from a storefront. Granted at purchase time by
|
||||
-- `/api/storefronts/v2/buyItem` and read back by `/api/avatar/v4/items`, where they are
|
||||
-- concatenated with the default catalog. The item is keyed by its `AvatarItemDesc` (the
|
||||
-- gift-drop's item guid string) so re-buying the same item is a no-op rather than a
|
||||
-- duplicate row; `data` is the rendered avatar-item DTO. Kept in sync with
|
||||
-- INVENTORY_SCHEMA_DDL in src/inventory-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
account_id INTEGER NOT NULL,
|
||||
avatar_item_desc TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, avatar_item_desc)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Owned consumables, owned by the `econ` worker. Unlike avatar items (own-once, one
|
||||
-- row per (account, item)), consumables stack: each purchase inserts a fresh instance
|
||||
-- row carrying its own id, count and created_at. Granted at purchase time by
|
||||
-- `/api/storefronts/v2/buyItem` (when the gift-drop carries a `ConsumableItemDesc`) and
|
||||
-- read back by `/api/consumables/v2/getUnlocked`, which groups a player's rows by
|
||||
-- `consumable_item_desc` into the client's unlocked-consumable DTO (its `Ids`/`CreatedAts`
|
||||
-- are these per-instance columns; `Count` their sum). Kept in sync with
|
||||
-- CONSUMABLE_SCHEMA_DDL in src/consumables-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consumable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
consumable_item_desc TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id);
|
||||
@@ -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)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user