mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5010f62371 |
@@ -1,249 +0,0 @@
|
|||||||
---
|
|
||||||
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`.
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
---
|
|
||||||
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`
|
|
||||||
+2
-50
@@ -1,19 +1,9 @@
|
|||||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>. Used by
|
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
||||||
# `just dev` too, so a locally-run worker hands out the same addresses it would deployed.
|
|
||||||
RECFLARE_DOMAIN=rec.example.com
|
RECFLARE_DOMAIN=rec.example.com
|
||||||
|
|
||||||
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
||||||
# worker's directory name. Defaults to the directory name when unset. Use "@" to
|
# worker's directory name. Defaults to the directory name when unset.
|
||||||
# put a worker on the APEX of the domain rather than a subdomain.
|
|
||||||
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
|
# 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
|
# Id of the shared `recflare` D1 database (create it manually with
|
||||||
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
||||||
@@ -62,20 +52,6 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
||||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=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`).
|
# 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,
|
# 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.
|
# it just stops new ones. Set either to 0 to turn that cap off.
|
||||||
@@ -84,31 +60,7 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
||||||
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
|
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
|
||||||
|
|
||||||
# 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`).
|
# 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 —
|
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||||
# raising it later does NOT top up existing players.
|
# raising it later does NOT top up existing players.
|
||||||
# RECFLARE_STARTING_TOKENS=10000
|
# 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.
|
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ inconsistency here without checking the client first.
|
|||||||
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
|
- 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
|
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.
|
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
|
- 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
|
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
|
||||||
clubhouse on screen until it answered the full details envelope.
|
clubhouse on screen until it answered the full details envelope.
|
||||||
@@ -99,50 +94,14 @@ inconsistency here without checking the client first.
|
|||||||
publish: no publish step exists in the client for them. Saves live in the
|
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 —
|
`subroom_save` table with globally-unique ids (a bare id has to resolve —
|
||||||
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
||||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
|
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
|
||||||
|
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
|
||||||
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
|
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
|
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
|
||||||
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
|
`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
|
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
|
`/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.
|
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
|
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||||
|
|||||||
+6
-84
@@ -43,12 +43,10 @@ services but would require small code changes.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
**You must have all these requirements or RecFlare deployment will fail!**
|
- node (modern)
|
||||||
|
- pnpm
|
||||||
- node 24 (https://nodejs.org)
|
- bun
|
||||||
- pnpm (install with `npm install -g pnpm`)
|
- jq/awk/sed
|
||||||
- bun (https://bun.sh)
|
|
||||||
- jq/awk/sed (on Windows try `winget jq` etc.)
|
|
||||||
- A Cloudflare account with a zone (domain) you control, for deploying.
|
- A Cloudflare account with a zone (domain) you control, for deploying.
|
||||||
|
|
||||||
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
||||||
@@ -67,8 +65,6 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
|
|||||||
just 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:**
|
**Configure your custom domain:**
|
||||||
|
|
||||||
Create a new .env file from the template:
|
Create a new .env file from the template:
|
||||||
@@ -81,11 +77,11 @@ Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export
|
|||||||
|
|
||||||
(Optional) - per-app subdomain overrides come from
|
(Optional) - per-app subdomain overrides come from
|
||||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
||||||
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
|
if you wanted to merge two services together.
|
||||||
|
|
||||||
**Create the storage resources:**
|
**Create the storage resources:**
|
||||||
|
|
||||||
The workers bind Cloudflare storage primitives. Create them once against your
|
The workers bind Cloudflare storage primitive. Create them once against your
|
||||||
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
|
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
|
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
|
||||||
nothing in version control needs editing. Authenticate wrangler first
|
nothing in version control needs editing. Authenticate wrangler first
|
||||||
@@ -108,24 +104,6 @@ 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
|
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!
|
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -191,7 +169,6 @@ 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_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
|
||||||
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
|
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
|
||||||
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
|
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
|
||||||
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
|
||||||
|
|
||||||
Then deploy just the worker that reads it:
|
Then deploy just the worker that reads it:
|
||||||
|
|
||||||
@@ -222,61 +199,6 @@ 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
|
> `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.
|
> 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
|
## Repository Structure
|
||||||
|
|
||||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||||
|
|||||||
@@ -71,17 +71,6 @@ preview:
|
|||||||
deploy *args:
|
deploy *args:
|
||||||
bun turbo deploy "$@"
|
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`
|
# 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`.
|
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
|
||||||
[group('2. local dev')]
|
[group('2. local dev')]
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,18 +11,9 @@ import {
|
|||||||
searchAccounts,
|
searchAccounts,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import {
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
logger,
|
|
||||||
withCleanSpec,
|
|
||||||
withDefaultCors,
|
|
||||||
withNotFound,
|
|
||||||
withOnError,
|
|
||||||
} from '@repo/hono-helpers'
|
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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 {
|
import {
|
||||||
AccountDto,
|
AccountDto,
|
||||||
BioRequest,
|
BioRequest,
|
||||||
@@ -81,11 +72,6 @@ const DEFAULT_USERNAME_CHANGES = 1
|
|||||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||||
* On success `value` is the updated account; on error `error` carries the message
|
* On success `value` is the updated account; on error `error` carries the message
|
||||||
* and `value` is an empty string.
|
* 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 = '') {
|
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
|
||||||
return c.json({ success: error === '', error, value })
|
return c.json({ success: error === '', error, value })
|
||||||
@@ -119,20 +105,14 @@ function toAccountDto(account: Account) {
|
|||||||
/**
|
/**
|
||||||
* Project a stored account into the private self DTO (the /account/me shape) —
|
* Project a stored account into the private self DTO (the /account/me shape) —
|
||||||
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
|
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
|
||||||
* OMITTED when null (emitting `null` makes the client's enum parser throw).
|
* OMITTED when null (emitting `null` makes the client's enum parser throw);
|
||||||
*
|
* `email`/`birthday` are kept as null (not enums, so null is fine).
|
||||||
* 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) {
|
function toSelfAccountDto(account: Account) {
|
||||||
return {
|
return {
|
||||||
...toAccountDto(account),
|
...toAccountDto(account),
|
||||||
email: account.email ?? '',
|
email: account.email ?? null,
|
||||||
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
|
birthday: null,
|
||||||
// everyone can be old.
|
|
||||||
birthday: '1904-01-01T00:00:00.000Z',
|
|
||||||
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,13 +131,9 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
|
|||||||
try {
|
try {
|
||||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||||
const publicDto = toAccountDto(account)
|
const publicDto = toAccountDto(account)
|
||||||
await hub.notifyPlayer(
|
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
|
||||||
account.accountId,
|
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
|
||||||
NotificationType.SubscriptionUpdateSelfProfile,
|
await hub.broadcast('AccountUpdate', publicDto)
|
||||||
toSelfAccountDto(account)
|
|
||||||
)
|
|
||||||
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
|
|
||||||
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push account update notifications', {
|
logger.error('failed to push account update notifications', {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -183,14 +159,6 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -441,20 +409,18 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set display name',
|
summary: 'Set display name',
|
||||||
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
|
requestBody: form(DisplayNameRequest, 'The new display name'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
400: { description: 'Empty display name (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { displayName } = c.req.valid('form')
|
const displayName = (await formField(c, 'displayName')).trim()
|
||||||
|
if (displayName === '') return c.body(null, 400)
|
||||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||||
await pushAccountUpdate(c, account)
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
@@ -470,33 +436,23 @@ const app = new Hono<App>()
|
|||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Change username',
|
summary: 'Change username',
|
||||||
description: [
|
description: [
|
||||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
'Rejects a name taken by another account and requires a remaining change; on',
|
||||||
'account and requires a remaining change; on success the name is persisted and',
|
'success the name is persisted and the counter decremented. Always HTTP 200 —',
|
||||||
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
'failures carry a message in `error` (see the UsernameResult envelope).',
|
||||||
'(see the UsernameResult envelope).',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
|
requestBody: form(UsernameRequest, 'The desired username'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
|
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
const { username } = c.req.valid('form')
|
const username = (await formField(c, 'username')).trim()
|
||||||
|
if (username === '') return usernameResult(c, 'You must enter a username.')
|
||||||
|
|
||||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||||
const existing = await getAccountByUsername(c.env.DB, username)
|
const existing = await getAccountByUsername(c.env.DB, username)
|
||||||
@@ -528,17 +484,18 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set email',
|
summary: 'Set email',
|
||||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
|
requestBody: form(EmailRequest, 'The new email'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Not a syntactically valid address (empty body)' },
|
400: { description: 'Email without an “@” (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { email } = c.req.valid('form')
|
const email = (await formField(c, 'email')).trim()
|
||||||
|
if (!email.includes('@')) return c.body(null, 400)
|
||||||
await updateAccount(c.env.DB, id, { email })
|
await updateAccount(c.env.DB, id, { email })
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
}
|
}
|
||||||
@@ -552,17 +509,18 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set phone number',
|
summary: 'Set phone number',
|
||||||
description: 'Persisted on the account row. Not broadcast.',
|
description: 'Persisted on the account row. Not broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
|
requestBody: form(PhoneRequest, 'The new phone number'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty phone (empty body)' },
|
400: { description: 'Empty phone (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { phone } = c.req.valid('form')
|
const phone = (await formField(c, 'phone')).trim()
|
||||||
|
if (phone === '') return c.body(null, 400)
|
||||||
await updateAccount(c.env.DB, id, { phone })
|
await updateAccount(c.env.DB, id, { phone })
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
}
|
}
|
||||||
@@ -637,20 +595,18 @@ const app = new Hono<App>()
|
|||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Set bio',
|
summary: 'Set bio',
|
||||||
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
|
description: 'Free text; empty is allowed. Persisted and broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
|
requestBody: form(BioRequest, 'The new bio'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Bio over 255 characters (empty body)' },
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { bio } = c.req.valid('form')
|
const bio = await formField(c, 'bio')
|
||||||
const account = await updateAccount(c.env.DB, id, { bio })
|
const account = await updateAccount(c.env.DB, id, { bio })
|
||||||
await pushAccountUpdate(c, account)
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
import { resolver } from 'hono-openapi'
|
import { resolver } from 'hono-openapi'
|
||||||
import { z } from 'zod'
|
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'
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAPI schemas for the accounts worker.
|
* OpenAPI schemas for the accounts worker.
|
||||||
*
|
*
|
||||||
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
|
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||||
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
|
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
|
||||||
* 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,
|
* As with the auth worker, this is deliberate. The Rec Room client is the only real
|
||||||
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
|
* consumer and the handlers are intentionally lenient — form fields are read as
|
||||||
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
|
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
|
||||||
* version of this note prescribed. Wiring one up means the schema both validates the
|
* to a graceful path (or a synthesized default account) rather than a hard error.
|
||||||
* request and generates the spec, so a limit can't be changed in one and not the other —
|
* These schemas record what the client is observed to send and what we send back; to
|
||||||
* which is precisely how the documented email limit came to disagree with the real one.
|
* enforce one, do it per-route and land a test with it.
|
||||||
*
|
|
||||||
* 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. */
|
/** Emit a zod schema as an `application/json` response body. */
|
||||||
@@ -77,16 +62,12 @@ export const AccountDto = z.object({
|
|||||||
/**
|
/**
|
||||||
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
|
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
|
||||||
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
|
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
|
||||||
* unset (emitting `null` makes the client's enum parser throw).
|
* unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are
|
||||||
|
* kept as nullable since they aren't enums.
|
||||||
*/
|
*/
|
||||||
export const SelfAccountDto = AccountDto.extend({
|
export const SelfAccountDto = AccountDto.extend({
|
||||||
email: z
|
email: z.string().nullable(),
|
||||||
.string()
|
birthday: z.null().describe('Always null — birthday is not stored'),
|
||||||
.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'),
|
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,54 +122,21 @@ export const CreateAccountRequest = z.object({
|
|||||||
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** Single-string form bodies, one per profile mutation. */
|
||||||
* 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({
|
export const DisplayNameRequest = z.object({
|
||||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||||
.min(1)
|
|
||||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UsernameRequest = z.object({
|
export const UsernameRequest = z.object({
|
||||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
username: z.string().describe('Trimmed; must be unique and changes must remain'),
|
||||||
.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({
|
export const EmailRequest = z.object({
|
||||||
email: z
|
email: z.string().describe('Must contain "@"; otherwise 400'),
|
||||||
.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({
|
export const PhoneRequest = z.object({
|
||||||
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
|
phone: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||||
// 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({
|
export const IdentityFlagsRequest = z.object({
|
||||||
@@ -199,10 +147,7 @@ export const PronounsRequest = z.object({
|
|||||||
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const BioRequest = z.object({
|
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
|
||||||
// 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({
|
export const ProfileImageRequest = z.object({
|
||||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||||
|
|||||||
@@ -161,9 +161,6 @@ describe('auth-gated endpoints', () => {
|
|||||||
personalPronouns: 0,
|
personalPronouns: 0,
|
||||||
identityFlags: 0,
|
identityFlags: 0,
|
||||||
availableUsernameChanges: 1,
|
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
|
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||||
@@ -462,179 +459,4 @@ describe('auth-gated endpoints', () => {
|
|||||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
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')
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
+4
-14
@@ -2,11 +2,10 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { avatarRoutes } from './routes/avatar'
|
import { avatarRoutes } from './routes/avatar'
|
||||||
import { configRoutes } from './routes/config'
|
import { configRoutes } from './routes/config'
|
||||||
import { eventRoutes } from './routes/events'
|
|
||||||
import { gameplayRoutes } from './routes/gameplay'
|
import { gameplayRoutes } from './routes/gameplay'
|
||||||
import { imageRoutes } from './routes/images'
|
import { imageRoutes } from './routes/images'
|
||||||
import { inventoryRoutes } from './routes/inventory'
|
import { inventoryRoutes } from './routes/inventory'
|
||||||
@@ -40,14 +39,6 @@ const app = new Hono<App>({ strict: false })
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -57,7 +48,6 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.route('/', progressionRoutes)
|
.route('/', progressionRoutes)
|
||||||
.route('/', avatarRoutes)
|
.route('/', avatarRoutes)
|
||||||
.route('/', gameplayRoutes)
|
.route('/', gameplayRoutes)
|
||||||
.route('/', eventRoutes)
|
|
||||||
.route('/', moderationRoutes)
|
.route('/', moderationRoutes)
|
||||||
.route('/', inventoryRoutes)
|
.route('/', inventoryRoutes)
|
||||||
.route('/', roomRoutes)
|
.route('/', roomRoutes)
|
||||||
@@ -78,9 +68,9 @@ app.get(
|
|||||||
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
|
'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',
|
'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,',
|
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
|
||||||
'player events, reputation and the assorted sinks the client hits while loading.',
|
'reputation and the assorted sinks the client hits while loading. Relationships,',
|
||||||
'Relationships, inventions, images and player events are D1-backed; several',
|
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
|
||||||
'endpoints are still stubs, noted per route.',
|
'route.',
|
||||||
'',
|
'',
|
||||||
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
|
'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',
|
'equipment, consumables and objectives on `econ`) are already served there — the',
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,844 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
+1
-11
@@ -1,4 +1,4 @@
|
|||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -12,16 +12,6 @@ export async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
|
|
||||||
* stamps from the account's flags (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. */
|
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||||
export function unauthorized(c: Context<App>) {
|
export function unauthorized(c: Context<App>) {
|
||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||||
|
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||||
|
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||||
|
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||||
|
*
|
||||||
|
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
|
||||||
|
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`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,
|
||||||
|
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||||
|
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||||
|
// A player's interaction with a saved image — one row per (player, image). Only
|
||||||
|
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||||
|
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
||||||
|
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
||||||
|
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
||||||
|
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
saved_image_id INTEGER NOT NULL,
|
||||||
|
cheered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
PRIMARY KEY (player_id, saved_image_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||||
|
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||||
|
* here in the image data layer so both the upload route and the slideshow query share
|
||||||
|
* one definition.
|
||||||
|
*/
|
||||||
|
export const SavedImageType = {
|
||||||
|
None: 0,
|
||||||
|
ShareCamera: 1,
|
||||||
|
OutfitThumbnail: 2,
|
||||||
|
RoomThumbnail: 3,
|
||||||
|
ProfileThumbnail: 4,
|
||||||
|
InventionThumbnail: 5,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** A stored image record (the client-facing SavedImage shape). */
|
||||||
|
export interface SavedImage {
|
||||||
|
Id: number
|
||||||
|
/** A {@link SavedImageType} value. */
|
||||||
|
Type: number
|
||||||
|
Accessibility: number
|
||||||
|
AccessibilityLocked: boolean
|
||||||
|
ImageName: string
|
||||||
|
Description: string | null
|
||||||
|
PlayerId: number
|
||||||
|
TaggedPlayerIds: number[]
|
||||||
|
RoomId: number | null
|
||||||
|
PlayerEventId: number | null
|
||||||
|
CreatedAt: string
|
||||||
|
CheerCount: number
|
||||||
|
CommentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||||
|
export interface NewImage {
|
||||||
|
imageName: string
|
||||||
|
playerId: number
|
||||||
|
type?: number
|
||||||
|
accessibility?: number
|
||||||
|
roomId?: number | null
|
||||||
|
description?: string | null
|
||||||
|
taggedPlayerIds?: number[]
|
||||||
|
playerEventId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a new image record for an upload, returning the stored row. */
|
||||||
|
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||||
|
// 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 image')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const image: SavedImage = {
|
||||||
|
Id: row?.next ?? 1,
|
||||||
|
Type: input.type ?? 1,
|
||||||
|
Accessibility: input.accessibility ?? 1,
|
||||||
|
AccessibilityLocked: false,
|
||||||
|
ImageName: input.imageName,
|
||||||
|
Description: input.description ?? null,
|
||||||
|
PlayerId: input.playerId,
|
||||||
|
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||||
|
RoomId: input.roomId ?? null,
|
||||||
|
PlayerEventId: input.playerEventId ?? null,
|
||||||
|
CreatedAt: new Date().toISOString(),
|
||||||
|
CheerCount: 0,
|
||||||
|
CommentCount: 0,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||||
|
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||||
|
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||||
|
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||||
|
*/
|
||||||
|
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
||||||
|
)
|
||||||
|
.bind(savedImageId)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const count = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(savedImageId, count)
|
||||||
|
.run()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||||
|
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||||
|
* an already-cheered image is a no-op on the count.
|
||||||
|
*/
|
||||||
|
export async function setImageCheer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
savedImageId: number,
|
||||||
|
cheer: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||||
|
)
|
||||||
|
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
await syncImageCheerCount(db, savedImageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||||
|
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||||
|
*/
|
||||||
|
export async function getCheeredImageIds(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
ids: number[]
|
||||||
|
): Promise<Set<number>> {
|
||||||
|
if (ids.length === 0) return new Set()
|
||||||
|
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT saved_image_id AS id FROM image_interaction
|
||||||
|
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||||
|
)
|
||||||
|
.bind(playerId, ...ids)
|
||||||
|
.all<{ id: number }>()
|
||||||
|
return new Set(results.map((r) => r.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||||
|
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||||
|
.bind(name)
|
||||||
|
.first<ImageRow>()
|
||||||
|
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
||||||
|
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
||||||
|
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
||||||
|
* responsibility (see the deletesaved route).
|
||||||
|
*/
|
||||||
|
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
||||||
|
await db.batch([
|
||||||
|
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
||||||
|
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||||
|
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||||
|
* `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most
|
||||||
|
* cheered first (ties broken by newest), anything else is newest-first. Paginated
|
||||||
|
* via skip/take; returns a bare array of SavedImage. The per-room set is small, so
|
||||||
|
* the room_id index does the lookup and filtering/sorting happens in memory.
|
||||||
|
*
|
||||||
|
* NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends
|
||||||
|
* `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera.
|
||||||
|
*/
|
||||||
|
export async function getImagesByRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
sort: number,
|
||||||
|
filter: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE room_id = ?1')
|
||||||
|
.bind(roomId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
let images = results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
|
||||||
|
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
||||||
|
|
||||||
|
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
|
|
||||||
|
return images.slice(skip, skip + take)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */
|
||||||
|
const newestFirst = (a: SavedImage, b: SavedImage) =>
|
||||||
|
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public images a player has taken — their photo list, newest first.
|
||||||
|
* Paginated via skip/take; returns a bare array of SavedImage. Uses the
|
||||||
|
* player_id index; the per-player set is small, so filtering/sorting is in memory.
|
||||||
|
*/
|
||||||
|
export async function getImagesByPlayer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
sort: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
||||||
|
.bind(playerId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
return results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
|
.slice(skip, skip + take)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-facing projection of a saved image for the player photo lists (the
|
||||||
|
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||||
|
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
||||||
|
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
||||||
|
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
||||||
|
*/
|
||||||
|
export interface ImagesPlayer {
|
||||||
|
Accessibility: number
|
||||||
|
AccessibilityLocked: boolean
|
||||||
|
CheerCount: number
|
||||||
|
CommentCount: number
|
||||||
|
CreatedAt: string
|
||||||
|
Description: string | null
|
||||||
|
ImageName: string
|
||||||
|
PlayerEventId: number | null
|
||||||
|
PlayerId: number
|
||||||
|
RoomId: number | null
|
||||||
|
SavedImageId: number
|
||||||
|
SavedImageType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored image to the client's ImagesPlayer shape. */
|
||||||
|
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
||||||
|
return {
|
||||||
|
Accessibility: img.Accessibility,
|
||||||
|
AccessibilityLocked: img.AccessibilityLocked,
|
||||||
|
CheerCount: img.CheerCount,
|
||||||
|
CommentCount: img.CommentCount,
|
||||||
|
CreatedAt: img.CreatedAt,
|
||||||
|
Description: img.Description,
|
||||||
|
ImageName: img.ImageName,
|
||||||
|
PlayerEventId: img.PlayerEventId,
|
||||||
|
PlayerId: img.PlayerId,
|
||||||
|
RoomId: img.RoomId,
|
||||||
|
SavedImageId: img.Id,
|
||||||
|
SavedImageType: img.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default number of recent images the slideshow feed returns. */
|
||||||
|
export const SLIDESHOW_LIMIT = 130
|
||||||
|
|
||||||
|
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||||
|
export interface SlideshowImage {
|
||||||
|
SavedImageId: number
|
||||||
|
ImageName: string
|
||||||
|
Username: string
|
||||||
|
RoomName: string | null
|
||||||
|
RoomId: number | null
|
||||||
|
SavedImageType: number
|
||||||
|
PlayerEventId: number | null
|
||||||
|
Accessibility: number
|
||||||
|
PlayerIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
||||||
|
const placeholders = (n: number): string =>
|
||||||
|
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
||||||
|
|
||||||
|
/** Map account ids → username, resolved from the shared accounts table. */
|
||||||
|
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
if (ids.length === 0) return new Map()
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT account_id AS id, json_extract(data, '$.username') AS username
|
||||||
|
FROM account WHERE account_id IN (${placeholders(ids.length)})`
|
||||||
|
)
|
||||||
|
.bind(...ids)
|
||||||
|
.all<{ id: number; username: string }>()
|
||||||
|
return new Map(results.map((r) => [r.id, r.username]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map room ids → room name, resolved from the shared rooms table. */
|
||||||
|
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
if (ids.length === 0) return new Map()
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
|
||||||
|
FROM room WHERE room_id IN (${placeholders(ids.length)})`
|
||||||
|
)
|
||||||
|
.bind(...ids)
|
||||||
|
.all<{ id: number; name: string }>()
|
||||||
|
return new Map(results.map((r) => [r.id, r.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||||
|
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||||
|
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||||
|
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||||
|
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||||
|
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||||
|
*/
|
||||||
|
export async function getSlideshowImages(
|
||||||
|
db: D1Database,
|
||||||
|
limit = SLIDESHOW_LIMIT
|
||||||
|
): Promise<SlideshowImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM image
|
||||||
|
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||||
|
AND json_extract(data, '$.Type') = ?1
|
||||||
|
ORDER BY id DESC LIMIT ?2`
|
||||||
|
)
|
||||||
|
.bind(SavedImageType.ShareCamera, limit)
|
||||||
|
.all<ImageRow>()
|
||||||
|
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
|
||||||
|
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
|
||||||
|
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
|
||||||
|
const roomNames = await getRoomNames(db, roomIds)
|
||||||
|
|
||||||
|
return images.map((img) => ({
|
||||||
|
SavedImageId: img.Id,
|
||||||
|
ImageName: img.ImageName,
|
||||||
|
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
|
||||||
|
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
|
||||||
|
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
|
||||||
|
RoomId: img.RoomId,
|
||||||
|
SavedImageType: img.Type,
|
||||||
|
PlayerEventId: img.PlayerEventId,
|
||||||
|
Accessibility: img.Accessibility,
|
||||||
|
PlayerIds: img.TaggedPlayerIds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's photo feed — the public images they took plus the ones they're
|
||||||
|
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
||||||
|
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
||||||
|
* TaggedPlayerIds array (there's no index for it).
|
||||||
|
*/
|
||||||
|
export async function getPlayerFeed(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
skip: number,
|
||||||
|
take: number
|
||||||
|
): Promise<SavedImage[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM image
|
||||||
|
WHERE player_id = ?1
|
||||||
|
OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId)
|
||||||
|
.all<ImageRow>()
|
||||||
|
return results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
.filter((img) => img.Accessibility === 1)
|
||||||
|
.sort(newestFirst)
|
||||||
|
.slice(skip, skip + take)
|
||||||
|
}
|
||||||
+31
-131
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
||||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
|
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
|
||||||
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
|
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||||
* the same JSON-blob pattern the image/rooms/accounts tables use.
|
* JSON-blob pattern the image/rooms/accounts tables use.
|
||||||
*
|
*
|
||||||
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
||||||
* applied under its own `migrations_table`). The invention's data file itself is
|
* applied under its own `migrations_table`). The invention's data file itself is
|
||||||
@@ -12,29 +12,19 @@
|
|||||||
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
||||||
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
||||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
* 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 +
|
* 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
|
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
|
||||||
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
|
* of a JSON `true` is 1, so the column is 1/0.
|
||||||
* 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[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS invention (
|
`CREATE TABLE IF NOT EXISTS invention (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) 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 UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||||
@@ -275,74 +265,6 @@ export async function getInventionsByCreator(
|
|||||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
.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 search — the browse/search list the client shows when picking an
|
||||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||||
@@ -382,73 +304,50 @@ export async function searchInventions(
|
|||||||
* ones via the indexed `is_featured` column.
|
* ones via the indexed `is_featured` column.
|
||||||
*/
|
*/
|
||||||
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
||||||
// All three are generated columns off the JSON blob, so the filter stays in SQL.
|
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE is_published = 1
|
WHERE json_extract(data, '$.IsPublished') = 1
|
||||||
AND hide_from_player = 0
|
AND json_extract(data, '$.HideFromPlayer') = 0
|
||||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||||
)
|
)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Length of the "today" window — a trailing day, not the calendar one. */
|
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
||||||
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
function topScore(invention: SavedInvention): number {
|
||||||
|
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||||
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
|
return (
|
||||||
function startOfWindow(): string {
|
n(invention.NumDownloads) * 3 +
|
||||||
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
|
n(invention.CheerCount) * 2 +
|
||||||
|
n(invention.NumPlayersHaveUsedInRoom)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "top today" feed — the inventions other players picked up in the last 24 hours,
|
* The "top today" feed — published inventions ranked by engagement. The real feed
|
||||||
* most first.
|
* 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.
|
||||||
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
|
* Paginated via skip/take; returns a bare array, like the other invention feeds.
|
||||||
* 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(
|
export async function getTopInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
|
const inventions = await publicInventions(db)
|
||||||
if (counts.length === 0) return []
|
return inventions
|
||||||
|
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
||||||
// getInventionsByIds answers in the order it is asked, so the ranking survives the
|
.slice(skip, skip + take)
|
||||||
// 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.
|
* The featured feed — published inventions flagged `IsFeatured`, newest first.
|
||||||
* Selected on the indexed `is_featured` column rather than by parsing every public
|
* Selected on the indexed `is_featured` column rather than by parsing every public
|
||||||
* invention.
|
* 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.
|
||||||
* 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(
|
export async function getFeaturedInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -456,6 +355,7 @@ export async function getFeaturedInventions(
|
|||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
const featured = await publicInventions(db, true)
|
const featured = await publicInventions(db, true)
|
||||||
|
if (featured.length === 0) return getTopInventions(db, skip, take)
|
||||||
return featured
|
return featured
|
||||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take)
|
||||||
@@ -679,8 +579,8 @@ export async function getInventionsByRoom(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||||
AND is_published = 1
|
AND json_extract(data, '$.IsPublished') = 1
|
||||||
AND hide_from_player = 0`
|
AND json_extract(data, '$.HideFromPlayer') = 0`
|
||||||
)
|
)
|
||||||
.bind(roomId)
|
.bind(roomId)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
|
|||||||
+12
-242
@@ -97,16 +97,6 @@ export const BareString = z.string()
|
|||||||
/** The `{ error }` body the 400 / 403 branches return. */
|
/** The `{ error }` body the 400 / 403 branches return. */
|
||||||
export const ErrorResponse = z.object({ error: z.string() })
|
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 ----------------------------------------------------------------
|
// ---- Config ----------------------------------------------------------------
|
||||||
|
|
||||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||||
@@ -170,49 +160,9 @@ export const RelationshipDto = z.object({
|
|||||||
Muted: 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. */
|
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
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 -----------------------------------------------------------
|
// ---- Progression -----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -335,14 +285,8 @@ export const InventionPersonalDetails = z.object({
|
|||||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||||
export const SetTagsRequest = z.object({
|
export const SetTagsRequest = z.object({
|
||||||
InventionId: z.int(),
|
InventionId: z.int(),
|
||||||
AutoTags: z
|
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
|
||||||
.array(z.string())
|
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
|
||||||
.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. */
|
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
|
||||||
@@ -362,14 +306,8 @@ export const SaveInventionRequest = z.object({
|
|||||||
inventionDataFilename: z
|
inventionDataFilename: z
|
||||||
.string()
|
.string()
|
||||||
.describe('The blob uploaded through the storage worker; the one required field'),
|
.describe('The blob uploaded through the storage worker; the one required field'),
|
||||||
name: z
|
name: z.string().optional().describe('Defaults to “Untitled”'),
|
||||||
.string()
|
description: z.string().optional(),
|
||||||
.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(),
|
imageName: z.string().optional(),
|
||||||
instantiationCost: z.int().optional(),
|
instantiationCost: z.int().optional(),
|
||||||
lightsCost: z.int().optional(),
|
lightsCost: z.int().optional(),
|
||||||
@@ -435,142 +373,10 @@ export const KeepsakeConfig = z.object({
|
|||||||
SocialXpBoostEnabled: z.boolean(),
|
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. */
|
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||||
export const PlayerEventsAll = z.object({
|
export const PlayerEventsAll = z.object({
|
||||||
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
Created: JsonArray,
|
||||||
Responses: JsonArray.describe(
|
Responses: JsonArray,
|
||||||
'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. */
|
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||||
@@ -579,6 +385,12 @@ export const PlayerEventsPage = z.object({
|
|||||||
Events: JsonArray,
|
Events: JsonArray,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
||||||
|
export const SubscriptionResponse = z.object({
|
||||||
|
subscription: z.null(),
|
||||||
|
platformAccountSubscribedPlayerId: z.null(),
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Moderation ------------------------------------------------------------
|
// ---- Moderation ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -599,48 +411,6 @@ export const ModerationBlockDetails = z.object({
|
|||||||
TimeoutStartedAt: z.string().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. */
|
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||||
export const DeviceIdRequest = z.object({
|
export const DeviceIdRequest = z.object({
|
||||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Friendship / relationship storage on the shared `recflare` D1 database.
|
||||||
|
*
|
||||||
|
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
|
||||||
|
* relationship is genuinely columnar, so it gets a normal relational table
|
||||||
|
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
|
||||||
|
* unordered pair of players: the player who initiated is the `requester`, the
|
||||||
|
* other is the `target`. `relationship_type` is stored from the requester's
|
||||||
|
* point of view; when we project the row for the *target* we flip
|
||||||
|
* Sent↔Received (Friend/None are symmetric).
|
||||||
|
*
|
||||||
|
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
|
||||||
|
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||||
|
* workers' migrations that share the database).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||||
|
export enum RelationshipType {
|
||||||
|
None = 0,
|
||||||
|
FriendRequestSent = 1,
|
||||||
|
FriendRequestReceived = 2,
|
||||||
|
Friend = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS relationship (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
requester_id INTEGER NOT NULL,
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
relationship_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_ignored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requester_muted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_ignored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target_muted INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A stored relationship row (snake_case columns, one row per player pair). */
|
||||||
|
interface RelationshipRow {
|
||||||
|
requester_id: number
|
||||||
|
target_id: number
|
||||||
|
relationship_type: number
|
||||||
|
requester_favorited: number
|
||||||
|
requester_ignored: number
|
||||||
|
requester_muted: number
|
||||||
|
target_favorited: number
|
||||||
|
target_ignored: number
|
||||||
|
target_muted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||||
|
export interface RelationshipResponse {
|
||||||
|
Favorited: number
|
||||||
|
Ignored: number
|
||||||
|
Muted: number
|
||||||
|
PlayerID: number
|
||||||
|
RelationshipType: RelationshipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||||
|
* each sees a different projection of the same row (the target of a request sees
|
||||||
|
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||||
|
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||||
|
*
|
||||||
|
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||||
|
* outstanding, befriending someone you're already friends with, accepting something that
|
||||||
|
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||||
|
* (the reference server is likewise silent on its no-change branch).
|
||||||
|
*/
|
||||||
|
export interface RelationshipChange {
|
||||||
|
self: RelationshipResponse
|
||||||
|
other: RelationshipResponse
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The projection reported for a pair with no stored relationship. */
|
||||||
|
function noneResponse(otherId: number): RelationshipResponse {
|
||||||
|
return {
|
||||||
|
PlayerID: otherId,
|
||||||
|
RelationshipType: RelationshipType.None,
|
||||||
|
Favorited: 0,
|
||||||
|
Ignored: 0,
|
||||||
|
Muted: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||||
|
function flipType(type: number): RelationshipType {
|
||||||
|
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||||
|
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
|
||||||
|
return type as RelationshipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a stored row into the RelationshipResponse for `playerId` (who must be
|
||||||
|
* one of the pair). `PlayerID` is the *other* player; the type and the
|
||||||
|
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
|
||||||
|
*/
|
||||||
|
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
|
||||||
|
const isRequester = row.requester_id === playerId
|
||||||
|
return {
|
||||||
|
PlayerID: isRequester ? row.target_id : row.requester_id,
|
||||||
|
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
|
||||||
|
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||||
|
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||||
|
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a written row for both players in the pair. */
|
||||||
|
function toChange(
|
||||||
|
row: RelationshipRow,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
changed: boolean
|
||||||
|
): RelationshipChange {
|
||||||
|
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the single row for an unordered pair (either direction), or null. */
|
||||||
|
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM relationship
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(a, b)
|
||||||
|
.first<RelationshipRow>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All of a player's relationships, projected from that player's point of view.
|
||||||
|
*
|
||||||
|
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||||
|
* were never friends with, is recorded, and they still carry that player's
|
||||||
|
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||||
|
*/
|
||||||
|
export async function getRelationshipsForPlayer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number
|
||||||
|
): Promise<RelationshipResponse[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM relationship
|
||||||
|
WHERE requester_id = ?1 OR target_id = ?1`
|
||||||
|
)
|
||||||
|
.bind(playerId)
|
||||||
|
.all<RelationshipRow>()
|
||||||
|
return results.map((row) => toResponse(row, playerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||||
|
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||||
|
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||||
|
* the flags are preserved for whichever side each player is on. Returns the
|
||||||
|
* row as written, for the caller to project onto whichever side it needs.
|
||||||
|
*/
|
||||||
|
async function upsertPair(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number,
|
||||||
|
type: RelationshipType
|
||||||
|
): Promise<RelationshipRow> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (!existing) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO relationship (requester_id, target_id, relationship_type)
|
||||||
|
VALUES (?1, ?2, ?3)`
|
||||||
|
)
|
||||||
|
.bind(requesterId, targetId, type)
|
||||||
|
.run()
|
||||||
|
return {
|
||||||
|
requester_id: requesterId,
|
||||||
|
target_id: targetId,
|
||||||
|
relationship_type: type,
|
||||||
|
requester_favorited: 0,
|
||||||
|
requester_ignored: 0,
|
||||||
|
requester_muted: 0,
|
||||||
|
target_favorited: 0,
|
||||||
|
target_ignored: 0,
|
||||||
|
target_muted: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep each player's flags with that player as the row is normalized to
|
||||||
|
// requester = requesterId.
|
||||||
|
const reqIsRequester = existing.requester_id === requesterId
|
||||||
|
const reqFlags = {
|
||||||
|
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
|
||||||
|
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
|
||||||
|
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
|
||||||
|
}
|
||||||
|
const tgtFlags = {
|
||||||
|
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
|
||||||
|
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
|
||||||
|
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship
|
||||||
|
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
|
||||||
|
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
|
||||||
|
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
requesterId,
|
||||||
|
targetId,
|
||||||
|
type,
|
||||||
|
reqFlags.favorited,
|
||||||
|
reqFlags.ignored,
|
||||||
|
reqFlags.muted,
|
||||||
|
tgtFlags.favorited,
|
||||||
|
tgtFlags.ignored,
|
||||||
|
tgtFlags.muted
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
return {
|
||||||
|
requester_id: requesterId,
|
||||||
|
target_id: targetId,
|
||||||
|
relationship_type: type,
|
||||||
|
requester_favorited: reqFlags.favorited,
|
||||||
|
requester_ignored: reqFlags.ignored,
|
||||||
|
requester_muted: reqFlags.muted,
|
||||||
|
target_favorited: tgtFlags.favorited,
|
||||||
|
target_ignored: tgtFlags.ignored,
|
||||||
|
target_muted: tgtFlags.muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||||
|
* has a pending request out to the requester, the two become friends instead
|
||||||
|
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||||
|
* that's already outstanding, are no-ops.
|
||||||
|
*/
|
||||||
|
export async function sendFriendRequest(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (existing) {
|
||||||
|
// Already friends, or we already have a request out to them — nothing to write.
|
||||||
|
if (
|
||||||
|
existing.relationship_type === RelationshipType.Friend ||
|
||||||
|
(existing.requester_id === requesterId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||||
|
) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
|
}
|
||||||
|
// The target already requested us → crossing requests become a friendship.
|
||||||
|
if (
|
||||||
|
existing.requester_id === targetId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
|
) {
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||||
|
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||||
|
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||||
|
* we stay lenient, but either way nothing changed.)
|
||||||
|
*/
|
||||||
|
export async function acceptFriendRequest(
|
||||||
|
db: D1Database,
|
||||||
|
accepterId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, accepterId, otherId)
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.requester_id === otherId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
|
) {
|
||||||
|
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||||
|
return toChange(row, accepterId, otherId, true)
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
? toChange(existing, accepterId, otherId, false)
|
||||||
|
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||||
|
*/
|
||||||
|
export async function addFriend(
|
||||||
|
db: D1Database,
|
||||||
|
requesterId: number,
|
||||||
|
targetId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
|
}
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any relationship between the two players (unfriend / cancel request /
|
||||||
|
* decline).
|
||||||
|
*
|
||||||
|
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||||
|
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||||
|
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||||
|
*/
|
||||||
|
export async function removeFriend(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship SET relationship_type = ?3
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, RelationshipType.None)
|
||||||
|
.run()
|
||||||
|
const updated = await findPair(db, playerId, otherId)
|
||||||
|
return updated
|
||||||
|
? toChange(updated, playerId, otherId, true)
|
||||||
|
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||||
|
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
||||||
|
* relationship with `otherId`. These flags are stored per player, so the write
|
||||||
|
* targets the caller's OWN side of the row — `requester_*` when the caller
|
||||||
|
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
||||||
|
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
||||||
|
* created with the caller as requester. Returns the relationship from `playerId`'s
|
||||||
|
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
||||||
|
* into the SQL is safe (same pattern as the room interaction toggles).
|
||||||
|
*/
|
||||||
|
export async function setRelationshipFlag(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
flag: RelationshipFlag,
|
||||||
|
value: boolean
|
||||||
|
): Promise<RelationshipResponse> {
|
||||||
|
const existing = await findPair(db, playerId, otherId)
|
||||||
|
const v = value ? 1 : 0
|
||||||
|
if (!existing) {
|
||||||
|
// New row: the caller is the requester, so the flag lives on the requester side.
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, RelationshipType.None, v)
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
// Update whichever side the caller is on, leaving the other player's flag alone.
|
||||||
|
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE relationship SET ${side}_${flag} = ?3
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId, v)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await findPair(db, playerId, otherId)
|
||||||
|
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||||
|
}
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>()
|
|
||||||
}
|
|
||||||
+45
-165
@@ -1,25 +1,18 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import {
|
|
||||||
inventionDescriptionRejection,
|
|
||||||
inventionNameRejection,
|
|
||||||
inventionTagRejection,
|
|
||||||
} from '@repo/domain'
|
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
createInvention,
|
createInvention,
|
||||||
getFeaturedInventions,
|
getFeaturedInventions,
|
||||||
getInventionById,
|
getInventionById,
|
||||||
|
getInventionsByCreator,
|
||||||
getInventionsByIds,
|
getInventionsByIds,
|
||||||
getInventionsByRoom,
|
getInventionsByRoom,
|
||||||
getInventionTagFilters,
|
getInventionTagFilters,
|
||||||
getInventionTags,
|
getInventionTags,
|
||||||
getInventionVersion,
|
getInventionVersion,
|
||||||
getMyInventions,
|
|
||||||
getTopInventions,
|
getTopInventions,
|
||||||
ownsAllInventions,
|
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
searchInventions,
|
searchInventions,
|
||||||
@@ -83,20 +76,6 @@ async function creatorsInvention(
|
|||||||
return { invention }
|
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 ----------------------------------------------------------
|
// ---- Avatar gifts ----------------------------------------------------------
|
||||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
// 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
|
// gift-box consume live in the `econ` worker, which the client calls on the econ host
|
||||||
@@ -291,8 +270,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const ids = inventionIdQuery(c)
|
const ids = c.req
|
||||||
if (ids.length === 0) return c.json([])
|
.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([])
|
||||||
|
|
||||||
const playerId = await authedId(c)
|
const playerId = await authedId(c)
|
||||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||||
@@ -304,40 +287,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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,
|
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||||||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||||
.get(
|
.get(
|
||||||
@@ -422,43 +371,34 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
|
// Edit an invention's metadata. A GET that writes — that's what the client sends
|
||||||
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
|
// (`?inventionId=1&description=my+description`), with the fields to change as
|
||||||
// GET that writes in some places and as a bodyless POST in others (the permission
|
// query params. Absent params keep their stored value; `permission` sets what
|
||||||
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
|
// other players may do with it (a name like `useonly` or the raw number). An
|
||||||
// neither reads a body. Absent params keep their stored value; `permission` sets
|
// empty `description` clears it, but an empty `name`/`imageName` is ignored
|
||||||
// what other players may do with it. An empty `description` clears it, but an empty
|
// rather than blanking the invention. Publishing and pricing are separate
|
||||||
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
|
// endpoints. Auth-gated, creator only; answers the save envelope.
|
||||||
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
|
.get(
|
||||||
.on(
|
|
||||||
['GET', 'POST'],
|
|
||||||
'/api/inventions/v1/update',
|
'/api/inventions/v1/update',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'Edit an invention’s metadata',
|
summary: 'Edit an invention’s metadata',
|
||||||
description:
|
description:
|
||||||
'GET or POST — the client sends both, and the fields to change ride as query ' +
|
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
||||||
'params either way; no body is read. Absent params keep their stored value. An ' +
|
'query params. Absent params keep their stored value. An empty `description` ' +
|
||||||
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
|
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
||||||
'than blanking the invention. A supplied name/description must satisfy the same ' +
|
'invention. Publishing and pricing are separate endpoints.',
|
||||||
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
|
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
intQuery('inventionId', 'Invention id; required'),
|
intQuery('inventionId', 'Invention id; required'),
|
||||||
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
stringQuery('name', 'New name; empty is ignored'),
|
||||||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
stringQuery('description', 'New description; present-but-empty clears it'),
|
||||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||||
stringQuery(
|
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||||
'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: {
|
responses: {
|
||||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||||
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||||
404: { description: 'No such invention' },
|
404: { description: 'No such invention' },
|
||||||
@@ -476,23 +416,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const allowTrial = c.req.query('allowTrial')
|
const allowTrial = c.req.query('allowTrial')
|
||||||
const permission = c.req.query('permission')
|
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, {
|
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||||
name,
|
name: nonEmpty('name'),
|
||||||
// Present-but-empty clears the description, so this checks presence.
|
// Present-but-empty clears the description, so this checks presence.
|
||||||
description,
|
description: c.req.query('description'),
|
||||||
imageName: nonEmpty('imageName'),
|
imageName: nonEmpty('imageName'),
|
||||||
allowTrial:
|
allowTrial:
|
||||||
allowTrial === undefined
|
allowTrial === undefined
|
||||||
@@ -596,15 +523,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
'`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 ' +
|
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||||
'only.\n\n' +
|
'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 ' +
|
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
400: json(ErrorResponse, 'Unparseable body'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||||
404: { description: 'No such invention' },
|
404: { description: 'No such invention' },
|
||||||
@@ -621,29 +546,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const strings = (v: unknown): string[] =>
|
const strings = (v: unknown): string[] =>
|
||||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||||
|
|
||||||
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(
|
const tags = await setInventionTags(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
gate.invention.InventionId,
|
gate.invention.InventionId,
|
||||||
autoTags,
|
strings(body.AutoTags),
|
||||||
customTags
|
strings(body.CustomTags)
|
||||||
)
|
)
|
||||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||||
}
|
}
|
||||||
@@ -674,21 +581,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
|
// The "top today" invention feed — published inventions ranked by engagement
|
||||||
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
||||||
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
|
// (take defaults to 50, as the client asks for). Bare array.
|
||||||
// to 50, as the client asks for). Bare array.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/toptoday',
|
'/api/inventions/v1/toptoday',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The “top today” feed',
|
summary: 'The “top today” feed',
|
||||||
description:
|
description:
|
||||||
'Published inventions ranked by how many players acquired them in the last 24 ' +
|
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
|
||||||
'hours, counted from the purchase records — free grants included, one per ' +
|
'daily counters, so “today” is a label, not a window.',
|
||||||
'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),
|
parameters: pageParams(50),
|
||||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||||
}),
|
}),
|
||||||
@@ -699,17 +602,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
|
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
||||||
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
|
// to the top feed while nothing is curated. Bare array, like toptoday.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/featured',
|
'/api/inventions/v1/featured',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The featured feed',
|
summary: 'The featured feed',
|
||||||
description:
|
description:
|
||||||
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
|
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
|
||||||
'Serves an empty list while nothing is flagged rather than standing in the top ' +
|
'curated — so this is never empty just because no one has picked favourites.',
|
||||||
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
|
|
||||||
parameters: pageParams(50),
|
parameters: pageParams(50),
|
||||||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||||||
}),
|
}),
|
||||||
@@ -746,20 +648,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
|
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||||
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
|
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||||
// 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(
|
.get(
|
||||||
'/api/inventions/v2/mine',
|
'/api/inventions/v2/mine',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The caller’s own inventions',
|
summary: 'The caller’s own inventions',
|
||||||
description:
|
description:
|
||||||
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
|
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
|
||||||
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
|
'see. Not paginated.',
|
||||||
'invention listed even if it has since been unpublished or hidden. Not paginated.',
|
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||||
@@ -769,7 +667,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
return c.json(await getMyInventions(c.env.DB, id))
|
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -788,19 +686,14 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
||||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
'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 ' +
|
'name/description is defaulted rather than rejected.\n\n' +
|
||||||
'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 ' +
|
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||||
'until they call `v3/publish`.',
|
'until they call `v3/publish`.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||||
400: json(
|
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
|
||||||
ErrorResponse,
|
|
||||||
'Unparseable body, no inventionDataFilename, or an invalid name/description'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -819,24 +712,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
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, {
|
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||||
creatorPlayerId: id,
|
creatorPlayerId: id,
|
||||||
inventionDataFilename,
|
inventionDataFilename,
|
||||||
name,
|
name: str(body.name),
|
||||||
description,
|
description: str(body.description),
|
||||||
imageName: str(body.imageName),
|
imageName: str(body.imageName),
|
||||||
instantiationCost: num(body.instantiationCost),
|
instantiationCost: num(body.instantiationCost),
|
||||||
lightsCost: num(body.lightsCost),
|
lightsCost: num(body.lightsCost),
|
||||||
|
|||||||
@@ -1,678 +0,0 @@
|
|||||||
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) })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
+104
-18
@@ -6,21 +6,25 @@ import communityBoard from '../../static/community-board.json'
|
|||||||
import {
|
import {
|
||||||
BareString,
|
BareString,
|
||||||
idParam,
|
idParam,
|
||||||
|
intQuery,
|
||||||
IsPureResponse,
|
IsPureResponse,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
KeepsakeCategories,
|
|
||||||
KeepsakeConfig,
|
KeepsakeConfig,
|
||||||
|
PlayerEventsAll,
|
||||||
|
PlayerEventsPage,
|
||||||
SanitizeRequest,
|
SanitizeRequest,
|
||||||
stringParam,
|
stringParam,
|
||||||
|
SubscriptionResponse,
|
||||||
|
TagFilters,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
// Text sanitization, keepsakes, objectives/events/rewards, and the misc
|
||||||
// sinks the client hits during load.
|
// analytics/subscription sinks the client hits during load.
|
||||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||||
// value back; `isPure` reports the text is clean.
|
// value back; `isPure` reports the text is clean.
|
||||||
@@ -98,25 +102,15 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.body(null, 204)
|
(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(
|
.get(
|
||||||
'/api/keepsakes/categories',
|
'/api/keepsakes/categories',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Gameplay'],
|
tags: ['Gameplay'],
|
||||||
summary: 'Keepsake categories',
|
summary: 'Keepsake categories',
|
||||||
description:
|
description: 'No keepsake catalog yet, so this is an empty list.',
|
||||||
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
|
responses: { 200: json(JsonArray, 'An empty list') },
|
||||||
'(`{ 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 })
|
(c) => c.json([])
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Objectives / events / rewards ---------------------------------------
|
// ---- Objectives / events / rewards ---------------------------------------
|
||||||
@@ -134,8 +128,86 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.json(communityBoard)
|
(c) => c.json(communityBoard)
|
||||||
)
|
)
|
||||||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
.get(
|
||||||
// now, unlike the stubs around them here.
|
'/api/playerevents/v1/all',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'The caller’s player events',
|
||||||
|
description:
|
||||||
|
'Events the player created and events they have RSVP’d to. No player-event ' +
|
||||||
|
'storage yet, so both lists are empty.',
|
||||||
|
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
|
||||||
|
}),
|
||||||
|
(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',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'Player-event filter chips',
|
||||||
|
description:
|
||||||
|
'The 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.',
|
||||||
|
responses: { 200: json(TagFilters, 'Empty chip lists') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ PinnedFilters: [], PopularFilters: [], 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. No
|
||||||
|
// player-event storage yet, so the feed is empty.
|
||||||
|
.get(
|
||||||
|
'/api/playerevents/v1/clubs',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'Player events across several clubs',
|
||||||
|
description:
|
||||||
|
'The events shelf for a set of clubs (`?id=1&id=2`). 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 player-event storage yet, ' +
|
||||||
|
'so the feed is empty.',
|
||||||
|
parameters: [intQuery('id', 'Repeatable club id')],
|
||||||
|
responses: { 200: json(JsonArray, 'An empty list') },
|
||||||
|
}),
|
||||||
|
(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]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
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. An empty `ContinuationToken` means no next ' +
|
||||||
|
'page.',
|
||||||
|
parameters: [idParam('clubId', 'Club id')],
|
||||||
|
responses: { 200: json(PlayerEventsPage, 'An empty page') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ ContinuationToken: '', Events: [] })
|
||||||
|
)
|
||||||
|
// Live player-event search (the "happening now" browse query). No player-event
|
||||||
|
// storage yet, so there's nothing live to return — a bare empty array.
|
||||||
|
.get(
|
||||||
|
'/api/playerevents/v1/searchlive',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'Search live player events',
|
||||||
|
description:
|
||||||
|
'The "happening now" search on the player-events browse screen. No player-event ' +
|
||||||
|
'storage yet, so there are no live events — returns an empty list.',
|
||||||
|
responses: { 200: json(JsonArray, 'An empty list') },
|
||||||
|
}),
|
||||||
|
(c) => c.json([])
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
'/api/announcement/v1/get',
|
'/api/announcement/v1/get',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -160,3 +232,17 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.body(null, 200)
|
(c) => c.body(null, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- Subscription ---------------------------------------------------------
|
||||||
|
.post(
|
||||||
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'The caller’s subscription',
|
||||||
|
description:
|
||||||
|
'Rec Room Plus subscription state. There are no subscriptions on this server, so ' +
|
||||||
|
'both fields are null. Also served by the `econ` worker on its own host.',
|
||||||
|
responses: { 200: json(SubscriptionResponse, 'No subscription') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
createImage,
|
createImage,
|
||||||
deleteImage,
|
deleteImage,
|
||||||
@@ -12,12 +13,8 @@ import {
|
|||||||
getSlideshowImages,
|
getSlideshowImages,
|
||||||
SavedImageType,
|
SavedImageType,
|
||||||
setImageCheer,
|
setImageCheer,
|
||||||
SLIDESHOW_LIMIT,
|
|
||||||
SLIDESHOW_MAX_LIMIT,
|
|
||||||
toImagesPlayer,
|
toImagesPlayer,
|
||||||
} from '@repo/domain'
|
} from '../images-db'
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
CheeredEntry,
|
CheeredEntry,
|
||||||
@@ -315,10 +312,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
// 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(
|
.get(
|
||||||
'/api/images/v1/slideshow',
|
'/api/images/v1/slideshow',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -330,21 +323,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
'Deliberately public — it surfaces only already-public images and backs the ' +
|
'Deliberately public — it surfaces only already-public images and backs the ' +
|
||||||
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
||||||
'client refreshes against.',
|
'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') },
|
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// Junk, zero and negative takes fall back to the default rather than 400ing or
|
const Images = await getSlideshowImages(c.env.DB)
|
||||||
// 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()
|
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||||
return c.json({ Images, ValidTill })
|
return c.json({ Images, ValidTill })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,31 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import { authedId, authedRoles, unauthorized } from '../http'
|
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
|
||||||
BareBoolean,
|
BareBoolean,
|
||||||
CreateReportRequest,
|
|
||||||
CreateWarningRequest,
|
|
||||||
DeviceIdRequest,
|
DeviceIdRequest,
|
||||||
form,
|
form,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
ModerationBlockDetails,
|
ModerationBlockDetails,
|
||||||
SuccessErrorEnvelope,
|
|
||||||
UNAUTHORIZED_RESPONSE,
|
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
|
||||||
import { createWarning } from '../warnings-db'
|
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
|
||||||
import type { App } from '../context'
|
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 ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||||
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
||||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
|
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
||||||
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
|
// an empty string — the client distinguishes "no message" from a blank one.
|
||||||
// `Message` is null, not an empty string — the client distinguishes "no message"
|
|
||||||
// from a blank one.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Whether the caller is blocked',
|
summary: 'Whether the caller is blocked',
|
||||||
description:
|
description:
|
||||||
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||||
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
|
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
||||||
'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 ' +
|
'`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` is null rather than an empty string — the client distinguishes “no ' +
|
||||||
'message” from a blank one.',
|
'message” from a blank one.',
|
||||||
@@ -118,120 +69,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
(c) => c.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: '' })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
// 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
|
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
|
||||||
// bearer token and fires before account creation, so there is no caller to attribute
|
// bearer token and fires before account creation, so there is no caller to attribute
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
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 { parseFormIds, queryIds } from '../http'
|
||||||
import {
|
import {
|
||||||
BulkIdsRequest,
|
BulkIdsRequest,
|
||||||
@@ -19,35 +13,8 @@ import {
|
|||||||
ReputationDto,
|
ReputationDto,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
|
||||||
import type { Progression } from '@repo/domain'
|
|
||||||
import type { App } from '../context'
|
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
|
* 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.
|
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
|
||||||
@@ -102,20 +69,13 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
|||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Progression'],
|
tags: ['Progression'],
|
||||||
summary: 'A player’s level and XP',
|
summary: 'A player’s level and XP',
|
||||||
description:
|
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
|
||||||
'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')],
|
parameters: [idParam('id', 'Account id')],
|
||||||
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
(c) => {
|
||||||
const id = Number.parseInt(c.req.param('id'), 10)
|
const id = Number.parseInt(c.req.param('id'), 10)
|
||||||
const progression = await getProgression(c.env.DB, id)
|
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||||
await pushProgression(c, progression)
|
|
||||||
return c.json(progression)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -200,13 +160,12 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Progression'],
|
tags: ['Progression'],
|
||||||
summary: 'Progressions in bulk (GET form)',
|
summary: 'Progressions in bulk (GET form)',
|
||||||
description:
|
description:
|
||||||
'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
|
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
|
||||||
'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
|
'default level-1 progression per requested id, in request order.',
|
||||||
'ids that have earned nothing.',
|
|
||||||
parameters: BULK_ID_QUERY,
|
parameters: BULK_ID_QUERY,
|
||||||
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
|
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
|
||||||
}),
|
}),
|
||||||
async (c) => c.json(await getProgressions(c.env.DB, queryIds(c)))
|
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
'/api/v1/progression/bulk',
|
'/api/v1/progression/bulk',
|
||||||
|
|||||||
+13
-240
@@ -1,83 +1,41 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import {
|
|
||||||
acceptFriendRequest,
|
|
||||||
addFriend,
|
|
||||||
getAccountsByIds,
|
|
||||||
getMutualFriendIds,
|
|
||||||
getRelationshipsForPlayer,
|
|
||||||
MUTUAL_FRIENDS_LIMIT,
|
|
||||||
removeFriend,
|
|
||||||
sendFriendRequest,
|
|
||||||
setRelationshipFlag,
|
|
||||||
} from '@repo/domain'
|
|
||||||
import { logger } from '@repo/hono-helpers'
|
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 { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AckResponse,
|
AckResponse,
|
||||||
AUTHED,
|
AUTHED,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
|
||||||
intQuery,
|
intQuery,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
|
||||||
MutualFriendDto,
|
|
||||||
RelationshipDto,
|
RelationshipDto,
|
||||||
SendMessageRequest,
|
|
||||||
SendMultipleMessagesRequest,
|
|
||||||
SuccessErrorEnvelope,
|
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import {
|
||||||
|
acceptFriendRequest,
|
||||||
|
addFriend,
|
||||||
|
getRelationshipsForPlayer,
|
||||||
|
removeFriend,
|
||||||
|
sendFriendRequest,
|
||||||
|
setRelationshipFlag,
|
||||||
|
} from '../relationships-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { App } from '../context'
|
||||||
import type {
|
import type {
|
||||||
RelationshipChange,
|
RelationshipChange,
|
||||||
RelationshipFlag,
|
RelationshipFlag,
|
||||||
RelationshipResponse,
|
RelationshipResponse,
|
||||||
} from '@repo/domain'
|
} from '../relationships-db'
|
||||||
import type { App } from '../context'
|
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
/**
|
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
|
||||||
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
|
const RELATIONSHIP_CHANGED = 1
|
||||||
* `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
|
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||||
@@ -92,7 +50,7 @@ async function notifyRelationship(
|
|||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
playerId,
|
playerId,
|
||||||
NotificationType.RelationshipChanged,
|
RELATIONSHIP_CHANGED,
|
||||||
{ ...rel }
|
{ ...rel }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -241,191 +199,6 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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
|
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||||
|
|||||||
@@ -2,49 +2,16 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
|||||||
import { exports } from 'cloudflare:workers'
|
import { exports } from 'cloudflare:workers'
|
||||||
import { beforeAll, describe, expect, test } from 'vitest'
|
import { beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
import {
|
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
||||||
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 '../../api.app'
|
||||||
|
|
||||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-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 INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import {
|
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||||
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 { Env } from '../../context'
|
||||||
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
import type { SavedImage } from '../../images-db'
|
||||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -77,9 +44,14 @@ const TEST_ROOMS = [
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
await env.DB.prepare(
|
||||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
`CREATE TABLE IF NOT EXISTS room (
|
||||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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()
|
||||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
// 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).
|
// 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 stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -102,29 +74,13 @@ beforeAll(async () => {
|
|||||||
.run()
|
.run()
|
||||||
|
|
||||||
// Images table (owned by the img worker) — uploadsaved records a row here.
|
// Images table (owned by the img worker) — uploadsaved records a row here.
|
||||||
for (const stmt of IMAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||||
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
// 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()
|
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
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||||
@@ -138,13 +94,10 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||||
// 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 now = Math.floor(Date.now() / 1000)
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
|
JSON.stringify({ sub, exp: now + 3600 })
|
||||||
)}`
|
)}`
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -241,6 +194,17 @@ describe('public endpoints', () => {
|
|||||||
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
|
||||||
|
// No player-event storage → no tags in use → no chips. Trending is null.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
PinnedFilters: [],
|
||||||
|
PopularFilters: [],
|
||||||
|
TrendingFilters: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
|
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`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -250,6 +214,26 @@ describe('public endpoints', () => {
|
|||||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
|
||||||
|
// The client deserializes this as a bare array — an envelope here fails with
|
||||||
|
// "expected:'[', actual:'{'". No player-event storage yet → empty.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual([])
|
||||||
|
|
||||||
|
// The single-club form does wrap its events with a paging cursor.
|
||||||
|
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
|
||||||
|
expect(one.status).toBe(200)
|
||||||
|
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/playerevents/v1/searchlive returns an empty list', async () => {
|
||||||
|
// No player-event storage yet → nothing live to return.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/searchlive`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||||
@@ -312,89 +296,6 @@ describe('public endpoints', () => {
|
|||||||
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
|
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 () => {
|
test('POST /api/players/v2/progression/bulk returns an array', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -457,14 +358,12 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
|
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => {
|
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
|
||||||
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
|
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
|
||||||
expect(room.status).toBe(204)
|
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`)
|
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
|
||||||
expect(cats.status).toBe(200)
|
expect(cats.status).toBe(200)
|
||||||
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
|
expect(await cats.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /voice/config returns an object', async () => {
|
test('GET /voice/config returns an object', async () => {
|
||||||
@@ -532,7 +431,7 @@ describe('public endpoints', () => {
|
|||||||
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
|
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||||
})
|
})
|
||||||
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
||||||
'2026-07-12/x.inv'
|
'2026-07-12/x.inv'
|
||||||
@@ -564,49 +463,6 @@ describe('public endpoints', () => {
|
|||||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
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 () => {
|
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -638,51 +494,6 @@ 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 () => {
|
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -762,39 +573,6 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
|
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.
|
// Only the creator may retag; unknown inventions 404; no token → 401.
|
||||||
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
||||||
expect(notMine.status).toBe(403)
|
expect(notMine.status).toBe(403)
|
||||||
@@ -942,52 +720,6 @@ describe('public endpoints', () => {
|
|||||||
expect(await batch('')).toEqual([])
|
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 () => {
|
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.
|
// Two inventions created in room 76, one of them still a draft.
|
||||||
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
||||||
@@ -1169,16 +901,6 @@ describe('public endpoints', () => {
|
|||||||
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
||||||
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
|
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.
|
// allowTrial takes true/1.
|
||||||
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
||||||
expect(trial.Invention.AllowTrial).toBe(true)
|
expect(trial.Invention.AllowTrial).toBe(true)
|
||||||
@@ -1199,42 +921,6 @@ describe('public endpoints', () => {
|
|||||||
expect(anon.status).toBe(401)
|
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 () => {
|
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
|
||||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1326,15 +1012,12 @@ describe('public endpoints', () => {
|
|||||||
const ids = async (res: Response): Promise<number[]> =>
|
const ids = async (res: Response): Promise<number[]> =>
|
||||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||||
|
|
||||||
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
|
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
|
||||||
// the only inventions acquired so far in this file are an unpublished one and an id
|
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||||
// with no invention row — neither of which a public feed may show.
|
const beforeFeatured = await ids(
|
||||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
|
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
||||||
[]
|
|
||||||
)
|
|
||||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
|
expect(beforeFeatured).toEqual(beforeTop)
|
||||||
|
|
||||||
const feedInvention = (
|
const feedInvention = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -1372,42 +1055,19 @@ describe('public endpoints', () => {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
|
// Top: engagement-ranked, so the biggest download counts lead.
|
||||||
// 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()
|
|
||||||
|
|
||||||
// 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`))
|
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||||
expect(top).toEqual([201, 203])
|
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
||||||
|
expect(top).not.toContain(204)
|
||||||
|
expect(top).not.toContain(205)
|
||||||
|
|
||||||
// Featured: only the flagged, visible inventions — newest first. 201 is published but
|
// Featured: only the flagged, visible inventions — newest first.
|
||||||
// unflagged, so it stays out however popular it is.
|
|
||||||
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
||||||
expect(featured).toEqual([203, 202])
|
expect(featured).toEqual([203, 202])
|
||||||
|
|
||||||
// skip/take paginate both feeds.
|
// skip/take paginate the top feed.
|
||||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||||
expect(await ids(page)).toEqual([203])
|
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 () => {
|
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||||
@@ -1439,257 +1099,6 @@ 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', () => {
|
describe('rooms', () => {
|
||||||
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
||||||
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
||||||
@@ -1797,29 +1206,6 @@ describe('images', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
|
|
||||||
// without the cap a single anonymous request could pull the whole image table through
|
|
||||||
// the two joins behind it.
|
|
||||||
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
|
|
||||||
// 120 public ShareCamera photos — more than both the default and the cap.
|
|
||||||
for (let i = 0; i < 120; i++) {
|
|
||||||
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
|
|
||||||
}
|
|
||||||
const feed = async (query: string) => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
return ((await res.json()) as { Images: unknown[] }).Images.length
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(await feed('')).toBe(10)
|
|
||||||
expect(await feed('?take=25')).toBe(25)
|
|
||||||
expect(await feed('?take=500')).toBe(100)
|
|
||||||
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
|
|
||||||
expect(await feed('?take=0')).toBe(10)
|
|
||||||
expect(await feed('?take=-5')).toBe(10)
|
|
||||||
expect(await feed('?take=lots')).toBe(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||||
// Seed an image to cheer.
|
// Seed an image to cheer.
|
||||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||||
@@ -2533,1019 +1919,6 @@ describe('relationships', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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', () => {
|
describe('openapi', () => {
|
||||||
test('GET /openapi.json documents every route', async () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
@@ -3562,8 +1935,7 @@ describe('openapi', () => {
|
|||||||
// Every route the worker serves is described. This is the drift guard: adding a
|
// 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
|
// route without a describeRoute() block fails here rather than silently shipping
|
||||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||||
// `.on(['GET','POST'], …)` routes (the relationship mutations, invention update)
|
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
|
||||||
// contribute both methods.
|
|
||||||
const documented = new Set(
|
const documented = new Set(
|
||||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
@@ -3600,7 +1972,6 @@ describe('openapi', () => {
|
|||||||
'GET /api/inventions/v1',
|
'GET /api/inventions/v1',
|
||||||
'GET /api/inventions/v1/details',
|
'GET /api/inventions/v1/details',
|
||||||
'GET /api/inventions/v1/featured',
|
'GET /api/inventions/v1/featured',
|
||||||
'GET /api/inventions/v1/fulllineageowner',
|
|
||||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||||
'GET /api/inventions/v1/room',
|
'GET /api/inventions/v1/room',
|
||||||
'GET /api/inventions/v1/tagfilters',
|
'GET /api/inventions/v1/tagfilters',
|
||||||
@@ -3618,20 +1989,14 @@ describe('openapi', () => {
|
|||||||
'GET /api/messages/v2/get',
|
'GET /api/messages/v2/get',
|
||||||
'GET /api/playerReputation/v1/{id}',
|
'GET /api/playerReputation/v1/{id}',
|
||||||
'GET /api/playerReputation/v2/bulk',
|
'GET /api/playerReputation/v2/bulk',
|
||||||
'GET /api/playerevents/v1',
|
|
||||||
'GET /api/playerevents/v1/all',
|
'GET /api/playerevents/v1/all',
|
||||||
'GET /api/playerevents/v1/bulk',
|
|
||||||
'GET /api/playerevents/v1/club/{clubId}',
|
'GET /api/playerevents/v1/club/{clubId}',
|
||||||
'GET /api/playerevents/v1/clubs',
|
'GET /api/playerevents/v1/clubs',
|
||||||
'GET /api/playerevents/v1/search',
|
|
||||||
'GET /api/playerevents/v1/searchlive',
|
'GET /api/playerevents/v1/searchlive',
|
||||||
'GET /api/playerevents/v1/tagfilters',
|
'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/v1/progression/{id}',
|
||||||
'GET /api/players/v2/progression/bulk',
|
'GET /api/players/v2/progression/bulk',
|
||||||
'GET /api/quickPlay/v1/getandclear',
|
'GET /api/quickPlay/v1/getandclear',
|
||||||
'GET /api/relationships/mutualfriends',
|
|
||||||
'GET /api/relationships/v1/favorite',
|
'GET /api/relationships/v1/favorite',
|
||||||
'GET /api/relationships/v1/ignore',
|
'GET /api/relationships/v1/ignore',
|
||||||
'GET /api/relationships/v1/mute',
|
'GET /api/relationships/v1/mute',
|
||||||
@@ -3648,29 +2013,20 @@ describe('openapi', () => {
|
|||||||
'GET /api/rooms/v1/filters',
|
'GET /api/rooms/v1/filters',
|
||||||
'GET /api/versioncheck/v4',
|
'GET /api/versioncheck/v4',
|
||||||
'GET /voice/config',
|
'GET /voice/config',
|
||||||
|
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
'POST /api/PlayerReporting/v3/create',
|
|
||||||
'POST /api/avatar/v2/gifts/generate',
|
'POST /api/avatar/v2/gifts/generate',
|
||||||
'POST /api/gamesight/event',
|
'POST /api/gamesight/event',
|
||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/update',
|
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
'POST /api/messages/v1/sendMultiple',
|
|
||||||
'POST /api/messages/v2/send',
|
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/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/v1/progression/bulk',
|
||||||
'POST /api/players/v2/progression/bulk',
|
'POST /api/players/v2/progression/bulk',
|
||||||
'POST /api/playerwarnings',
|
|
||||||
'POST /api/relationships/v1/favorite',
|
'POST /api/relationships/v1/favorite',
|
||||||
'POST /api/relationships/v1/ignore',
|
'POST /api/relationships/v1/ignore',
|
||||||
'POST /api/relationships/v1/mute',
|
'POST /api/relationships/v1/mute',
|
||||||
@@ -3716,195 +2072,3 @@ describe('openapi', () => {
|
|||||||
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
|
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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"CurrentAnnouncement": {
|
"CurrentAnnouncement": {
|
||||||
"Message": "Server powered by RecFlare",
|
"Message": "Server powered by RecFlare",
|
||||||
"MoreInfoUrl": "https://recflare.net"
|
"MoreInfoUrl": "https://github.com/djdevin/recflare"
|
||||||
},
|
},
|
||||||
"InstagramImages": [
|
"InstagramImages": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,19 +33,19 @@
|
|||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.DC",
|
"Key": "AntiHile.DC",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "false"
|
"Value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.LPD",
|
"Key": "AntiHile.LPD",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "false"
|
"Value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.QD",
|
"Key": "AntiHile.QD",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "false"
|
"Value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "Backtrace.stopTimeUTC",
|
"Key": "Backtrace.stopTimeUTC",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "2026-06-01 00:00"
|
"Value": "9999-09-28 23:55"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
|
|||||||
+20
-72
@@ -42,79 +42,35 @@ route without documenting it fails rather than silently shipping an incomplete s
|
|||||||
without matchmaking. A posted `password` becomes the login credential.
|
without matchmaking. A posted `password` becomes the login credential.
|
||||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
- **`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
|
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||||
identity `platform_auth` proves.
|
identity the Steam ticket proves.
|
||||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
- **`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.
|
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
|
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||||
the account by `username` or numeric `account_id` and requires the matching password
|
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
|
(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
|
all, which is what closes id/username-only takeover.
|
||||||
`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`
|
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.
|
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||||
|
|
||||||
### Verifiable platforms: Steam and Meta
|
### Steam is the only verifiable platform
|
||||||
|
|
||||||
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
|
||||||
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
|
ticket and checks Steam's signature against Steam's system public key. No publisher
|
||||||
asserts a platform) must be a platform we can verify. Two are:
|
Web API key, no network call. Steam (platform `0`) is therefore the only platform
|
||||||
|
whose identity can be proven, so any grant that authenticates _by platform identity_
|
||||||
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
|
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
|
||||||
Steam's signature against Steam's system public key. Verified **offline**: no
|
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
|
||||||
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
|
ever written to an account's `platformId`.
|
||||||
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
|
## Signup caps
|
||||||
|
|
||||||
`create_account` is capped on two independent arms, per verified platform identity and
|
`create_account` is capped on two independent arms, per verified platform id and per
|
||||||
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
|
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
|
||||||
arm is coarse and will produce false positives behind NAT, shared campus and mobile
|
is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||||
networks. Both default to 3.
|
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`,
|
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
|
`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
|
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||||
@@ -122,12 +78,11 @@ small private server, or when a shared network is being locked out.
|
|||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------- | ------------------------------------------------------ |
|
||||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
| `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` |
|
||||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
|
||||||
|
|
||||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
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
|
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||||
@@ -154,19 +109,12 @@ wrangler secrets-store store create recflare --scopes workers
|
|||||||
|
|
||||||
# Set the shared signing key (prompted for the value)
|
# Set the shared signing key (prompted for the value)
|
||||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
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 local values (omit `--remote`) so `.get()` resolves:
|
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
|
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 signing key invalidates all existing tokens (clients re-authenticate).
|
Rotating the store value invalidates all existing tokens (clients re-authenticate).
|
||||||
The Meta secret is read per request, so updating it takes effect without a redeploy.
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
-- 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 <> '';
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
+117
-506
@@ -3,12 +3,13 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
countAccountsByPlatformId,
|
||||||
countAccountsBySignupIp,
|
countAccountsBySignupIp,
|
||||||
createAccount,
|
createAccount,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
getAccountsByIds,
|
getAccountsByPlatformId,
|
||||||
getPasswordHash,
|
getPasswordHash,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -18,16 +19,11 @@ import {
|
|||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
updateAccount,
|
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
|
||||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
|
||||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
|
||||||
import { verifyMetaNonce } from './meta-nonce'
|
|
||||||
import {
|
import {
|
||||||
CachedLogin,
|
CachedLogin,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
@@ -42,61 +38,21 @@ import {
|
|||||||
TokenRequest,
|
TokenRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
import {
|
|
||||||
countAccountsForPlatformIdentity,
|
|
||||||
getLinksForPlatformId,
|
|
||||||
getLinksForPlatformIdentity,
|
|
||||||
isPlatformIdentityLinked,
|
|
||||||
linkPlatformIdentity,
|
|
||||||
} from './platform-db'
|
|
||||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||||
import { verifySteamTicket } from './steam-ticket'
|
import { verifySteamTicket } from './steam-ticket'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
import type { PlatformLink } from './platform-db'
|
|
||||||
|
|
||||||
/** OAuth scopes granted by `/connect/token`. */
|
/** OAuth scopes granted by `/connect/token`. */
|
||||||
const TOKEN_SCOPE =
|
const TOKEN_SCOPE =
|
||||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||||
|
|
||||||
/**
|
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
||||||
* 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 = {
|
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||||
platform: PlatformType.Oculus,
|
platform: PlatformType.Oculus,
|
||||||
platformId: SIDELOAD_PLATFORM_ID,
|
platformId: '1',
|
||||||
accountId: 1,
|
accountId: 1,
|
||||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||||
requirePassword: true,
|
requirePassword: true,
|
||||||
@@ -195,201 +151,63 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
* The elevated role names for an account's token `role` claim, derived from its
|
||||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
* role flags. Base roles (gameClient) are added by generateToken — these are only
|
||||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
* the operator-granted extras. Order is stable so tokens are deterministic.
|
||||||
* 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(
|
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
|
||||||
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
|
if (!account) return []
|
||||||
): string[] {
|
const roles: string[] = []
|
||||||
const roles = ['screenshare']
|
|
||||||
if (!account) return roles
|
|
||||||
if (account.isDeveloper) roles.push('developer')
|
if (account.isDeveloper) roles.push('developer')
|
||||||
if (account.isModerator) roles.push('moderator')
|
if (account.isModerator) roles.push('moderator')
|
||||||
if (account.isJunior) roles.push('junior')
|
|
||||||
return roles
|
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`
|
* 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
|
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||||
* platform recorded — and until Meta verification landed Steam was the only identity
|
* platform recorded — and Steam is the only platform whose identity we can prove, so
|
||||||
* we could prove, so an unset one *is* Steam. Every account bound since records its
|
* an unset one *is* Steam.
|
||||||
* platform explicitly; this default only covers those older rows.
|
|
||||||
*/
|
*/
|
||||||
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||||
return account.platform ?? 0
|
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
|
* 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
|
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
||||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
* (the platform_auth ticket) 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, link: PlatformLink) {
|
function toCachedLogin(account: Account) {
|
||||||
return {
|
return {
|
||||||
platform: link.platform,
|
platform: accountPlatform(account),
|
||||||
platformId: link.platformId,
|
platformId: account.platformId ?? '',
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||||
requirePassword: false,
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -401,14 +219,6 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -440,36 +250,33 @@ const app = new Hono<App>()
|
|||||||
tags: ['Cached login'],
|
tags: ['Cached login'],
|
||||||
summary: 'Accounts linked to a platform id',
|
summary: 'Accounts linked to a platform id',
|
||||||
description: [
|
description: [
|
||||||
'Accounts the client may offer on its login screen for this platform identity —',
|
'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',
|
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
|
||||||
'`cached_login` grant (both read the same table). An account linked to several',
|
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
|
||||||
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
'back to a fresh login or create_account.',
|
||||||
'and the client falls back to a fresh login or create_account.',
|
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
|
||||||
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
|
'canned, non-redeemable entry with `requirePassword: true`.',
|
||||||
'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(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'platform',
|
name: 'platform',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
|
description: 'PlatformType integer. A non-numeric value disables the link filter.',
|
||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'id',
|
name: 'id',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
|
description: 'Platform-native id — a SteamID64 for Steam.',
|
||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
CachedLogin.or(FakeCachedLogin).array(),
|
CachedLogin.or(FakeCachedLogin).array(),
|
||||||
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
|
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -477,27 +284,21 @@ const app = new Hono<App>()
|
|||||||
const { platform, id } = c.req.param()
|
const { platform, id } = c.req.param()
|
||||||
logger.info('cached login lookup', { platform, id })
|
logger.info('cached login lookup', { platform, id })
|
||||||
const platformInt = Number.parseInt(platform, 10)
|
const platformInt = Number.parseInt(platform, 10)
|
||||||
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
|
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
|
||||||
// produce a real Meta identity or a nonce to prove one with — it asks about the
|
// the real path would always yield []. Hand back one canned entry instead, so the
|
||||||
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
|
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
||||||
// login screen with nothing to do. Hand back one canned entry to push it onto the
|
// genuine cached login there is no platform ticket behind this, so the client must
|
||||||
// username/password login instead, which is the only flow such a build can finish.
|
// prompt. Delete this branch once Oculus platform auth lands.
|
||||||
// `requirePassword` is true for exactly that reason: there's no platform proof here,
|
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||||
// and the `cached_login` grant would (correctly) refuse this entry.
|
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||||
//
|
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||||
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
|
return c.json(
|
||||||
// real Meta logins, and shadowing the whole platform would hide genuine links from
|
accounts
|
||||||
// their pickers.
|
.filter(
|
||||||
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
|
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
|
||||||
return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
)
|
||||||
}
|
.map(toCachedLogin)
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -511,8 +312,8 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
'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',
|
'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',
|
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
|
||||||
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||||
@@ -523,8 +324,7 @@ const app = new Hono<App>()
|
|||||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||||
for (const pid of ids) {
|
for (const pid of ids) {
|
||||||
// No platform accompanies these ids, so they match on any platform.
|
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||||
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
|
|
||||||
}
|
}
|
||||||
return c.json(out)
|
return c.json(out)
|
||||||
}
|
}
|
||||||
@@ -545,13 +345,12 @@ const app = new Hono<App>()
|
|||||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
'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',
|
'`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`',
|
'`platform`, that platform must be Steam and `platform_auth` must verify.',
|
||||||
'must verify.',
|
|
||||||
'',
|
'',
|
||||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||||
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
|
||||||
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
'`account_id` must be linked to exactly the identity that ticket proves. An account',
|
||||||
'link for that identity cannot be cached-logged-into.',
|
'with no stored platform identity cannot be cached-logged-into.',
|
||||||
'',
|
'',
|
||||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
'**`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.',
|
'platform and platform id come from what was stored at issue time, not the body.',
|
||||||
@@ -559,48 +358,16 @@ const app = new Hono<App>()
|
|||||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
'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,',
|
'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`',
|
'which is what closes id/username-only takeover.',
|
||||||
'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;',
|
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
|
||||||
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
|
||||||
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
|
||||||
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
'`platform_id` and is the only value ever written to an account. Password and refresh',
|
||||||
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
|
'grants carry their own credential and are not gated this way.',
|
||||||
'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',
|
'**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',
|
'powers refresh on every login and every refresh grant.',
|
||||||
'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'),
|
].join('\n'),
|
||||||
requestBody: form(
|
requestBody: form(
|
||||||
TokenRequest,
|
TokenRequest,
|
||||||
@@ -611,18 +378,13 @@ const app = new Hono<App>()
|
|||||||
400: json(
|
400: json(
|
||||||
OAuthError,
|
OAuthError,
|
||||||
[
|
[
|
||||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
|
||||||
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||||
'or a banned account',
|
|
||||||
].join(' ')
|
].join(' ')
|
||||||
),
|
),
|
||||||
500: json(
|
500: json(
|
||||||
OAuthError,
|
OAuthError,
|
||||||
[
|
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
|
||||||
'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(' ')
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -657,91 +419,43 @@ const app = new Hono<App>()
|
|||||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||||
|
|
||||||
// A platform-authenticated login proves who you are with the platform itself, and
|
// A platform-authenticated login proves who you are with the platform itself,
|
||||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
// ticket. So those logins must be Steam:
|
||||||
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||||
//
|
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||||
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||||
// platform is fatal:
|
// is the password-account path — allowed, but it binds no platformId.)
|
||||||
// - cached_login authenticates purely by platform identity.
|
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
||||||
// - create_account that asserts a platform: we won't bind an identity we can't
|
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
||||||
// prove. (create_account with NO platform is the password-account path —
|
// and refresh_token grants carry their own credential and aren't gated here.
|
||||||
// allowed, but binds no platformId.)
|
let verifiedSteamId: string | null = null
|
||||||
//
|
|
||||||
// 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 platformAsserted = !Number.isNaN(platformInt)
|
const platformAsserted = !Number.isNaN(platformInt)
|
||||||
const gatedOnPlatform =
|
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||||
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
|
if (platformInt !== PlatformType.Steam) {
|
||||||
// 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' }
|
|
||||||
|
|
||||||
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 (gatedOnPlatform && proof.status !== 'verified') {
|
|
||||||
if (proof.status === 'unsupported') {
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
error_description: 'unsupported platform; only Steam and Meta can be verified',
|
error_description: 'unsupported platform; only Steam can be verified',
|
||||||
},
|
},
|
||||||
400
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (proof.status === 'unconfigured') {
|
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||||
// An operator misconfiguration, not the client's fault: without the app secret
|
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||||
// every Meta player is locked out, so it answers 500 the way an unset
|
if (!verified) {
|
||||||
// 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(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'server_error',
|
error: 'invalid_grant',
|
||||||
error_description: 'Meta platform verification is not configured',
|
error_description: 'invalid or missing platform_auth ticket',
|
||||||
},
|
},
|
||||||
500
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The reason is for the operator; the client is told only that it was rejected.
|
verifiedSteamId = verified.steamId
|
||||||
// A wrong app secret and a stale nonce look identical from the client side.
|
platformId = verified.steamId
|
||||||
return c.json(
|
|
||||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
|
|
||||||
400
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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:
|
// Resolve the account this token is for:
|
||||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
// - 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.
|
// username — players don't pick one initially); the token's `sub` is its id.
|
||||||
@@ -756,35 +470,6 @@ const app = new Hono<App>()
|
|||||||
// via create_account or /account/me/changepassword.
|
// via create_account or /account/me/changepassword.
|
||||||
let accountId: string
|
let accountId: string
|
||||||
if (grantType === 'create_account') {
|
if (grantType === 'create_account') {
|
||||||
// A banned player's next move is a new account, so the ban is checked BEFORE
|
|
||||||
// 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
|
// 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
|
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||||
@@ -797,16 +482,10 @@ const app = new Hono<App>()
|
|||||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||||
if (
|
if (
|
||||||
maxPerPlatformId > 0 &&
|
maxPerPlatformId > 0 &&
|
||||||
verifiedPlatformId !== null &&
|
verifiedSteamId !== null &&
|
||||||
(await countAccountsForPlatformIdentity(
|
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||||
c.env.DB,
|
|
||||||
verifiedPlatform ?? 0,
|
|
||||||
verifiedPlatformId
|
|
||||||
)) >= maxPerPlatformId
|
|
||||||
) {
|
) {
|
||||||
logger.info('signup rejected: platform account limit', {
|
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||||
platformId: verifiedPlatformId,
|
|
||||||
})
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -830,15 +509,14 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||||
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||||
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
// so only this Steam user can log back into the account. A password/anonymous
|
||||||
// (for the account DTO and the refresh grant's claims); the link written just
|
// create_account (no platform) binds no platformId.
|
||||||
// below is what a later cached login is actually authorized against.
|
|
||||||
const account = await createAccount(c.env.DB, {
|
const account = await createAccount(c.env.DB, {
|
||||||
platforms: platformInt || 0,
|
platforms: platformInt || 0,
|
||||||
platform: verifiedPlatform ?? undefined,
|
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||||
platformId: verifiedPlatformId ?? undefined,
|
platformId: verifiedSteamId ?? undefined,
|
||||||
lastLoginTime: new Date().toISOString(),
|
lastLoginTime: new Date().toISOString(),
|
||||||
deviceId: deviceId || undefined,
|
deviceId: deviceId || undefined,
|
||||||
deviceClass: deviceId ? deviceClass : undefined,
|
deviceClass: deviceId ? deviceClass : undefined,
|
||||||
@@ -846,14 +524,6 @@ const app = new Hono<App>()
|
|||||||
lastLoginIp: clientIp || undefined,
|
lastLoginIp: clientIp || undefined,
|
||||||
})
|
})
|
||||||
accountId = String(account.accountId)
|
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).
|
// Establish the login password when one is posted (raw password never stored).
|
||||||
const password = typeof body.password === 'string' ? body.password : ''
|
const password = typeof body.password === 'string' ? body.password : ''
|
||||||
if (password !== '') {
|
if (password !== '') {
|
||||||
@@ -877,25 +547,18 @@ const app = new Hono<App>()
|
|||||||
} else if (grantType === 'cached_login') {
|
} else if (grantType === 'cached_login') {
|
||||||
// Platform-authenticated login into an already-linked account. The client posts
|
// Platform-authenticated login into an already-linked account. The client posts
|
||||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||||
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||||
// table says that account is linked to exactly this platform identity — this is
|
// account is linked to exactly this platform identity — this is the check that
|
||||||
// the check that keeps anyone but that platform user out of the account
|
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||||
// (platform ownership is the credential; no password needed). An account with no
|
// ownership is the credential; no password needed). An account with no stored
|
||||||
// link for the presented identity must use a password.
|
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||||
//
|
//
|
||||||
// The picker lists straight from the same table, so it can only offer accounts
|
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||||
// this check accepts.
|
// above), never the client-supplied field. See steam-ticket.ts.
|
||||||
//
|
|
||||||
// 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 postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||||
const linked =
|
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||||
account !== null &&
|
|
||||||
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
|
|
||||||
if (!account || !linked) {
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -937,61 +600,10 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
accountId = String(resolvedId)
|
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 setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||||
}
|
}
|
||||||
|
|
||||||
// A banned player gets no token — and with no token every other worker is shut to
|
|
||||||
// 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),
|
|
||||||
})
|
|
||||||
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: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
|
|
||||||
},
|
|
||||||
400
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
// 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
|
// 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
|
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||||
@@ -1021,8 +633,7 @@ const app = new Hono<App>()
|
|||||||
platformId,
|
platformId,
|
||||||
platform,
|
platform,
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount),
|
accountRoles(roleAccount)
|
||||||
accountPrivileges(roleAccount)
|
|
||||||
)
|
)
|
||||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ export type Env = SharedHonoEnv & {
|
|||||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
// 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).
|
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||||
JWT_SECRET: SecretsStoreSecret
|
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).
|
// 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.
|
// 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
|
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||||
@@ -26,18 +19,6 @@ export type Env = SharedHonoEnv & {
|
|||||||
// read them through `intVar`, never as a bare number.
|
// read them through `intVar`, never as a bare number.
|
||||||
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
||||||
MAX_ACCOUNTS_PER_IP?: 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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }
|
|
||||||
}
|
|
||||||
@@ -69,33 +69,21 @@ export const PlatformType = {
|
|||||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
* A PlatformType by value. Only Steam can actually be verified — see the
|
||||||
* see the platform-auth notes on `POST /connect/token`.
|
* platform-auth notes on `POST /connect/token`.
|
||||||
*/
|
*/
|
||||||
export const PlatformTypeSchema = z
|
export const PlatformTypeSchema = z
|
||||||
.union([
|
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
||||||
z.literal(-1),
|
|
||||||
z
|
|
||||||
.int()
|
|
||||||
.min(0)
|
|
||||||
.max(Math.max(...Object.values(PlatformType))),
|
|
||||||
])
|
|
||||||
.describe(
|
.describe(
|
||||||
Object.entries(PlatformType)
|
Object.entries(PlatformType)
|
||||||
.map(([name, value]) => `${value} ${name}`)
|
.map(([name, value]) => `${value} ${name}`)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||||
* 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({
|
export const CachedLogin = z.object({
|
||||||
platform: PlatformTypeSchema,
|
platform: PlatformTypeSchema,
|
||||||
platformId: z
|
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||||
.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'),
|
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"),
|
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||||
requirePassword: z
|
requirePassword: z
|
||||||
@@ -104,9 +92,8 @@ export const CachedLogin = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
|
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
|
||||||
* but `requirePassword` is true — with no Meta SDK there is nothing to prove platform
|
* is true — nothing proves platform ownership, so the client has to prompt.
|
||||||
* ownership with, so the client falls through to username/password.
|
|
||||||
*/
|
*/
|
||||||
export const FakeCachedLogin = CachedLogin.extend({
|
export const FakeCachedLogin = CachedLogin.extend({
|
||||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||||
@@ -152,18 +139,11 @@ export const TokenRequest = z.object({
|
|||||||
platform_id: z
|
platform_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
|
||||||
'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
|
platform_auth: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
||||||
'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'),
|
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||||
device_id: z
|
device_id: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -8,24 +8,12 @@ import {
|
|||||||
getAccountsByDeviceId,
|
getAccountsByDeviceId,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
|
||||||
SCHEMA_DDL,
|
SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
|
||||||
|
|
||||||
import {
|
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||||
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 { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -43,28 +31,14 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
|
|||||||
// accounts the login tests authenticate as (42, 77).
|
// accounts the login tests authenticate as (42, 77).
|
||||||
const LOGIN_PASSWORD = 'correct-horse'
|
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),
|
// 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
|
// and seed the Orientation room (owned by the rooms worker) so signup can place
|
||||||
// the new player there.
|
// the new player there.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
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 SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of REFRESH_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.
|
// 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()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
@@ -75,9 +49,12 @@ beforeAll(async () => {
|
|||||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
await env.DB.prepare(
|
||||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
`CREATE TABLE IF NOT EXISTS room (
|
||||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
data TEXT NOT NULL,
|
||||||
|
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||||
|
)`
|
||||||
|
).run()
|
||||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
// 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()
|
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
await seedRoomWithSubRooms(env.DB, {
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
@@ -86,27 +63,8 @@ beforeAll(async () => {
|
|||||||
IsDorm: false,
|
IsDorm: false,
|
||||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||||
})
|
})
|
||||||
// Report table (owned by the api worker) — a banned account is refused a token, and
|
|
||||||
// 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. */
|
/** Decode a JWT payload (no verification) for asserting claims. */
|
||||||
function decodePayload(token: string): Record<string, unknown> {
|
function decodePayload(token: string): Record<string, unknown> {
|
||||||
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
@@ -143,51 +101,6 @@ async function postToken(
|
|||||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
|
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. */
|
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||||
function changePassword(body: string, token?: string): Promise<Response> {
|
function changePassword(body: string, token?: string): Promise<Response> {
|
||||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||||
@@ -209,25 +122,16 @@ describe('auth worker routes', () => {
|
|||||||
expect(await res.text()).toBe('"AA=="')
|
expect(await res.text()).toBe('"AA=="')
|
||||||
})
|
})
|
||||||
|
|
||||||
test.each([
|
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
|
||||||
['0 (Steam)', 0],
|
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
||||||
['1 (Meta)', 1],
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
|
||||||
])(
|
expect(res.status).toBe(200)
|
||||||
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
|
expect(await res.json()).toEqual([])
|
||||||
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
|
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
|
||||||
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
|
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
|
||||||
// the platform login screen and onto username/password.
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
|
||||||
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(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([
|
expect(await res.json()).toEqual([
|
||||||
{
|
{
|
||||||
@@ -240,11 +144,10 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
|
||||||
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
// so every OTHER platform is rejected on the platform-authenticated grants — we
|
||||||
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
// won't bind or authorize an identity we can't prove.
|
||||||
// can't prove.
|
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
|
||||||
'create_account rejects unverifiable platform %i',
|
'create_account rejects unverifiable platform %i',
|
||||||
async (platform) => {
|
async (platform) => {
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
@@ -252,11 +155,11 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
expect(res.json.error).toBe('invalid_grant')
|
||||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
expect(res.json.error_description).toContain('only Steam')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||||
'cached_login rejects unverifiable platform %i',
|
'cached_login rejects unverifiable platform %i',
|
||||||
async (platform) => {
|
async (platform) => {
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
@@ -264,7 +167,7 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
expect(res.json.error).toBe('invalid_grant')
|
||||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
expect(res.json.error_description).toContain('only Steam')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,109 +191,6 @@ describe('auth worker routes', () => {
|
|||||||
expect(res.json.error_description).toContain('platform_auth')
|
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 () => {
|
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
|
// Seed a Steam-linked account directly (a real create_account needs a live
|
||||||
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
||||||
@@ -406,7 +206,6 @@ describe('auth worker routes', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([
|
expect(await res.json()).toEqual([
|
||||||
@@ -420,69 +219,32 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
|
||||||
// The point of the link table. The same account is reachable from the PC and from
|
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
|
||||||
// the headset, and each picker reports the identity IT was asked about — that's
|
// Steam-linked account can carry a platformId with no platform. The picker offered
|
||||||
// what the client posts back on the cached_login grant.
|
// such an account (it treats a missing platform as Steam) while the cached_login
|
||||||
const steamId = '76561197962463777'
|
// grant rejected it — "no linked account for this platform identity" forever.
|
||||||
const metaId = '27061366730207777'
|
// Both now run the same check.
|
||||||
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 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)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
||||||
.run()
|
.run()
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||||
// No link row yet: not offered.
|
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
|
||||||
const before = await cachedLogins(0, steamId)
|
expect(offered.map((a) => a.accountId)).toContain(8)
|
||||||
expect(before.map((a) => a.accountId)).not.toContain(8)
|
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||||
|
|
||||||
// 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 () => {
|
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||||
@@ -498,7 +260,7 @@ describe('auth worker routes', () => {
|
|||||||
expires_in: number
|
expires_in: number
|
||||||
}
|
}
|
||||||
expect(json.token_type).toBe('Bearer')
|
expect(json.token_type).toBe('Bearer')
|
||||||
expect(json.expires_in).toBe(TOKEN_TTL_SECONDS)
|
expect(json.expires_in).toBe(3600)
|
||||||
// header.payload.signature
|
// header.payload.signature
|
||||||
const parts = json.access_token.split('.')
|
const parts = json.access_token.split('.')
|
||||||
expect(parts).toHaveLength(3)
|
expect(parts).toHaveLength(3)
|
||||||
@@ -515,14 +277,9 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||||
expect(payload.role).toContain('gameClient')
|
expect(payload.role).toContain('gameClient')
|
||||||
// screenshare is a feature gate, not a grant — every token carries it.
|
// A plain account carries only the base role — no elevated roles.
|
||||||
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('developer')
|
||||||
expect(payload.role).not.toContain('moderator')
|
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')
|
expect(payload.scope).toContain('rn.api')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -542,25 +299,6 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
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 () => {
|
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' })
|
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
@@ -843,157 +581,6 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.platform_id).toBe('steam-123')
|
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 () => {
|
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 login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||||
const refreshToken = login.json.refresh_token as string
|
const refreshToken = login.json.refresh_token as string
|
||||||
@@ -1133,272 +720,3 @@ describe('auth worker routes', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
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,23 +22,11 @@
|
|||||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
// 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"
|
// 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.
|
// 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": [
|
"secrets_store_secrets": [
|
||||||
{
|
{
|
||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
},
|
|
||||||
{
|
|
||||||
"binding": "META_APP_SECRET",
|
|
||||||
"store_id": "local",
|
|
||||||
"secret_name": "META_APP_SECRET"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|||||||
+40
-75
@@ -2,13 +2,7 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
withCleanSpec,
|
|
||||||
withDefaultCors,
|
|
||||||
withNotFound,
|
|
||||||
withOnError,
|
|
||||||
writeContentRange,
|
|
||||||
} from '@repo/hono-helpers'
|
|
||||||
|
|
||||||
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
|
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
|
||||||
import {
|
import {
|
||||||
@@ -29,41 +23,38 @@ import type { App, Env } from './context'
|
|||||||
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
|
* 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,
|
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
|
||||||
* honoring Range requests. 404s when the file is missing.
|
* honoring Range requests. 404s when the file is missing.
|
||||||
* Supports conditional GET and byte-range requests (206) — large-file
|
* Supports conditional GET and byte-range requests (206) — large-file
|
||||||
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
|
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
|
||||||
* reassembled file (e.g. EAC "Signatures don't match").
|
* 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) {
|
async function serveAsset(c: Context<App>, key: string) {
|
||||||
if (key.includes('..')) return c.body(null, 400)
|
if (key.includes('..')) return c.body(null, 400)
|
||||||
|
|
||||||
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
|
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
|
||||||
// R2 parses the `Range` header itself when handed the request headers, so there is no
|
const range = parseRange(c.req.header('range'))
|
||||||
// grammar to reimplement here. It resolves every form (`bytes=a-b`, `bytes=a-`,
|
const object = await (c.env as Env).CDN_ASSETS.get(key, {
|
||||||
// `bytes=-n`) to a concrete offset/length, and anything it cannot parse or satisfy to
|
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||||||
// the whole object — see the 206 branch, which is what turns that back into a 200.
|
...(range ? { range } : {}),
|
||||||
// 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()
|
if (!object) return c.notFound()
|
||||||
|
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
@@ -76,12 +67,20 @@ async function serveAsset(c: Context<App>, key: string) {
|
|||||||
// Precondition matched (If-None-Match) → R2 returns no body.
|
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||||
|
|
||||||
// A `bytes=` request is ALWAYS answered 206 with a Content-Range naming the bytes
|
// Range honored → 206 Partial Content with Content-Range.
|
||||||
// actually enclosed — never a bare 200 carrying the whole object. That is the one
|
if (object.range && c.req.header('range')) {
|
||||||
// answer a chunked downloader cannot survive: it asked for a slice, so it writes
|
// R2 hands back the RESOLVED range, and the object it returns carries all three
|
||||||
// whatever comes back at that offset, and a whole-object body silently corrupts the
|
// keys with the inapplicable ones set to undefined — so `'suffix' in r` is true
|
||||||
// reassembled file (EAC "Signatures don't match"). See writeContentRange().
|
// even for an offset/length range and cannot discriminate between the two forms.
|
||||||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
// (It read as a suffix range every time, making offset/length NaN and the
|
||||||
|
// Content-Range header garbage.) Read the values, not the keys. A `bytes=-N`
|
||||||
|
// request already comes back resolved to a concrete offset/length; the suffix
|
||||||
|
// fallback below is only there in case that ever stops being true.
|
||||||
|
const r = object.range as { offset?: number; length?: number; suffix?: number }
|
||||||
|
const length = r.length ?? r.suffix ?? object.size - (r.offset ?? 0)
|
||||||
|
const offset = r.offset ?? object.size - length
|
||||||
|
headers.set('content-length', String(length))
|
||||||
|
headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`)
|
||||||
return new Response(object.body, { status: 206, headers })
|
return new Response(object.body, { status: 206, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,15 +98,6 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -205,28 +195,6 @@ const app = new Hono<App>()
|
|||||||
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
|
(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
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
app.get(
|
app.get(
|
||||||
@@ -241,11 +209,10 @@ app.get(
|
|||||||
description: [
|
description: [
|
||||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
|
||||||
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
|
'R2 bucket, plus the one bundled config file the loading screen reads.',
|
||||||
'screen reads.',
|
|
||||||
'',
|
'',
|
||||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
|
||||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
'`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',
|
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||||
'authenticated call to another worker.',
|
'authenticated call to another worker.',
|
||||||
@@ -257,9 +224,7 @@ app.get(
|
|||||||
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
|
'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 —',
|
'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',
|
'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',
|
'error.',
|
||||||
'always carries a `Content-Range` stating which bytes the body holds, even where',
|
|
||||||
'that turns out to be all of them.',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject
|
|||||||
304: { description: '`If-None-Match` matched the stored etag (no body)' },
|
304: { description: '`If-None-Match` matched the stored etag (no body)' },
|
||||||
400: { description: 'The key contains `..` (no body)' },
|
400: { description: 'The key contains `..` (no body)' },
|
||||||
404: { description: 'No such object in the bucket' },
|
404: { description: 'No such object in the bucket' },
|
||||||
416: { description: 'The `Range` header could not be satisfied (no body)' },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
|
|||||||
in: 'header',
|
in: 'header',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
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).',
|
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.',
|
||||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
schema: { type: 'string', example: 'bytes=0-1023' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,44 +73,6 @@ describe('cdn endpoints', () => {
|
|||||||
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
|
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 () => {
|
test('GET /room/:dataBlob streams the room blob from R2', async () => {
|
||||||
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
|
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
|
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
|
||||||
@@ -124,20 +86,6 @@ describe('cdn endpoints', () => {
|
|||||||
expect(res.status).toBe(404)
|
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 () => {
|
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
|
||||||
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
|
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
|
||||||
// api worker hands back as the invention's BlobName.
|
// api worker hands back as the invention's BlobName.
|
||||||
@@ -153,21 +101,6 @@ describe('cdn endpoints', () => {
|
|||||||
expect(res.status).toBe(404)
|
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 () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -191,7 +124,6 @@ describe('cdn endpoints', () => {
|
|||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'GET /',
|
'GET /',
|
||||||
'GET /config/LoadingScreenTipData',
|
'GET /config/LoadingScreenTipData',
|
||||||
'GET /data/{id}',
|
|
||||||
'GET /invention/{dataBlob}',
|
'GET /invention/{dataBlob}',
|
||||||
'GET /room/{dataBlob}',
|
'GET /room/{dataBlob}',
|
||||||
'GET /sigs/{sigName}',
|
'GET /sigs/{sigName}',
|
||||||
|
|||||||
+1
-10
@@ -4,17 +4,8 @@
|
|||||||
"main": "src/cdn.app.ts",
|
"main": "src/cdn.app.ts",
|
||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"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": {
|
"cache": {
|
||||||
"enabled": false
|
"enabled": true
|
||||||
},
|
},
|
||||||
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
||||||
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import '../../chat.app'
|
import '../../chat.app'
|
||||||
|
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
|
||||||
import {
|
import {
|
||||||
ChatModerationState,
|
ChatModerationState,
|
||||||
getMessage,
|
getMessage,
|
||||||
@@ -722,7 +721,7 @@ describe('ChatMessageReceived push', () => {
|
|||||||
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
||||||
interface SentNotification {
|
interface SentNotification {
|
||||||
playerId: number
|
playerId: number
|
||||||
notificationType: NotificationType
|
notificationType: number
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
}
|
}
|
||||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||||
@@ -760,9 +759,8 @@ describe('ChatMessageReceived push', () => {
|
|||||||
|
|
||||||
const sent = await hub.getByName('global').takeSent()
|
const sent = await hub.getByName('global').takeSent()
|
||||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||||
expect(
|
// NotificationType.ChatMessageReceived
|
||||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
||||||
).toBe(true)
|
|
||||||
expect(sent[0]!.data).toEqual({
|
expect(sent[0]!.data).toEqual({
|
||||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||||
chatThreadId: chatThread.chatThreadId,
|
chatThreadId: chatThread.chatThreadId,
|
||||||
@@ -984,9 +982,7 @@ describe('POST /thread/:id', () => {
|
|||||||
it('pushes ChatMessageReceived to every member', async () => {
|
it('pushes ChatMessageReceived to every member', async () => {
|
||||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||||
getByName(name: string): {
|
getByName(name: string): {
|
||||||
takeSent(): Promise<
|
takeSent(): Promise<Array<{ playerId: number; notificationType: number }>>
|
||||||
Array<{ playerId: number; notificationType: NotificationType }>
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const caller = 889005
|
const caller = 889005
|
||||||
@@ -996,9 +992,7 @@ describe('POST /thread/:id', () => {
|
|||||||
await send(caller, `/thread/${chatThreadId}`)
|
await send(caller, `/thread/${chatThreadId}`)
|
||||||
const sent = await hub.getByName('global').takeSent()
|
const sent = await hub.getByName('global').takeSent()
|
||||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
||||||
expect(
|
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
||||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
|
||||||
).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports invalid arguments for blank contents without storing anything', async () => {
|
it('reports invalid arguments for blank contents without storing anything', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,965 @@
|
|||||||
|
/**
|
||||||
|
* Club storage on the shared `recflare` D1 database. A club is a single JSON blob
|
||||||
|
* in the `data` column (the client-facing Club DTO); queryable fields (ClubId,
|
||||||
|
* Name, Category, Visibility, State, CreatorAccountId) are SQLite generated
|
||||||
|
* (virtual) columns extracted from that JSON and indexed — the same JSON-blob
|
||||||
|
* pattern the rooms/accounts tables use. Mirrors the Go/GORM `Club` model.
|
||||||
|
*
|
||||||
|
* Membership lives in a separate `club_member` table (one row per club/account);
|
||||||
|
* the club's `MemberCount` is a denormalized field kept in sync from those rows.
|
||||||
|
*
|
||||||
|
* The `clubs` worker owns this schema/migration (migrations/0001_club.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 tables directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSavedImagesByNames, placeholderSavedImage } from '@repo/domain'
|
||||||
|
|
||||||
|
import type { SavedImage } from '@repo/domain'
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS club (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||||
|
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||||
|
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
||||||
|
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
||||||
|
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
||||||
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_category ON club (category)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id)`,
|
||||||
|
// Club membership — one row per (club, account); `membership_type` (see
|
||||||
|
// ClubMembershipType) encodes bans, pending requests/invites, and roles in a
|
||||||
|
// single field. Surrogate PK mirrors the Go model; the UNIQUE (club_id,
|
||||||
|
// account_id) index enforces one membership per pair (and backs the upsert). The
|
||||||
|
// club's MemberCount is kept in sync from the rows that count as real members.
|
||||||
|
`CREATE TABLE IF NOT EXISTS club_member (
|
||||||
|
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
membership_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`,
|
||||||
|
// Club announcements — the club's noticeboard, newest first. Columns rather than a
|
||||||
|
// JSON blob (mirroring the Go model), since nothing here is client-shaped beyond
|
||||||
|
// the fields themselves.
|
||||||
|
`CREATE TABLE IF NOT EXISTS club_announcement (
|
||||||
|
announcement_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
image_name TEXT NOT NULL DEFAULT '',
|
||||||
|
meta TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's membership state in a club (mirror of the Go `ClubMembershipType`).
|
||||||
|
* The single field spans bans, the pending request/invite states, and the member
|
||||||
|
* role tiers; `Member` (10) is the threshold at/above which someone is an actual
|
||||||
|
* member (below it is pending/none/banned).
|
||||||
|
*/
|
||||||
|
export enum ClubMembershipType {
|
||||||
|
Banned = -1,
|
||||||
|
None = 0,
|
||||||
|
PendingRequested = 1,
|
||||||
|
PendingInvited = 2,
|
||||||
|
PendingDenied = 3,
|
||||||
|
Member = 10,
|
||||||
|
Moderator = 20,
|
||||||
|
Coowner = 30,
|
||||||
|
Creator = 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's visibility (mirror of the Go `ClubVisibility`). */
|
||||||
|
export enum ClubVisibility {
|
||||||
|
Private = 0,
|
||||||
|
Public = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a player may join a club (mirror of the Go `ClubJoinability`). */
|
||||||
|
export enum ClubJoinability {
|
||||||
|
Open = 0,
|
||||||
|
InviteOnly = 1,
|
||||||
|
AskToJoin = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membership types at/above which a row counts as an actual member (not pending/banned). */
|
||||||
|
const MEMBER_THRESHOLD = ClubMembershipType.Member
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-facing club shape (PascalCase, mirror of the Go `Club` JSON tags). The
|
||||||
|
* Go model's `CreatedAt` is `json:"-"` — stored but never serialized — so it lives
|
||||||
|
* in the blob (see StoredClub) but is dropped from this DTO.
|
||||||
|
*/
|
||||||
|
export interface Club {
|
||||||
|
ClubId: number
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
Category: string
|
||||||
|
Visibility: number
|
||||||
|
Joinability: number
|
||||||
|
AllowJuniors: boolean
|
||||||
|
MainImageName: string
|
||||||
|
ClubType: number
|
||||||
|
ClubhouseRoomId: number | null
|
||||||
|
CreatorAccountId: number
|
||||||
|
IsRRO: boolean
|
||||||
|
MinLevel: number
|
||||||
|
State: number
|
||||||
|
MemberCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored club — the DTO plus fields the client never sees on the Club object
|
||||||
|
* itself: `CreatedAt` (`json:"-"` in Go) and the club's custom tags, which the Go
|
||||||
|
* server keeps in a `club_custom_tags` table but which we keep on the blob, since
|
||||||
|
* they're only ever read and written with the club.
|
||||||
|
*/
|
||||||
|
interface StoredClub extends Club {
|
||||||
|
CreatedAt: string
|
||||||
|
CustomTags?: string[]
|
||||||
|
/**
|
||||||
|
* The club's gallery image names, in order (the client PUTs to
|
||||||
|
* `/additionalimage/{index}`). Packed, never sparse: removing one shifts the rest
|
||||||
|
* up, so the list is always the images the club actually has.
|
||||||
|
*/
|
||||||
|
AdditionalImages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many gallery images a club has room for (slots 0..2). */
|
||||||
|
export const MAX_ADDITIONAL_IMAGES = 3
|
||||||
|
|
||||||
|
interface ClubRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored club to the client DTO (drops the non-serialized CreatedAt). */
|
||||||
|
function toDto(s: StoredClub): Club {
|
||||||
|
return {
|
||||||
|
ClubId: s.ClubId,
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
Category: s.Category,
|
||||||
|
Visibility: s.Visibility,
|
||||||
|
Joinability: s.Joinability,
|
||||||
|
AllowJuniors: s.AllowJuniors,
|
||||||
|
MainImageName: s.MainImageName,
|
||||||
|
ClubType: s.ClubType,
|
||||||
|
ClubhouseRoomId: s.ClubhouseRoomId,
|
||||||
|
CreatorAccountId: s.CreatorAccountId,
|
||||||
|
IsRRO: s.IsRRO,
|
||||||
|
MinLevel: s.MinLevel,
|
||||||
|
State: s.State,
|
||||||
|
MemberCount: s.MemberCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseOne = (row: ClubRow | null): Club | null =>
|
||||||
|
row ? toDto(JSON.parse(row.data) as StoredClub) : null
|
||||||
|
const parseAll = (rows: ClubRow[]): Club[] =>
|
||||||
|
rows.map((r) => toDto(JSON.parse(r.data) as StoredClub))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute a club's `MemberCount` from the `club_member` rows and write it back
|
||||||
|
* into the blob (the generated column follows). Returns the fresh count. Keeping
|
||||||
|
* the count derived avoids drift from concurrent joins/leaves.
|
||||||
|
*/
|
||||||
|
async function syncMemberCount(db: D1Database, clubId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM club_member WHERE club_id = ?1 AND membership_type >= ?2')
|
||||||
|
.bind(clubId, MEMBER_THRESHOLD)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const count = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, which json_set would write
|
||||||
|
// into the blob as `"MemberCount":3.0` — and this blob is served to the client.
|
||||||
|
.prepare(
|
||||||
|
"UPDATE club SET data = json_set(data, '$.MemberCount', CAST(?2 AS INTEGER)) WHERE club_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(clubId, count)
|
||||||
|
.run()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a player's membership type in a club (None when there's no row). */
|
||||||
|
export async function getMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<ClubMembershipType> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
||||||
|
.bind(clubId, accountId)
|
||||||
|
.first<{ t: number }>()
|
||||||
|
return (row?.t ?? ClubMembershipType.None) as ClubMembershipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a player's membership type for a club (one row per pair). `created_at` is
|
||||||
|
* stamped on first insert and preserved on later type changes.
|
||||||
|
*/
|
||||||
|
async function setMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
type: ClubMembershipType
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO club_member (club_id, account_id, membership_type, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(club_id, account_id) DO UPDATE SET membership_type = ?3`
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, type, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields a caller may supply when creating a club; everything else takes the Go defaults. */
|
||||||
|
export interface NewClub {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
visibility?: number
|
||||||
|
joinability?: number
|
||||||
|
allowJuniors?: boolean
|
||||||
|
mainImageName?: string
|
||||||
|
clubType?: number
|
||||||
|
clubhouseRoomId?: number | null
|
||||||
|
isRRO?: boolean
|
||||||
|
minLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a club owned by `creatorAccountId`. The id is the next free integer (the
|
||||||
|
* Go model uses `autoIncrement:false`, i.e. an app-assigned id). Unset fields fall
|
||||||
|
* back to the Go model's column defaults. The creator is added as the club's first
|
||||||
|
* member (Owner), so the returned club has MemberCount 1.
|
||||||
|
*/
|
||||||
|
export async function createClub(
|
||||||
|
db: D1Database,
|
||||||
|
creatorAccountId: number,
|
||||||
|
input: NewClub
|
||||||
|
): Promise<Club> {
|
||||||
|
const idRow = await db
|
||||||
|
.prepare('SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const clubId = idRow?.next ?? 1
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const stored: StoredClub = {
|
||||||
|
ClubId: clubId,
|
||||||
|
Name: input.name,
|
||||||
|
Description: input.description ?? '',
|
||||||
|
Category: input.category ?? '',
|
||||||
|
Visibility: input.visibility ?? ClubVisibility.Public,
|
||||||
|
Joinability: input.joinability ?? ClubJoinability.Open,
|
||||||
|
AllowJuniors: input.allowJuniors ?? true,
|
||||||
|
MainImageName: input.mainImageName ?? 'DefaultImgPurple',
|
||||||
|
ClubType: input.clubType ?? 0,
|
||||||
|
ClubhouseRoomId: input.clubhouseRoomId ?? null,
|
||||||
|
CreatorAccountId: creatorAccountId,
|
||||||
|
IsRRO: input.isRRO ?? false,
|
||||||
|
MinLevel: input.minLevel ?? 0,
|
||||||
|
State: 0,
|
||||||
|
MemberCount: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
||||||
|
|
||||||
|
// The creator is the club's first member, joining as its Creator.
|
||||||
|
await setMembership(db, clubId, creatorAccountId, ClubMembershipType.Creator)
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...toDto(stored), MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each membership tier is allowed to do in a club. These are the defaults every
|
||||||
|
* new club gets (co-owners can do everything, moderators can approve/ban, plain
|
||||||
|
* members can do none of it); nothing edits them yet, so they're derived per club
|
||||||
|
* rather than stored.
|
||||||
|
*/
|
||||||
|
export interface ClubPermission {
|
||||||
|
ClubId: number
|
||||||
|
Type: number
|
||||||
|
ApproveMember: boolean
|
||||||
|
BanUnban: boolean
|
||||||
|
CreateEvent: boolean
|
||||||
|
EditDetails: boolean
|
||||||
|
EditPermissionSettings: boolean
|
||||||
|
PostAnnouncement: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function clubPermission(
|
||||||
|
clubId: number,
|
||||||
|
type: ClubMembershipType,
|
||||||
|
granted: Partial<Omit<ClubPermission, 'ClubId' | 'Type'>> = {}
|
||||||
|
): ClubPermission {
|
||||||
|
return {
|
||||||
|
ClubId: clubId,
|
||||||
|
Type: type,
|
||||||
|
ApproveMember: false,
|
||||||
|
BanUnban: false,
|
||||||
|
CreateEvent: false,
|
||||||
|
EditDetails: false,
|
||||||
|
EditPermissionSettings: false,
|
||||||
|
PostAnnouncement: false,
|
||||||
|
...granted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The club-details payload the client reads from create/details. */
|
||||||
|
export interface ClubDetails {
|
||||||
|
/**
|
||||||
|
* The club's gallery images as whole image records — the same `SavedImage` shape
|
||||||
|
* every other image on the site is served as. The client deserializes these into
|
||||||
|
* objects, so a bare array of names fails its parser ("expected '{'").
|
||||||
|
*/
|
||||||
|
AdditionalImages: SavedImage[]
|
||||||
|
Club: Club
|
||||||
|
ClubId: number
|
||||||
|
CoownerPermissions: ClubPermission
|
||||||
|
CustomTags: string[]
|
||||||
|
MemberPermissions: ClubPermission
|
||||||
|
ModeratorPermissions: ClubPermission
|
||||||
|
MyMembershipType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the club-details view for a caller. `MyMembershipType` is the caller's own
|
||||||
|
* membership (0 = none, e.g. a signed-out viewer). Additional images (set via
|
||||||
|
* `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off
|
||||||
|
* the club's blob.
|
||||||
|
*/
|
||||||
|
export async function getClubDetails(
|
||||||
|
db: D1Database,
|
||||||
|
club: Club,
|
||||||
|
accountId: number | null
|
||||||
|
): Promise<ClubDetails> {
|
||||||
|
return {
|
||||||
|
AdditionalImages: await getClubGallery(db, club.ClubId),
|
||||||
|
Club: club,
|
||||||
|
ClubId: club.ClubId,
|
||||||
|
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
|
||||||
|
ApproveMember: true,
|
||||||
|
BanUnban: true,
|
||||||
|
CreateEvent: true,
|
||||||
|
EditDetails: true,
|
||||||
|
EditPermissionSettings: true,
|
||||||
|
PostAnnouncement: true,
|
||||||
|
}),
|
||||||
|
CustomTags: await getClubCustomTags(db, club.ClubId),
|
||||||
|
MemberPermissions: clubPermission(club.ClubId, ClubMembershipType.Member),
|
||||||
|
ModeratorPermissions: clubPermission(club.ClubId, ClubMembershipType.Moderator, {
|
||||||
|
ApproveMember: true,
|
||||||
|
BanUnban: true,
|
||||||
|
}),
|
||||||
|
MyMembershipType: accountId === null ? 0 : await getMembership(db, club.ClubId, accountId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club announcement (mirror of the Go `ClubAnnouncement`). */
|
||||||
|
export interface ClubAnnouncement {
|
||||||
|
AnnouncementId: number
|
||||||
|
ClubId: number
|
||||||
|
AccountId: number
|
||||||
|
Title: string
|
||||||
|
Body: string
|
||||||
|
ImageName: string
|
||||||
|
Meta: string
|
||||||
|
CreatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's announcements, newest first. An unknown club simply has none. */
|
||||||
|
export async function getClubAnnouncements(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number
|
||||||
|
): Promise<ClubAnnouncement[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT announcement_id, club_id, account_id, title, body, image_name, meta, created_at
|
||||||
|
FROM club_announcement
|
||||||
|
WHERE club_id = ?1
|
||||||
|
ORDER BY created_at DESC, announcement_id DESC`
|
||||||
|
)
|
||||||
|
.bind(clubId)
|
||||||
|
.all<{
|
||||||
|
announcement_id: number
|
||||||
|
club_id: number
|
||||||
|
account_id: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
image_name: string
|
||||||
|
meta: string
|
||||||
|
created_at: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
AnnouncementId: r.announcement_id,
|
||||||
|
ClubId: r.club_id,
|
||||||
|
AccountId: r.account_id,
|
||||||
|
Title: r.title,
|
||||||
|
Body: r.body,
|
||||||
|
ImageName: r.image_name,
|
||||||
|
Meta: r.meta,
|
||||||
|
CreatedAt: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post an announcement to a club, returning its new id. */
|
||||||
|
export async function createClubAnnouncement(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
fields: { title?: string; body?: string; imageName?: string; meta?: string }
|
||||||
|
): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO club_announcement (club_id, account_id, title, body, image_name, meta, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
RETURNING announcement_id`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
clubId,
|
||||||
|
accountId,
|
||||||
|
fields.title ?? '',
|
||||||
|
fields.body ?? '',
|
||||||
|
fields.imageName ?? '',
|
||||||
|
fields.meta ?? '',
|
||||||
|
new Date().toISOString()
|
||||||
|
)
|
||||||
|
.first<{ announcement_id: number }>()
|
||||||
|
return row?.announcement_id ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What club search answers: the page of clubs plus the total that matched. */
|
||||||
|
export interface ClubSearchResult {
|
||||||
|
Clubs: Club[]
|
||||||
|
ContinuationToken: null
|
||||||
|
TotalClubs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club search (`/club/search`). Public, non-subscription clubs only. `category` is an
|
||||||
|
* exact (case-insensitive) match, `query` a substring of the name or description.
|
||||||
|
* `sort`: 1 = newest first, 2 = by name, anything else (including the client's 0) =
|
||||||
|
* biggest first, then newest. `count` caps the page — out-of-range values fall back to
|
||||||
|
* 30, as the reference does. `TotalClubs` is the full match count, not the page size.
|
||||||
|
*/
|
||||||
|
export async function searchClubs(
|
||||||
|
db: D1Database,
|
||||||
|
category: string,
|
||||||
|
query: string,
|
||||||
|
sort: string | undefined,
|
||||||
|
count: number
|
||||||
|
): Promise<ClubSearchResult> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM club
|
||||||
|
WHERE visibility = ?1
|
||||||
|
AND json_extract(data, '$.ClubType') != ?2`
|
||||||
|
)
|
||||||
|
.bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
|
||||||
|
const stored = results.map((r) => JSON.parse(r.data) as StoredClub)
|
||||||
|
const term = query.trim().toLowerCase()
|
||||||
|
const wanted = category.trim().toLowerCase()
|
||||||
|
|
||||||
|
const matched = stored.filter((club) => {
|
||||||
|
if (wanted !== '' && club.Category.toLowerCase() !== wanted) return false
|
||||||
|
if (term === '') return true
|
||||||
|
return club.Name.toLowerCase().includes(term) || club.Description.toLowerCase().includes(term)
|
||||||
|
})
|
||||||
|
|
||||||
|
const byNewest = (a: StoredClub, b: StoredClub) => b.CreatedAt.localeCompare(a.CreatedAt)
|
||||||
|
matched.sort((a, b) => {
|
||||||
|
if (sort === '1') return byNewest(a, b)
|
||||||
|
if (sort === '2') return a.Name.localeCompare(b.Name)
|
||||||
|
return b.MemberCount - a.MemberCount || byNewest(a, b)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
Clubs: matched.slice(0, count).map(toDto),
|
||||||
|
ContinuationToken: null,
|
||||||
|
TotalClubs: matched.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's "home club" — the one whose clubhouse they spawn into. It's a field
|
||||||
|
* on the *account* row (owned by the `auth` worker, on the same shared database, the
|
||||||
|
* way the `api` worker writes the account's profile image), not on the club: one
|
||||||
|
* home club per player.
|
||||||
|
*
|
||||||
|
* Returns null when they haven't set one, when the club is gone, or when it has no
|
||||||
|
* clubhouse room — a home club with nowhere to go isn't usable, and the reference
|
||||||
|
* 404s all three cases identically.
|
||||||
|
*/
|
||||||
|
export async function getHomeClub(db: D1Database, accountId: number): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT json_extract(data, '$.homeClubId') AS clubId FROM account WHERE account_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.first<{ clubId: number | null }>()
|
||||||
|
if (row?.clubId == null) return null
|
||||||
|
|
||||||
|
const club = await getClub(db, row.clubId)
|
||||||
|
// `== null` catches a club row that predates the field (undefined), not just an
|
||||||
|
// explicit null — either way it has no clubhouse to spawn into.
|
||||||
|
if (club === null || club.ClubhouseRoomId == null) return null
|
||||||
|
return club
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the player's home club at `clubId` (stored on their account row). */
|
||||||
|
export async function setHomeClub(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
clubId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
// CAST to INTEGER — see syncMemberCount: a bound JS number lands as a REAL, so this
|
||||||
|
// would otherwise store `"homeClubId":7.0`.
|
||||||
|
.prepare(
|
||||||
|
"UPDATE account SET data = json_set(data, '$.homeClubId', CAST(?2 AS INTEGER)) WHERE account_id = ?1"
|
||||||
|
)
|
||||||
|
.bind(accountId, clubId)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the player's home club (the field is removed from their account row, not set
|
||||||
|
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
|
||||||
|
*/
|
||||||
|
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
|
||||||
|
.bind(accountId)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||||
|
export interface ClubMember {
|
||||||
|
ClubMemberId: number
|
||||||
|
ClubId: number
|
||||||
|
AccountId: number
|
||||||
|
MembershipType: number
|
||||||
|
CreatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club's members (`/club/:id/members`). `membershipType` filters to exactly that
|
||||||
|
* tier when given — note it's an exact match, not a threshold, so `30` lists only
|
||||||
|
* co-owners (not the creator above them). `sortBy` picks the order: 1 = by account
|
||||||
|
* id, 2 = oldest membership first, anything else = the default, highest tier first
|
||||||
|
* then oldest. An unknown club has no members, so it's an empty list, not a 404.
|
||||||
|
*/
|
||||||
|
export async function getClubMembers(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
membershipType: number | undefined,
|
||||||
|
sortBy: string | undefined
|
||||||
|
): Promise<ClubMember[]> {
|
||||||
|
const order =
|
||||||
|
sortBy === '1'
|
||||||
|
? 'account_id ASC'
|
||||||
|
: sortBy === '2'
|
||||||
|
? 'created_at ASC'
|
||||||
|
: 'membership_type DESC, created_at ASC'
|
||||||
|
const filter = membershipType === undefined ? '' : 'AND membership_type = ?2'
|
||||||
|
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT club_member_id, club_id, account_id, membership_type, created_at
|
||||||
|
FROM club_member
|
||||||
|
WHERE club_id = ?1 ${filter}
|
||||||
|
ORDER BY ${order}`
|
||||||
|
)
|
||||||
|
.bind(...(membershipType === undefined ? [clubId] : [clubId, membershipType]))
|
||||||
|
.all<{
|
||||||
|
club_member_id: number
|
||||||
|
club_id: number
|
||||||
|
account_id: number
|
||||||
|
membership_type: number
|
||||||
|
created_at: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
ClubMemberId: r.club_member_id,
|
||||||
|
ClubId: r.club_id,
|
||||||
|
AccountId: r.account_id,
|
||||||
|
MembershipType: r.membership_type,
|
||||||
|
CreatedAt: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields `modifydetails` can change. Anything left undefined keeps its stored value. */
|
||||||
|
export interface ClubPatch {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
visibility?: number
|
||||||
|
joinability?: number
|
||||||
|
allowJuniors?: boolean
|
||||||
|
mainImageName?: string
|
||||||
|
minLevel?: number
|
||||||
|
/** Replaces the club's tags wholesale when present; absent leaves them alone. */
|
||||||
|
customTags?: string[]
|
||||||
|
/** The club's clubhouse room; `null` clears it (undefined leaves it alone). */
|
||||||
|
clubhouseRoomId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an edit to a club's details (`modifydetails`). Only the keys present on the
|
||||||
|
* patch change. Custom tags are replaced as a set — trimmed, de-duplicated
|
||||||
|
* case-insensitively, first spelling wins. Returns the updated club, or null when
|
||||||
|
* there's no such club.
|
||||||
|
*/
|
||||||
|
export async function updateClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
patch: ClubPatch
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
if (row === null) return null
|
||||||
|
const stored = JSON.parse(row.data) as StoredClub
|
||||||
|
|
||||||
|
const updated: StoredClub = {
|
||||||
|
...stored,
|
||||||
|
Name: patch.name ?? stored.Name,
|
||||||
|
Description: patch.description ?? stored.Description,
|
||||||
|
Category: patch.category ?? stored.Category,
|
||||||
|
Visibility: patch.visibility ?? stored.Visibility,
|
||||||
|
Joinability: patch.joinability ?? stored.Joinability,
|
||||||
|
AllowJuniors: patch.allowJuniors ?? stored.AllowJuniors,
|
||||||
|
MainImageName: patch.mainImageName ?? stored.MainImageName,
|
||||||
|
MinLevel: patch.minLevel ?? stored.MinLevel,
|
||||||
|
CustomTags: patch.customTags === undefined ? stored.CustomTags : dedupeTags(patch.customTags),
|
||||||
|
// `null` clears the clubhouse, so this can't collapse to `??`.
|
||||||
|
ClubhouseRoomId:
|
||||||
|
patch.clubhouseRoomId === undefined ? stored.ClubhouseRoomId : patch.clubhouseRoomId,
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||||
|
.bind(JSON.stringify(updated), clubId)
|
||||||
|
.run()
|
||||||
|
return toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim, drop blanks, and de-duplicate tags case-insensitively (first spelling wins). */
|
||||||
|
function dedupeTags(tags: string[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const raw of tags) {
|
||||||
|
const tag = raw.trim()
|
||||||
|
if (tag === '' || seen.has(tag.toLowerCase())) continue
|
||||||
|
seen.add(tag.toLowerCase())
|
||||||
|
out.push(tag)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's gallery image names, in order (stored on the blob; `[]` when it has none). */
|
||||||
|
export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise<string[]> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club's gallery as the client reads it: the image record behind each name, in
|
||||||
|
* order. A name whose metadata row is missing falls back to a placeholder record so
|
||||||
|
* the picture still renders.
|
||||||
|
*/
|
||||||
|
export async function getClubGallery(db: D1Database, clubId: number): Promise<SavedImage[]> {
|
||||||
|
const names = await getClubAdditionalImages(db, clubId)
|
||||||
|
if (names.length === 0) return []
|
||||||
|
const records = await getSavedImagesByNames(db, names)
|
||||||
|
return names.map((name) => records.get(name) ?? placeholderSavedImage(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or remove, with an empty `imageName`) one of a club's gallery images. The list
|
||||||
|
* stays packed: removing an image shifts the ones after it up, and setting an index
|
||||||
|
* past the end appends rather than leaving a gap. Returns null when the club doesn't
|
||||||
|
* exist; the caller validates the index is in range.
|
||||||
|
*/
|
||||||
|
export async function setClubAdditionalImage(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
index: number,
|
||||||
|
imageName: string
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
if (row === null) return null
|
||||||
|
const stored = JSON.parse(row.data) as StoredClub
|
||||||
|
|
||||||
|
const images = [...(stored.AdditionalImages ?? [])]
|
||||||
|
if (imageName === '') {
|
||||||
|
// Removing past the end is a no-op, not an error: the image is already gone.
|
||||||
|
if (index < images.length) images.splice(index, 1)
|
||||||
|
} else if (index < images.length) {
|
||||||
|
images[index] = imageName
|
||||||
|
} else if (images.length < MAX_ADDITIONAL_IMAGES) {
|
||||||
|
images.push(imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: StoredClub = { ...stored, AdditionalImages: images }
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||||
|
.bind(JSON.stringify(updated), clubId)
|
||||||
|
.run()
|
||||||
|
return toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's custom tags (stored on the blob; empty when it has none). */
|
||||||
|
export async function getClubCustomTags(db: D1Database, clubId: number): Promise<string[]> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||||
|
.bind(clubId)
|
||||||
|
.first<ClubRow>()
|
||||||
|
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).CustomTags ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a single club by its ClubId. */
|
||||||
|
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
||||||
|
return parseOne(
|
||||||
|
await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first<ClubRow>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a club and everything hanging off it — its memberships and announcements —
|
||||||
|
* and clear it from the home club of anyone who'd set it. Returns false when there
|
||||||
|
* was no such club. Batched so a half-deleted club can't be left behind.
|
||||||
|
*/
|
||||||
|
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
|
||||||
|
if ((await getClub(db, clubId)) === null) return false
|
||||||
|
await db.batch([
|
||||||
|
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
|
||||||
|
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
|
||||||
|
// The account table belongs to the auth worker; a dangling homeClubId already
|
||||||
|
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
|
||||||
|
// club later reuses the id.
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE account SET data = json_remove(data, '$.homeClubId')
|
||||||
|
WHERE json_extract(data, '$.homeClubId') = ?1`
|
||||||
|
)
|
||||||
|
.bind(clubId),
|
||||||
|
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
|
||||||
|
* club you browse or list among your own — they're excluded from the "my clubs"
|
||||||
|
* lists (the client reaches them through the `/subscription/*` endpoints instead).
|
||||||
|
*/
|
||||||
|
const SUBSCRIPTION_CLUB_TYPE = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many clubs an account has made, for the per-account club cap. Subscription
|
||||||
|
* clubs don't count — they're provisioned for a creator's subscribers rather than
|
||||||
|
* made by hand, so they shouldn't eat a slot.
|
||||||
|
*/
|
||||||
|
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM club
|
||||||
|
WHERE creator_account_id = ?1
|
||||||
|
AND json_extract(data, '$.ClubType') != ?2`
|
||||||
|
)
|
||||||
|
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
return row?.n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
||||||
|
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM club
|
||||||
|
WHERE creator_account_id = ?1
|
||||||
|
AND json_extract(data, '$.ClubType') != ?2
|
||||||
|
ORDER BY json_extract(data, '$.CreatedAt') ASC`
|
||||||
|
)
|
||||||
|
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All clubs an account is an actual member of (GetMyMembershipClubs), oldest club
|
||||||
|
* first. Only memberships at/above `Member` count — pending requests, denied
|
||||||
|
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
||||||
|
* whose club is gone is simply absent.
|
||||||
|
*/
|
||||||
|
export async function getClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.data AS data
|
||||||
|
FROM club_member m
|
||||||
|
JOIN club c ON c.club_id = m.club_id
|
||||||
|
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
||||||
|
AND json_extract(c.data, '$.ClubType') != ?3
|
||||||
|
ORDER BY json_extract(c.data, '$.CreatedAt') ASC`
|
||||||
|
)
|
||||||
|
.bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an account is an actual member of a club (Member tier or above). */
|
||||||
|
export async function isClubMember(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await getMembership(db, clubId, accountId)) >= MEMBER_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have `accountId` join a club. On an Open club they become a `Member` immediately;
|
||||||
|
* on an InviteOnly/AskToJoin club the join is recorded as `PendingRequested` (an
|
||||||
|
* approval flow, not yet a member). Idempotent for anyone already a member, and a
|
||||||
|
* no-op for a banned account. Returns the club with its refreshed MemberCount, or
|
||||||
|
* null when the club doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function joinClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
// A ban can't be shed by re-joining, and an existing member/pending stays as-is.
|
||||||
|
if (current === ClubMembershipType.Banned || current >= MEMBER_THRESHOLD) {
|
||||||
|
return club
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
club.Joinability === ClubJoinability.Open
|
||||||
|
? ClubMembershipType.Member
|
||||||
|
: ClubMembershipType.PendingRequested
|
||||||
|
await setMembership(db, clubId, accountId, next)
|
||||||
|
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a request to join resolved. `joined` is an Open club (no approval needed),
|
||||||
|
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
|
||||||
|
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
|
||||||
|
* refusals — the caller can't get in this way.
|
||||||
|
*/
|
||||||
|
export type JoinRequestResult =
|
||||||
|
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
|
||||||
|
* an InviteOnly club can only be entered through an invite, so a request is refused
|
||||||
|
* rather than parked as pending. Returns the outcome plus the club with its refreshed
|
||||||
|
* MemberCount, or null when the club doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function requestToJoinClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<{ result: JoinRequestResult; club: Club } | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
// A ban can't be shed by asking again, and existing members/requests stay as-is.
|
||||||
|
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
|
||||||
|
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
|
||||||
|
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
|
||||||
|
|
||||||
|
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
|
||||||
|
|
||||||
|
const open = club.Joinability === ClubJoinability.Open
|
||||||
|
await setMembership(
|
||||||
|
db,
|
||||||
|
clubId,
|
||||||
|
accountId,
|
||||||
|
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
|
||||||
|
)
|
||||||
|
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
||||||
|
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
||||||
|
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
|
||||||
|
* exist. The club itself is left in place even when the last member leaves.
|
||||||
|
*
|
||||||
|
* The creator can't leave: a club with no owner has no one who can administer it, and
|
||||||
|
* there's no ownership transfer, so they have to delete the club instead. `creator`
|
||||||
|
* reports that refusal, with the club unchanged.
|
||||||
|
*/
|
||||||
|
export async function leaveClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, ClubMembershipType.Banned)
|
||||||
|
.run()
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { result: 'left', club: { ...club, MemberCount: count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an account's membership tier in a club — the invite / role-assignment write
|
||||||
|
* behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to
|
||||||
|
* `membershipType` (adding the account when it wasn't a member, and overriding a prior
|
||||||
|
* tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh
|
||||||
|
* count, or null when the club is gone. The caller is responsible for checking that the
|
||||||
|
* tier is one it may grant and that the target isn't the club's Creator.
|
||||||
|
*/
|
||||||
|
export async function setMemberType(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
membershipType: ClubMembershipType
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
await setMembership(db, clubId, accountId, membershipType)
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearHomeClub,
|
clearHomeClub,
|
||||||
ClubJoinability,
|
ClubJoinability,
|
||||||
@@ -19,22 +22,16 @@ import {
|
|||||||
getClubsByMember,
|
getClubsByMember,
|
||||||
getHomeClub,
|
getHomeClub,
|
||||||
getMembership,
|
getMembership,
|
||||||
glyphLength,
|
|
||||||
joinClub,
|
joinClub,
|
||||||
leaveClub,
|
leaveClub,
|
||||||
MAX_ADDITIONAL_IMAGES,
|
MAX_ADDITIONAL_IMAGES,
|
||||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
|
||||||
MAX_CLUB_NAME_LENGTH,
|
|
||||||
requestToJoinClub,
|
requestToJoinClub,
|
||||||
searchClubs,
|
searchClubs,
|
||||||
setClubAdditionalImage,
|
setClubAdditionalImage,
|
||||||
setHomeClub,
|
setHomeClub,
|
||||||
setMemberType,
|
setMemberType,
|
||||||
updateClub,
|
updateClub,
|
||||||
} from '@repo/domain'
|
} from './clubs-db'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnnouncementIdEnvelope,
|
AnnouncementIdEnvelope,
|
||||||
AnnouncementRequest,
|
AnnouncementRequest,
|
||||||
@@ -104,6 +101,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||||
|
|
||||||
|
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||||
|
const MAX_CLUB_NAME_LENGTH = 16
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||||
@@ -666,14 +665,6 @@ const app = new Hono<App>()
|
|||||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
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
|
// The per-account cap, checked after the cheap validations so a rejected name
|
||||||
// costs no extra D1 read.
|
// costs no extra D1 read.
|
||||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||||
@@ -787,19 +778,9 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, {
|
const updated = await updateClub(c.env.DB, clubId, {
|
||||||
name,
|
name,
|
||||||
description,
|
description: field('description') || undefined,
|
||||||
category: field('category')?.trim() || undefined,
|
category: field('category')?.trim() || undefined,
|
||||||
visibility: parseVisibility(field('visibility')),
|
visibility: parseVisibility(field('visibility')),
|
||||||
joinability: parseJoinability(field('joinability')),
|
joinability: parseJoinability(field('joinability')),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
|
|||||||
*/
|
*/
|
||||||
export const ClubDto = z.object({
|
export const ClubDto = z.object({
|
||||||
ClubId: z.int(),
|
ClubId: z.int(),
|
||||||
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
|
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
|
||||||
Description: z.string(),
|
Description: z.string(),
|
||||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||||
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
|
|||||||
export const CreateClubRequest = z.object({
|
export const CreateClubRequest = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
|
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
|
||||||
description: z.string().optional().describe('At most 512 characters'),
|
description: z.string().optional(),
|
||||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||||
joinability: z
|
joinability: z
|
||||||
@@ -292,11 +292,8 @@ export const CreateClubRequest = z.object({
|
|||||||
|
|
||||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||||
export const ModifyClubRequest = z.object({
|
export const ModifyClubRequest = z.object({
|
||||||
name: z
|
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
|
||||||
.string()
|
description: z.string().optional().describe('Empty means unchanged'),
|
||||||
.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(),
|
category: z.string().optional(),
|
||||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||||
joinability: z
|
joinability: z
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../clubs.app'
|
import '../../clubs.app'
|
||||||
|
|
||||||
import { CLUB_SCHEMA_DDL } from '@repo/domain'
|
import { SCHEMA_DDL } from '../../clubs-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
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.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
// Build the club / club_member tables (mirrors the migration).
|
// Build the club / club_member tables (mirrors the migration).
|
||||||
for (const stmt of CLUB_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Accounts table (owned by the auth worker) — a player's home club is a field on
|
// 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.
|
// their account row, so /club/home/me reads and writes it here.
|
||||||
@@ -241,16 +241,9 @@ describe('clubs endpoints', () => {
|
|||||||
expect(emoji.status).toBe(400)
|
expect(emoji.status).toBe(400)
|
||||||
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
||||||
|
|
||||||
// Names cap at 40 characters.
|
// Names cap at 16 characters.
|
||||||
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
|
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
|
||||||
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
|
expect((await create({ name: 'a'.repeat(16) })).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.
|
// Basic punctuation is allowed.
|
||||||
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
||||||
|
|||||||
@@ -6,20 +6,12 @@ A Cloudflare Workers application using Hono
|
|||||||
|
|
||||||
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
|
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
|
||||||
`false`.
|
`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
|
- `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
|
offers), served from the bundled `static/catalog-v1-all.json`. The client's
|
||||||
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
|
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
|
||||||
contains only available SKUs.
|
contains only available SKUs.
|
||||||
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
|
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
|
||||||
(limited-time offers/promos); `[]` (none active).
|
(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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
|
||||||
"hono": "4.12.27",
|
"hono": "4.12.27",
|
||||||
"hono-openapi": "1.3.1",
|
"workers-tagged-logger": "1.0.1"
|
||||||
"openapi-types": "12.1.3",
|
|
||||||
"workers-tagged-logger": "1.0.1",
|
|
||||||
"zod": "4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import catalog from '../static/catalog-v1-all.json'
|
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'
|
import type { App } from './context'
|
||||||
|
|
||||||
@@ -23,14 +11,6 @@ import type { App } from './context'
|
|||||||
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
|
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
|
||||||
* method routes are served bare.
|
* 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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -45,131 +25,24 @@ const app = new Hono<App>()
|
|||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
.get(
|
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
|
||||||
'/',
|
|
||||||
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
|
// Whether the player has ever spent money. A 404 here makes the client treat
|
||||||
// it as an error, so we return `false` (no purchases).
|
// it as an error, so we return `false` (no purchases).
|
||||||
.get(
|
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
|
||||||
'/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
|
// The purchasable SKU catalog (token packs, special offers), served from the
|
||||||
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
|
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
|
||||||
// catalog is already only the available SKUs, so the param doesn't change the
|
// catalog is already only the available SKUs, so the param doesn't change the
|
||||||
// response.
|
// response.
|
||||||
.get(
|
.get('/api/catalog/v1/all', (c) => c.json(catalog))
|
||||||
'/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
|
// Current purchase campaigns (limited-time offers/promos). None exist, and
|
||||||
// an empty list is the client's "no active campaigns" state.
|
// an empty list is the client's "no active campaigns" state.
|
||||||
.get(
|
.get('/purchasecampaign/allcurrent/v2', (c) => c.json([]))
|
||||||
'/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,
|
// Token-bundle purchase reminders (the "buy more tokens" nudge). None to show,
|
||||||
// and an empty list is the client's "no reminders" state.
|
// and an empty list is the client's "no reminders" state.
|
||||||
.get(
|
.get('/reminder/currentTokenBundles/v2', (c) => c.json([]))
|
||||||
'/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
|
export default app
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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'),
|
|
||||||
})
|
|
||||||
@@ -18,22 +18,6 @@ describe('commerce endpoints', () => {
|
|||||||
expect(await res.json()).toBe(false)
|
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 () => {
|
it('GET /api/catalog/v1/all serves the SKU catalog', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
|
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -54,45 +38,4 @@ describe('commerce endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+22
-368
@@ -4,15 +4,14 @@ 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
|
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).
|
main `api` worker, which also serves many of them — the client may call either host).
|
||||||
|
|
||||||
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
|
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||||
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
|
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
||||||
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
|
ASSETS binding. Several routes are still empty-list stubs.
|
||||||
empty-list stubs.
|
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
`✓` = 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.
|
missing/invalid).
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||||
@@ -43,13 +42,13 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
|||||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||||
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
||||||
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
||||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
|
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
|
||||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||||
|
|
||||||
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||||||
@@ -71,9 +70,9 @@ The core flow. The client posts the storefront/item ids, the currency, and the
|
|||||||
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
||||||
price the catalog no longer offers;
|
price the catalog no longer offers;
|
||||||
3. debits the buyer **atomically** (`400` on insufficient balance);
|
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||||||
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
|
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
|
||||||
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
|
into the `consumable` table (each buy stacks a new instance); currency/xp drops
|
||||||
query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
|
aren't granted yet;
|
||||||
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
||||||
|
|
||||||
Two things are easy to get wrong:
|
Two things are easy to get wrong:
|
||||||
@@ -87,50 +86,6 @@ Two things are easy to get wrong:
|
|||||||
A `Gift` block routes the item (and box) to another player, but the caller always pays.
|
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).
|
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
|
## Consume envelopes
|
||||||
|
|
||||||
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
||||||
@@ -140,323 +95,22 @@ parses it to finish the action, so a bare 200 reads as a failure and the item ne
|
|||||||
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
|
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).
|
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
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
| ---------------------------- | -------------- | ---------------------------------------------------------- |
|
| ---------------------------- | -------------- | -------------------------------------------------------- |
|
||||||
| `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
|
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
|
||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
| `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) |
|
| `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.
|
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- Gifting to another player grants the item and box but does not notify the recipient — the
|
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||||
reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
|
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||||
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.
|
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
||||||
empty-list stubs pending their own stores.
|
rewards) 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.
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- Weekly-challenge progress, owned by the `econ` worker. One row per (account,
|
|
||||||
-- challenge): the client evaluates a challenge's rule tree locally and posts its verdict
|
|
||||||
-- to `/api/challenge/v2/updateProgress`, which upserts here; `/api/challenge/v2/getCurrent`
|
|
||||||
-- reads the rows back to stamp each challenge's per-player `Complete`.
|
|
||||||
--
|
|
||||||
-- Only the completion flag is stored. The `Config` rule tree posted alongside it is the
|
|
||||||
-- challenge's definition (static/weekly-challenge.json, identical for every player) plus
|
|
||||||
-- the client's running count in `cc`; the server evaluates none of it, so a per-player copy
|
|
||||||
-- would just be a staler duplicate of the catalog.
|
|
||||||
--
|
|
||||||
-- `challenge_map_id` is the rotation the report belongs to. It is not part of the key, but
|
|
||||||
-- it scopes reads and resets the row when a challenge id comes back in a later rotation:
|
|
||||||
-- ids are only unique within one. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
|
|
||||||
-- src/challenge-db.ts.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS challenge_status (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
challenge_id INTEGER NOT NULL,
|
|
||||||
challenge_map_id INTEGER NOT NULL,
|
|
||||||
complete INTEGER NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, challenge_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_challenge_status_account_map ON challenge_status (account_id, challenge_map_id);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Game-reward eligibility, owned by the `econ` worker. One row per (account, reward type):
|
|
||||||
-- the client asks for a reward whenever it thinks one is due (`POST
|
|
||||||
-- /api/gamerewards/v1/request` with `rewardType`/`Message`), so this table is what decides
|
|
||||||
-- whether one is actually owed and keeps a repeat ask from paying out twice.
|
|
||||||
--
|
|
||||||
-- `granted_at` is when the type was last claimed and `grant_count` how many times it has
|
|
||||||
-- been; the claim is a conditional upsert, so the check and the write are one atomic
|
|
||||||
-- statement (the client can fire two requests at once after a match).
|
|
||||||
--
|
|
||||||
-- The reward TYPE is the whole key. The client also sends a `giftContext` (the activity,
|
|
||||||
-- e.g. `Soccer`), deliberately not keyed on: one cooldown per type, shared across
|
|
||||||
-- activities. Kept in sync with REWARD_STATUS_SCHEMA_DDL in src/reward-db.ts.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reward_status (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
reward_type TEXT NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL,
|
|
||||||
grant_count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, reward_type)
|
|
||||||
);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Weekly-challenge gift grants, owned by the `econ` worker. One row per (account,
|
|
||||||
-- rotation), written when the last challenge of a rotation is reported complete on
|
|
||||||
-- `/api/challenge/v2/updateProgress` and the rotation's `Gift` is handed out.
|
|
||||||
--
|
|
||||||
-- The table exists only to make that grant happen ONCE. The client reports progress
|
|
||||||
-- repeatedly, so every report that arrives with the set already finished would otherwise
|
|
||||||
-- mint another copy of the reward; the insert is the gate, and it conflicts on the second
|
|
||||||
-- report instead of paying out again.
|
|
||||||
--
|
|
||||||
-- Keyed by rotation as well as account so a new week's set can be finished and rewarded on
|
|
||||||
-- its own — `challenge_map_id` is the rotation, matching `challenge_status`. There is no
|
|
||||||
-- `granted` flag: the row's existence IS the grant. Kept in sync with
|
|
||||||
-- CHALLENGE_GIFT_SCHEMA_DDL in src/challenge-db.ts.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS challenge_gift (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
challenge_map_id INTEGER NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, challenge_map_id)
|
|
||||||
);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- Player progression (level + XP), owned by the `econ` worker as the writer, but shared:
|
|
||||||
-- `econ` pays XP out (game rewards) and `api` reads it back for
|
|
||||||
-- `GET /api/players/v{1,2}/progression/…`, so the helpers live in @repo/domain rather than
|
|
||||||
-- in either worker. Same split as `received_gift`.
|
|
||||||
--
|
|
||||||
-- One row per account, created on the first grant. A missing row means "nothing earned
|
|
||||||
-- yet", which is the level-1/0-XP default the progression endpoints already served — so
|
|
||||||
-- reads fall back to it instead of inserting on a GET.
|
|
||||||
--
|
|
||||||
-- `level` is stored rather than derived: the reference server levels a player up by
|
|
||||||
-- subtracting the tier's RequiredXp from the running XP, using thresholds from a config we
|
|
||||||
-- don't have (configv2.json's LevelProgressionMaps). Until those numbers exist XP
|
|
||||||
-- accumulates and everyone stays level 1; the column is here so turning the curve on later
|
|
||||||
-- is a write, not a migration. Kept in sync with PROGRESSION_SCHEMA_DDL in
|
|
||||||
-- packages/domain/src/progression-db.ts.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS progression (
|
|
||||||
account_id INTEGER PRIMARY KEY,
|
|
||||||
level INTEGER NOT NULL DEFAULT 1,
|
|
||||||
xp INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- Widen the game-reward cooldown key to include the activity the reward came from.
|
|
||||||
--
|
|
||||||
-- The client posts a `giftContext` alongside the type (`rewardType=PostGameActivity&
|
|
||||||
-- giftContext=Soccer`), which migration 0010 deliberately dropped: one cooldown per type,
|
|
||||||
-- shared across activities. That means the first activity of the day pays once no matter
|
|
||||||
-- how many different activities a player runs. Keying on (type, context) instead gives
|
|
||||||
-- each activity its own cooldown, so a different activity pays again while the same one
|
|
||||||
-- stays on cooldown.
|
|
||||||
--
|
|
||||||
-- SQLite can't add a column to a primary key, so the table is rebuilt and the rows copied
|
|
||||||
-- across. Existing rows have no context and take `''` — NOT the NULL that would read more
|
|
||||||
-- naturally, because SQLite allows (and does not dedupe) NULLs in a non-INTEGER primary
|
|
||||||
-- key, which would let the upsert insert a second unkeyed row instead of updating the
|
|
||||||
-- first and pay out every time. Asks that carry no `giftContext` land on that same `''`
|
|
||||||
-- bucket, so a pre-migration cooldown keeps counting.
|
|
||||||
|
|
||||||
CREATE TABLE reward_status_new (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
reward_type TEXT NOT NULL,
|
|
||||||
gift_context TEXT NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL,
|
|
||||||
grant_count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, reward_type, gift_context)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO reward_status_new (account_id, reward_type, gift_context, granted_at, grant_count)
|
|
||||||
SELECT account_id, reward_type, '', granted_at, grant_count FROM reward_status;
|
|
||||||
|
|
||||||
DROP TABLE reward_status;
|
|
||||||
|
|
||||||
ALTER TABLE reward_status_new RENAME TO reward_status;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Currency balances on the shared `recflare` D1 database.
|
* Currency balances on the shared `recflare` D1 database.
|
||||||
*
|
*
|
||||||
@@ -15,8 +13,7 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
* The currencies the client knows about (its `CurrencyType` enum). The client sends
|
||||||
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
|
||||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||||
*
|
*
|
||||||
@@ -93,23 +90,10 @@ export function startingBalances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ONE balance bucket this server uses: `NonPurchasedNotUsableInP2P` (-2).
|
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
|
||||||
*
|
* per-platform wallets (real RecNet did, for platform-purchased tokens).
|
||||||
* The client keys a balance by `(CurrencyType, Platform)` and shows the SUM of the buckets,
|
|
||||||
* so which Platform a balance is reported under is not cosmetic — it is the bucket's
|
|
||||||
* identity. Everything we hand out is minted rather than bought, and we track no
|
|
||||||
* per-platform wallets (real RecNet did, for tokens paid for on each store), so one
|
|
||||||
* account-wide bucket per currency answers for all of them.
|
|
||||||
*
|
|
||||||
* Every surface that names the bucket must name THIS one: the balance DTO's `Platform`, the
|
|
||||||
* `BalanceType` the storefront HTTP bodies echo, and the `Platform` on every
|
|
||||||
* `StorefrontBalance*` socket frame. Naming a second one there invents a balance the client
|
|
||||||
* adds to the real total — see the frame rule in econ.app.ts.
|
|
||||||
*
|
|
||||||
* The enum itself lives in the notify worker's `notification-payloads.ts`, recovered from
|
|
||||||
* the client's decoder, rather than being duplicated here.
|
|
||||||
*/
|
*/
|
||||||
export const ALL_PLATFORMS: BalancePlatform = BalancePlatform.NonPurchasedNotUsableInP2P
|
export const ALL_PLATFORMS = -2
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */
|
/** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */
|
||||||
export const BALANCE_SCHEMA_DDL: string[] = [
|
export const BALANCE_SCHEMA_DDL: string[] = [
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Weekly-challenge progress on the shared `recflare` D1 database — one row per
|
|
||||||
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back
|
|
||||||
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`.
|
|
||||||
*
|
|
||||||
* Only the completion flag is stored, not the `Config` rule tree the client posts with it.
|
|
||||||
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and
|
|
||||||
* is identical for everyone), decorated with the client's running count in `cc`; the
|
|
||||||
* server evaluates none of it, so persisting a per-player copy would only be a second,
|
|
||||||
* staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar.
|
|
||||||
*
|
|
||||||
* Completion LATCHES within a rotation: the client reports progress repeatedly, and a
|
|
||||||
* report that arrives with the challenge no longer complete (a fresh session, a reordered
|
|
||||||
* retry) must not un-finish something already finished. A report carrying a different
|
|
||||||
* `ChallengeMapId` is a new rotation and REPLACES the row instead — challenge ids are only
|
|
||||||
* unique within a rotation, so a challenge that returns in a later week would otherwise
|
|
||||||
* start out already complete on the old week's row.
|
|
||||||
*
|
|
||||||
* Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the
|
|
||||||
* same `updateProgress` call that reaches the threshold. That payout is gated by a
|
|
||||||
* second table here, `challenge_gift` — one row per (account, rotation), claimed once.
|
|
||||||
*
|
|
||||||
* The `econ` worker owns both tables and their migrations
|
|
||||||
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
|
|
||||||
export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS challenge_status (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
challenge_id INTEGER NOT NULL,
|
|
||||||
challenge_map_id INTEGER NOT NULL,
|
|
||||||
complete INTEGER NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, challenge_id)
|
|
||||||
)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/** One challenge's progress as the client reports it. */
|
|
||||||
export interface ChallengeProgress {
|
|
||||||
challengeMapId: number
|
|
||||||
challengeId: number
|
|
||||||
complete: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a progress report and return the completion the row now holds — which is what the
|
|
||||||
* response must echo, since it isn't always what was posted: within a rotation `complete`
|
|
||||||
* only ever goes false → true (see the latching note above), so a `false` report against a
|
|
||||||
* finished challenge answers `true`.
|
|
||||||
*
|
|
||||||
* SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the
|
|
||||||
* `CASE` can compare the stored `challenge_map_id` with the incoming one while the same
|
|
||||||
* statement overwrites it.
|
|
||||||
*/
|
|
||||||
export async function recordChallengeProgress(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
progress: ChallengeProgress
|
|
||||||
): Promise<boolean> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
||||||
ON CONFLICT (account_id, challenge_id) DO UPDATE SET
|
|
||||||
complete = CASE
|
|
||||||
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
|
|
||||||
THEN MAX(challenge_status.complete, excluded.complete)
|
|
||||||
ELSE excluded.complete
|
|
||||||
END,
|
|
||||||
challenge_map_id = excluded.challenge_map_id,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
RETURNING complete`
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
accountId,
|
|
||||||
progress.challengeId,
|
|
||||||
progress.challengeMapId,
|
|
||||||
progress.complete ? 1 : 0,
|
|
||||||
new Date().toISOString()
|
|
||||||
)
|
|
||||||
.first<{ complete: number }>()
|
|
||||||
return row?.complete === 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
|
|
||||||
* so a stale row from an earlier week — same challenge id, different `challenge_map_id` —
|
|
||||||
* doesn't show up pre-completed before the client has reported anything against it.
|
|
||||||
*
|
|
||||||
* Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the
|
|
||||||
* challenges in static/weekly-challenge.json appear here — three of the five a week
|
|
||||||
* publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
|
|
||||||
*/
|
|
||||||
export async function getCompletedChallengeIds(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
challengeMapId: number
|
|
||||||
): Promise<Set<number>> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT challenge_id FROM challenge_status
|
|
||||||
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
|
|
||||||
)
|
|
||||||
.bind(accountId, challengeMapId)
|
|
||||||
.all<{ challenge_id: number }>()
|
|
||||||
return new Set(results.map((r) => r.challenge_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
|
|
||||||
export const CHALLENGE_GIFT_SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS challenge_gift (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
challenge_map_id INTEGER NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, challenge_map_id)
|
|
||||||
)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take the one gift a rotation owes a player, returning whether this call is the one that
|
|
||||||
* got it — `false` means it was already handed out and the caller must grant nothing.
|
|
||||||
*
|
|
||||||
* The client keeps reporting progress after the set is finished, so "has this been paid?"
|
|
||||||
* has to be asked and answered in ONE statement: a read-then-insert would let two reports
|
|
||||||
* that land together both see no row and both pay out. `ON CONFLICT … DO NOTHING` with
|
|
||||||
* `RETURNING` gives us that — the second insert matches the existing row, writes nothing
|
|
||||||
* and returns nothing.
|
|
||||||
*
|
|
||||||
* The gate is deliberately at-most-once: the row is claimed BEFORE the items are granted,
|
|
||||||
* so a failure mid-grant loses the reward rather than risking a second one. It is a faucet,
|
|
||||||
* and a stuck one is easier to notice and re-grant by hand than a leaking one.
|
|
||||||
*/
|
|
||||||
export async function claimChallengeGift(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
challengeMapId: number,
|
|
||||||
now: Date = new Date()
|
|
||||||
): Promise<boolean> {
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO challenge_gift (account_id, challenge_map_id, granted_at)
|
|
||||||
VALUES (?1, ?2, ?3)
|
|
||||||
ON CONFLICT (account_id, challenge_map_id) DO NOTHING
|
|
||||||
RETURNING granted_at`
|
|
||||||
)
|
|
||||||
.bind(accountId, challengeMapId, now.toISOString())
|
|
||||||
.first<{ granted_at: string }>()
|
|
||||||
return row !== null
|
|
||||||
}
|
|
||||||
+104
-1228
@@ -2,29 +2,12 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||||
addXp,
|
|
||||||
consumeGift,
|
|
||||||
createGift,
|
|
||||||
getGift,
|
|
||||||
getPendingGifts,
|
|
||||||
grantInvention,
|
|
||||||
levelReward,
|
|
||||||
levelsReached,
|
|
||||||
ownsInvention,
|
|
||||||
} from '@repo/domain'
|
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
// as a value — the enum has no runtime dependencies.
|
||||||
// their own, and buyInvention has to read the very rows `api` writes.
|
|
||||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
|
||||||
// The notification-type ids the hub carries, and the payload shapes recovered from the
|
|
||||||
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
|
|
||||||
// the frames this worker builds are typed by the shapes the client actually parses — a
|
|
||||||
// wrong or renamed key (see the `Platform`/`BalanceType` trap) fails the build here.
|
|
||||||
import { BalanceAddType } from '../../notify/src/notification-payloads'
|
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||||
@@ -34,19 +17,11 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
|||||||
import { getAvatar, setAvatar } from './avatar-db'
|
import { getAvatar, setAvatar } from './avatar-db'
|
||||||
import {
|
import {
|
||||||
ALL_PLATFORMS,
|
ALL_PLATFORMS,
|
||||||
creditCurrency,
|
|
||||||
CurrencyType,
|
|
||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
ensureStartingBalances,
|
|
||||||
getBalance,
|
getBalance,
|
||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
import {
|
|
||||||
claimChallengeGift,
|
|
||||||
getCompletedChallengeIds,
|
|
||||||
recordChallengeProgress,
|
|
||||||
} from './challenge-db'
|
|
||||||
import {
|
import {
|
||||||
consumeConsumable,
|
consumeConsumable,
|
||||||
countConsumable,
|
countConsumable,
|
||||||
@@ -59,7 +34,6 @@ import {
|
|||||||
AUTHED,
|
AUTHED,
|
||||||
AvatarV2Dto,
|
AvatarV2Dto,
|
||||||
BalanceEntry,
|
BalanceEntry,
|
||||||
BuyInventionResponse,
|
|
||||||
BuyItemRequest,
|
BuyItemRequest,
|
||||||
BuyItemResponse,
|
BuyItemResponse,
|
||||||
ChallengeProgressRequest,
|
ChallengeProgressRequest,
|
||||||
@@ -71,29 +45,20 @@ import {
|
|||||||
EquipmentUpdateRequest,
|
EquipmentUpdateRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
GameRewardRequest,
|
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
OpaqueJsonBody,
|
OpaqueJsonBody,
|
||||||
OPTIONAL_AUTHED,
|
|
||||||
SaveOutfitRequest,
|
SaveOutfitRequest,
|
||||||
SaveOutfitV4Response,
|
SaveOutfitV4Response,
|
||||||
SubscriptionResponse,
|
SubscriptionResponse,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
UpdateObjectiveRequest,
|
|
||||||
UpdateObjectiveResponse,
|
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
import { getOutfits, setOutfit } from './outfit-db'
|
import { getOutfits, setOutfit } from './outfit-db'
|
||||||
import { claimReward } from './reward-db'
|
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
|
import type { GiftContent, StoredGift } from '@repo/domain'
|
||||||
import type {
|
|
||||||
BalanceResponsePayload,
|
|
||||||
PurchaseBalanceModificationPayload,
|
|
||||||
} from '../../notify/src/notification-payloads'
|
|
||||||
import type { Avatar } from './avatar-db'
|
import type { Avatar } from './avatar-db'
|
||||||
import type { ConsumeResult } from './consumables-db'
|
import type { ConsumeResult } from './consumables-db'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -104,8 +69,7 @@ import type { Outfit } from './outfit-db'
|
|||||||
/**
|
/**
|
||||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
* inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||||
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
|
|
||||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||||
*
|
*
|
||||||
@@ -120,31 +84,11 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps
|
|
||||||
* from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
|
|
||||||
* the request carries no valid token; an empty array means a valid token with no roles.
|
|
||||||
* Shaped to mirror {@link authedId}.
|
|
||||||
*/
|
|
||||||
async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
|
||||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||||
function unauthorized(c: Context<App>) {
|
function unauthorized(c: Context<App>) {
|
||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
|
|
||||||
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
|
|
||||||
* `"False"` is truthy, so a client reporting "not complete" would read as complete.
|
|
||||||
* Anything unrecognised (missing, `null`, `""`) is false.
|
|
||||||
*/
|
|
||||||
function parseBool(value: string | boolean | undefined): boolean {
|
|
||||||
return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
||||||
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
||||||
@@ -240,43 +184,13 @@ async function pushConsumableAdded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
|
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
||||||
*
|
* reference's
|
||||||
* The client holds a balance PER `(CurrencyType, Platform)` bucket and shows the SUM of the
|
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
||||||
* buckets. Every `StorefrontBalance*` frame is an absolute SET of the one bucket it names —
|
* The client applies it to the shown balance so a purchase debit reflects immediately,
|
||||||
* not a change to apply — so:
|
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
|
||||||
*
|
* currency (not the delta), `BalanceType` is -2 (account-wide, all platforms). Best-effort:
|
||||||
* 1. `Balance` is the RESULTING TOTAL. Sending the change sets the bucket TO that change.
|
* a hub failure is logged and swallowed, since the balance change has already committed.
|
||||||
* 2. The bucket key on the wire is `Platform`. The client's property is called
|
|
||||||
* `BalanceType` but carries a `[DataMember]` rename, and its decoder drops unknown
|
|
||||||
* members in silence — so a frame that says `BalanceType` lands in `Platform` 0,
|
|
||||||
* `SteamPurchased`, and creates a SECOND bucket that is added to the real one forever.
|
|
||||||
* 3. That bucket must be the same one `GET /api/storefronts/v4/balance/:type` reports,
|
|
||||||
* `ALL_PLATFORMS`. One account-wide bucket per currency is the whole model here; a
|
|
||||||
* frame naming any other Platform is a phantom balance, not a per-store nicety.
|
|
||||||
*
|
|
||||||
* Both live bugs were rule 2 or 3, and both looked like the frame being "additive":
|
|
||||||
* - A player who earned 250 on 10,000 read 20,250 — `BalanceType: -2` was dropped, so the
|
|
||||||
* total landed in a phantom Steam bucket beside the real one.
|
|
||||||
* - A player who spent 900 of 17,500 read 34,100, then 33,200 once the purchase response's
|
|
||||||
* -900 reached the real bucket — same phantom bucket, this time from `Platform: RecNet`.
|
|
||||||
* Neither was additivity: the totals were right, the bucket was wrong. Frames as specified
|
|
||||||
* here are idempotent, so re-sending one or racing a `GET /balance` cannot drift the total.
|
|
||||||
*
|
|
||||||
* See apps/notify/src/notification-payloads.ts for the payload shapes this is recovered
|
|
||||||
* from — the interfaces there type these calls, so a wrong key is now a build error.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a StorefrontBalanceUpdate (61) — "your balance in this bucket is now X" — after a
|
|
||||||
* player's balance changes for a reason that is not their own purchase. `balance` is their
|
|
||||||
* resulting TOTAL in that currency, per the rule above.
|
|
||||||
*
|
|
||||||
* A player who is reading the HTTP response for the same change gets this too: it sets the
|
|
||||||
* bucket to the same total the body reports, so the two agree rather than compound. Pushing
|
|
||||||
* it is what saves them a `GET /balance` re-fetch.
|
|
||||||
*
|
|
||||||
* Best-effort: a hub failure is logged and swallowed, since the change has already committed.
|
|
||||||
*/
|
*/
|
||||||
async function pushBalanceUpdate(
|
async function pushBalanceUpdate(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
@@ -284,19 +198,15 @@ async function pushBalanceUpdate(
|
|||||||
currencyType: number,
|
currencyType: number,
|
||||||
balance: number
|
balance: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// `satisfies` rather than a type annotation: the hub takes a Record<string, unknown>, and
|
|
||||||
// an interface (unlike an inferred object type) has no implicit index signature to match
|
|
||||||
// it. This still checks every key against the shape the client's decoder parses.
|
|
||||||
const payload = {
|
|
||||||
Balance: balance,
|
|
||||||
CurrencyType: currencyType,
|
|
||||||
Platform: ALL_PLATFORMS,
|
|
||||||
} satisfies BalanceResponsePayload
|
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
accountId,
|
accountId,
|
||||||
NotificationType.StorefrontBalanceUpdate,
|
NotificationType.StorefrontBalanceUpdate,
|
||||||
payload
|
{
|
||||||
|
Balance: balance,
|
||||||
|
CurrencyType: currencyType,
|
||||||
|
BalanceType: ALL_PLATFORMS,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
||||||
@@ -306,99 +216,6 @@ async function pushBalanceUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a StorefrontBalancePurchase (62) — the frame the reference sends when the balance
|
|
||||||
* moved because the player BOUGHT something, as opposed to the plain update above. Same
|
|
||||||
* absolute-set semantics: `balance` is the resulting total.
|
|
||||||
*
|
|
||||||
* `Delta` (the negated price) and `BalanceAddType` are display/telemetry only — the client
|
|
||||||
* logs them and then stores `Balance` outright, so a correct `Delta` beside a stale
|
|
||||||
* `Balance` still leaves the player's balance wrong. `Platform` is `ALL_PLATFORMS`, NOT
|
|
||||||
* `RecNetPurchased`: it has to name the bucket `GET /balance` reports, and sending RecNet
|
|
||||||
* here is exactly what doubled a buyer's tokens on screen. Best-effort, as above.
|
|
||||||
*/
|
|
||||||
async function pushBalancePurchase(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
currencyType: number,
|
|
||||||
delta: number,
|
|
||||||
balance: number
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = {
|
|
||||||
BalanceAddType: BalanceAddType.CommercePurchase,
|
|
||||||
Delta: delta,
|
|
||||||
Balance: balance,
|
|
||||||
Platform: ALL_PLATFORMS,
|
|
||||||
CurrencyType: currencyType,
|
|
||||||
} satisfies PurchaseBalanceModificationPayload
|
|
||||||
try {
|
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
|
||||||
accountId,
|
|
||||||
NotificationType.StorefrontBalancePurchase,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to push StorefrontBalancePurchase notification', {
|
|
||||||
accountId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The operator-granted role that comes with a complimentary subscription. */
|
|
||||||
const DEVELOPER_ROLE = 'developer'
|
|
||||||
|
|
||||||
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
|
||||||
const SUBSCRIPTION_LEVEL_GOLD = 0
|
|
||||||
|
|
||||||
/** `SubscriptionPeriod.Year`. 0 is Month, 2 ThreeMonth, 3 SixMonth. */
|
|
||||||
const SUBSCRIPTION_PERIOD_YEAR = 1
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `PlatformType.All` (-1) — the subscription belongs to no single store, which is the honest
|
|
||||||
* answer when no store sold it. The rest of the enum: 0 Steam, 1 Oculus, 2 PlayStation,
|
|
||||||
* 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico.
|
|
||||||
*/
|
|
||||||
const SUBSCRIPTION_PLATFORM_ALL = -1
|
|
||||||
|
|
||||||
/** The id every reported subscription carries — a placeholder, since none is stored. */
|
|
||||||
const STUB_SUBSCRIPTION_ID = 1
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
|
|
||||||
* client's API calls a `CampusCard`.
|
|
||||||
*
|
|
||||||
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
|
|
||||||
* paid-tier surfaces get exercised without a store. Every field is computed per call and
|
|
||||||
* none of it is persisted, so this is not a record of anything — revoking the role revokes
|
|
||||||
* the subscription, and no expiry sweep or renewal exists.
|
|
||||||
*
|
|
||||||
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
|
|
||||||
* lapses on a day nobody is expecting, and the client would start showing an expired
|
|
||||||
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
|
|
||||||
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
|
|
||||||
*/
|
|
||||||
function developerSubscription(accountId: number) {
|
|
||||||
const now = new Date()
|
|
||||||
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
|
||||||
// year whether or not a leap day falls in between.
|
|
||||||
const expires = new Date(now)
|
|
||||||
expires.setUTCFullYear(expires.getUTCFullYear() + 1)
|
|
||||||
return {
|
|
||||||
SubscriptionId: STUB_SUBSCRIPTION_ID,
|
|
||||||
RecNetPlayerId: accountId,
|
|
||||||
PlatformType: SUBSCRIPTION_PLATFORM_ALL,
|
|
||||||
PlatformId: '',
|
|
||||||
PlatformPurchaseId: '',
|
|
||||||
Level: SUBSCRIPTION_LEVEL_GOLD,
|
|
||||||
Period: SUBSCRIPTION_PERIOD_YEAR,
|
|
||||||
ExpirationDate: expires.toISOString(),
|
|
||||||
IsAutoRenewing: true,
|
|
||||||
CreatedAt: now.toISOString(),
|
|
||||||
ModifiedAt: now.toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project a stored avatar into the public render subset returned by
|
* Project a stored avatar into the public render subset returned by
|
||||||
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||||||
@@ -432,26 +249,6 @@ interface StoreGiftDrop {
|
|||||||
Context: number
|
Context: number
|
||||||
Currency: number
|
Currency: number
|
||||||
CurrencyType: number
|
CurrencyType: number
|
||||||
/**
|
|
||||||
* A QUERY drop — a loot box rather than an item. Its item fields are all empty on
|
|
||||||
* purpose: what the player gets is rolled at grant time from everything of the target
|
|
||||||
* rarity they don't already own (see {@link rollQueryDrop}). sf2's "Star Boxes" set and
|
|
||||||
* sf3's "Random box" family are the two that ship; sf2's tooltip says it outright — "A
|
|
||||||
* random 4-star item that you don't have."
|
|
||||||
*/
|
|
||||||
IsQuery?: boolean
|
|
||||||
/**
|
|
||||||
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
|
|
||||||
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
|
|
||||||
* to `Rarity`.
|
|
||||||
*/
|
|
||||||
QueryRedirectRarity?: number
|
|
||||||
/**
|
|
||||||
* XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a
|
|
||||||
* game reward is XP in a gift box, so the box and its notification carry the amount from
|
|
||||||
* here. The XP itself is banked in `progression`, not read back off the box.
|
|
||||||
*/
|
|
||||||
Xp?: number
|
|
||||||
}
|
}
|
||||||
interface StorePrice {
|
interface StorePrice {
|
||||||
CurrencyType: number
|
CurrencyType: number
|
||||||
@@ -538,7 +335,7 @@ function toGiftContent(
|
|||||||
AvatarItemType: giftDrop.AvatarItemType,
|
AvatarItemType: giftDrop.AvatarItemType,
|
||||||
CurrencyType: giftDrop.CurrencyType,
|
CurrencyType: giftDrop.CurrencyType,
|
||||||
Currency: giftDrop.Currency,
|
Currency: giftDrop.Currency,
|
||||||
Xp: giftDrop.Xp ?? 0,
|
Xp: 0,
|
||||||
PackageType: 0,
|
PackageType: 0,
|
||||||
Message: message,
|
Message: message,
|
||||||
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
||||||
@@ -550,616 +347,6 @@ function toGiftContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask
|
|
||||||
* for, mirroring the reference's
|
|
||||||
* `HubSendToPlayer(accountID, NotifFrame(GiftPackageReceivedImmediate, {...}))` — the
|
|
||||||
* client pops the "you got something" panel from it instead of waiting for the next read of
|
|
||||||
* `GET /api/avatar/v2/gifts`.
|
|
||||||
*
|
|
||||||
* The payload is the reference's field-for-field: the stored box's contents plus its `Id`,
|
|
||||||
* a `FromGiftDropId` of 0 (the reference never populates it either) and the
|
|
||||||
* platform/balance constants. `Xp` is the drop's, so a game reward's box announces the XP it
|
|
||||||
* paid; `Level` is 0, since nothing levels a player up yet.
|
|
||||||
*
|
|
||||||
* "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a
|
|
||||||
* box handed over by the server: a purchase gifted to another player, an admin token grant,
|
|
||||||
* a report reward. This is the same case — the player is being handed a box they never
|
|
||||||
* clicked for. Best-effort: a hub failure is logged and swallowed, since the gift itself is
|
|
||||||
* already granted and stored.
|
|
||||||
*/
|
|
||||||
async function pushGiftReceived(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
gift: GrantedGift,
|
|
||||||
message: string,
|
|
||||||
fromPlayerId: number
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
|
||||||
accountId,
|
|
||||||
NotificationType.GiftPackageReceivedImmediate,
|
|
||||||
{
|
|
||||||
Id: gift.id,
|
|
||||||
FromGiftDropId: 0,
|
|
||||||
FromPlayerId: fromPlayerId,
|
|
||||||
ConsumableItemDesc: gift.drop.ConsumableItemDesc,
|
|
||||||
AvatarItemDesc: gift.drop.AvatarItemDesc,
|
|
||||||
AvatarItemType: gift.drop.AvatarItemType ?? 0,
|
|
||||||
EquipmentPrefabName: gift.drop.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
|
|
||||||
CurrencyType: gift.drop.CurrencyType,
|
|
||||||
Currency: gift.drop.Currency,
|
|
||||||
Xp: gift.drop.Xp ?? 0,
|
|
||||||
Level: 0,
|
|
||||||
Platform: -1,
|
|
||||||
PlatformsToSpawnOn: -1,
|
|
||||||
BalanceType: ALL_PLATFORMS,
|
|
||||||
GiftContext: gift.drop.Context,
|
|
||||||
GiftRarity: gift.drop.Rarity,
|
|
||||||
Message: message,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to push GiftPackageReceivedImmediate notification', {
|
|
||||||
accountId,
|
|
||||||
giftId: gift.id,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead
|
|
||||||
* of waiting for its next progression read. `XP` is the progress into the current level (the
|
|
||||||
* ladder spends the rest on the level-ups), which is what the bar draws against the
|
|
||||||
* `LevelProgressionMaps` the client is served.
|
|
||||||
*
|
|
||||||
* Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the
|
|
||||||
* reward.
|
|
||||||
*/
|
|
||||||
async function pushProgressionUpdate(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
progression: Progression
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
|
||||||
accountId,
|
|
||||||
NotificationType.PlayerProgressionLevelUpdate,
|
|
||||||
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
|
|
||||||
accountId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The catalog a query drop rolls from: sf3, the general store. It is the only catalog with
|
|
||||||
* a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the
|
|
||||||
* "Random box" family itself sells, and a box promising "a random 4-star item" plainly
|
|
||||||
* means the whole item universe rather than whichever seasonal shelf it was bought from.
|
|
||||||
*/
|
|
||||||
const ROLL_STOREFRONT_TYPE = 3
|
|
||||||
|
|
||||||
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
|
|
||||||
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
|
||||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url))
|
|
||||||
if (!res.ok) return []
|
|
||||||
const storefront = (await res.json()) as Storefront
|
|
||||||
return storefront.StoreItems
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the player already owns what a drop carries — the question a query drop's "an
|
|
||||||
* item that you don't have" turns on, and the one that decides whether the weekly gift
|
|
||||||
* hands over its item or rolls the fallback box instead.
|
|
||||||
*
|
|
||||||
* Ownership is boolean for avatar items and equipment, which is what makes "already have
|
|
||||||
* it" meaningful. A drop carrying neither (a consumable, a currency drop, an empty query
|
|
||||||
* box) counts as owned: there is nothing ownable to hand over, so callers offering a
|
|
||||||
* fallback should take it.
|
|
||||||
*/
|
|
||||||
async function ownsGiftDrop(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
giftDrop: StoreGiftDrop
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
|
||||||
const owned = await getInventory(db, accountId)
|
|
||||||
return owned.some((item) => item.AvatarItemDesc === giftDrop.AvatarItemDesc)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
|
||||||
giftDrop.EquipmentModificationGuid !== ''
|
|
||||||
) {
|
|
||||||
const owned = await getEquipment(db, accountId)
|
|
||||||
return owned.some((eq) => eq.ModificationGuid === giftDrop.EquipmentModificationGuid)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How a query drop is rolled — what it may land on, and whose catalog copy to use. */
|
|
||||||
interface RollOptions {
|
|
||||||
/**
|
|
||||||
* Restrict the roll to avatar items, leaving equipment skins out of the pool. Off by
|
|
||||||
* default: a bought box says "a random item", and the catalog's own boxes mean both.
|
|
||||||
*/
|
|
||||||
avatarItemsOnly?: boolean
|
|
||||||
/**
|
|
||||||
* The roll catalog, when the caller has already read it — it's the big one (sf3), and a
|
|
||||||
* caller granting several boxes at once shouldn't re-read it per box.
|
|
||||||
*/
|
|
||||||
rollCatalog?: StoreItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roll a query drop: pick, uniformly at random, one item of `rarity` from the roll catalog
|
|
||||||
* that the player doesn't already own. Returns null when the pool is empty — an unreadable
|
|
||||||
* catalog, a rarity nothing is published at, or a player who owns every item of that tier.
|
|
||||||
*
|
|
||||||
* The pool is deliberately narrow. Other query drops are excluded (a box that rolls a box
|
|
||||||
* would either loop or hand over an unopenable one), and so is everything that isn't an
|
|
||||||
* avatar item or a piece of equipment: "an item you don't have" only means anything for
|
|
||||||
* things owned once, and consumables stack, so a consumable would be rollable forever and
|
|
||||||
* would crowd out the real prizes.
|
|
||||||
*
|
|
||||||
* `avatarItemsOnly` narrows it further to things worn on the avatar, leaving equipment
|
|
||||||
* skins out — a level-up prize should be something the player can see on themselves, not a
|
|
||||||
* skin for a weapon they may not own. It also skips the equipment read entirely, since
|
|
||||||
* nothing in the pool can match it.
|
|
||||||
*/
|
|
||||||
async function rollQueryDrop(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
rarity: number,
|
|
||||||
options: RollOptions = {}
|
|
||||||
): Promise<StoreGiftDrop | null> {
|
|
||||||
const [catalog, ownedItems, ownedEquipment] = await Promise.all([
|
|
||||||
options.rollCatalog ?? loadRollCatalog(c),
|
|
||||||
getInventory(c.env.DB, accountId),
|
|
||||||
options.avatarItemsOnly === true ? [] : getEquipment(c.env.DB, accountId),
|
|
||||||
])
|
|
||||||
const haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc))
|
|
||||||
const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid))
|
|
||||||
const pool = catalog.filter(({ GiftDrop: drop }) => {
|
|
||||||
if (drop.IsQuery === true || drop.Rarity !== rarity) return false
|
|
||||||
if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') {
|
|
||||||
return !haveItem.has(drop.AvatarItemDesc)
|
|
||||||
}
|
|
||||||
if (options.avatarItemsOnly === true) return false
|
|
||||||
if (
|
|
||||||
typeof drop.EquipmentModificationGuid === 'string' &&
|
|
||||||
drop.EquipmentModificationGuid !== ''
|
|
||||||
) {
|
|
||||||
return !haveEquipment.has(drop.EquipmentModificationGuid)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
const rolled = pool[Math.floor(Math.random() * pool.length)]
|
|
||||||
return rolled?.GiftDrop ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A gift box that was just created, and the drop it ended up holding. The drop is the
|
|
||||||
* RESOLVED one — what a query drop rolled, not the box that promised it — so a caller
|
|
||||||
* announcing the gift names the item the player actually won.
|
|
||||||
*/
|
|
||||||
interface GrantedGift {
|
|
||||||
id: number
|
|
||||||
drop: StoreGiftDrop
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick a random consumable from the roll catalog — the reward the published level table
|
|
||||||
* hands out for the early levels.
|
|
||||||
*
|
|
||||||
* Unlike a clothing roll this one has no rarity and no ownership filter: the table names no
|
|
||||||
* star tier for a consumable, and consumables STACK, so "one you don't have" is meaningless
|
|
||||||
* (a second Confetti Cannon is a fine prize). Returns a concrete drop rather than a query
|
|
||||||
* one, so the grant path just grants it.
|
|
||||||
*/
|
|
||||||
function rollConsumableDrop(catalog: StoreItem[]): StoreGiftDrop | null {
|
|
||||||
const pool = catalog.filter(
|
|
||||||
({ GiftDrop: drop }) =>
|
|
||||||
drop.IsQuery !== true &&
|
|
||||||
typeof drop.ConsumableItemDesc === 'string' &&
|
|
||||||
drop.ConsumableItemDesc !== ''
|
|
||||||
)
|
|
||||||
return pool[Math.floor(Math.random() * pool.length)]?.GiftDrop ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand a gift-drop to a player: grant whatever it turns out to carry (an avatar item, an
|
|
||||||
* equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet)
|
|
||||||
* and create the gift box that renders it.
|
|
||||||
*
|
|
||||||
* A query drop is ROLLED here first, so what gets granted — and what the box shows — is the
|
|
||||||
* item the player actually won, not the box that promised it. A roll with nothing left to
|
|
||||||
* give falls through with the box itself, which grants nothing: no worse than not rolling,
|
|
||||||
* and the warning says which rarity ran dry.
|
|
||||||
*
|
|
||||||
* Both faucets share this — a storefront purchase and the weekly-challenge reward — so a
|
|
||||||
* drop lands in a player's inventory the same way whichever one it came from. The item is
|
|
||||||
* granted here, not when the box is opened: consuming a box only deletes the row.
|
|
||||||
*/
|
|
||||||
async function grantGiftDrop(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
drop: StoreGiftDrop,
|
|
||||||
message: string,
|
|
||||||
options: RollOptions = {}
|
|
||||||
): Promise<GrantedGift> {
|
|
||||||
let giftDrop = drop
|
|
||||||
if (drop.IsQuery === true) {
|
|
||||||
const rarity = drop.QueryRedirectRarity ?? drop.Rarity
|
|
||||||
const rolled = await rollQueryDrop(c, accountId, rarity, options)
|
|
||||||
if (rolled === null) {
|
|
||||||
logger.warn('query gift-drop rolled nothing', {
|
|
||||||
accountId,
|
|
||||||
rarity,
|
|
||||||
friendlyName: drop.FriendlyName,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
giftDrop = rolled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const db = c.env.DB
|
|
||||||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
|
||||||
await grantItem(db, accountId, toAvatarItem(giftDrop))
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
|
||||||
giftDrop.EquipmentModificationGuid !== ''
|
|
||||||
) {
|
|
||||||
await grantEquipment(db, accountId, toEquipment(giftDrop))
|
|
||||||
}
|
|
||||||
const isConsumable =
|
|
||||||
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
|
|
||||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
|
||||||
// Capture the granted consumable's row id and the player's pre-existing count so the
|
|
||||||
// gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
|
||||||
let consumableMappingId = 0
|
|
||||||
let consumablePreExisting = 0
|
|
||||||
if (isConsumable) {
|
|
||||||
consumablePreExisting = await countConsumable(db, accountId, giftDrop.ConsumableItemDesc)
|
|
||||||
consumableMappingId = await grantConsumable(
|
|
||||||
db,
|
|
||||||
accountId,
|
|
||||||
giftDrop.ConsumableItemDesc,
|
|
||||||
consumableCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const { id } = await createGift(
|
|
||||||
db,
|
|
||||||
accountId,
|
|
||||||
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
|
|
||||||
)
|
|
||||||
return { id, drop: giftDrop }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
|
|
||||||
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
|
|
||||||
* same size of pat on the back until there's reason to price them apart.
|
|
||||||
*
|
|
||||||
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
|
|
||||||
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
|
|
||||||
* levels are paced by the hourly cooldown rather than cleared in one match.
|
|
||||||
*/
|
|
||||||
const GAME_REWARD_XP = 5
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
|
|
||||||
* gameplay rewards rather than a purchase or a player's gift. (`51` is the tokens variant,
|
|
||||||
* for when a reward pays currency instead of XP.)
|
|
||||||
*/
|
|
||||||
const GIFT_CONTEXT_GAME_REWARDS = 50
|
|
||||||
|
|
||||||
/** Shown on the box when the client asks for a reward without saying what to call it. */
|
|
||||||
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
|
|
||||||
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
|
|
||||||
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
|
|
||||||
* the copy here is what the box and its notification display.
|
|
||||||
*/
|
|
||||||
function toGameRewardDrop(): StoreGiftDrop {
|
|
||||||
return {
|
|
||||||
FriendlyName: '',
|
|
||||||
Tooltip: '',
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
AvatarItemDesc: '',
|
|
||||||
AvatarItemType: null,
|
|
||||||
EquipmentPrefabName: '',
|
|
||||||
EquipmentModificationGuid: '',
|
|
||||||
Rarity: 0,
|
|
||||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
|
||||||
Currency: 0,
|
|
||||||
CurrencyType: 0,
|
|
||||||
Xp: GAME_REWARD_XP,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
|
|
||||||
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
|
|
||||||
* has to be something the player can wear and be seen in — never an equipment skin for a
|
|
||||||
* weapon they may not own. This is the one roll that narrows the pool that far.
|
|
||||||
*/
|
|
||||||
function toLevelUpDrop(rarity: number): StoreGiftDrop {
|
|
||||||
return {
|
|
||||||
FriendlyName: '',
|
|
||||||
Tooltip: '',
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
AvatarItemDesc: '',
|
|
||||||
AvatarItemType: null,
|
|
||||||
EquipmentPrefabName: '',
|
|
||||||
EquipmentModificationGuid: '',
|
|
||||||
Rarity: rarity,
|
|
||||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
|
||||||
Currency: 0,
|
|
||||||
CurrencyType: 0,
|
|
||||||
IsQuery: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the
|
|
||||||
* published table names a reward for every level and a single grant can cross several (a
|
|
||||||
* large enough grant could clear the first three levels at 10 XP each). Each arrives as a
|
|
||||||
* gift box, announced like any other unasked-for gift.
|
|
||||||
*
|
|
||||||
* Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay
|
|
||||||
* clothing at a rising star rating. The catalog is read once and shared across the boxes.
|
|
||||||
* Best-effort as a whole: the XP is banked and the levels are already stored, so a failed
|
|
||||||
* roll costs a prize, not the level.
|
|
||||||
*/
|
|
||||||
async function grantLevelUpGifts(
|
|
||||||
c: Context<App>,
|
|
||||||
accountId: number,
|
|
||||||
grant: XpGrant
|
|
||||||
): Promise<void> {
|
|
||||||
const levels = levelsReached(grant)
|
|
||||||
if (levels.length === 0) return
|
|
||||||
try {
|
|
||||||
const rollCatalog = await loadRollCatalog(c)
|
|
||||||
for (const level of levels) {
|
|
||||||
const reward = levelReward(level)
|
|
||||||
if (reward === null) continue
|
|
||||||
const message = `Level ${level}!`
|
|
||||||
// A consumable is rolled to a concrete drop up front; clothing rides the query path,
|
|
||||||
// which rolls it against what the player already owns.
|
|
||||||
const drop =
|
|
||||||
reward.kind === 'consumable'
|
|
||||||
? rollConsumableDrop(rollCatalog)
|
|
||||||
: toLevelUpDrop(reward.rarity)
|
|
||||||
if (drop === null) {
|
|
||||||
logger.warn('level up reward rolled nothing', { accountId, level, kind: reward.kind })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const granted = await grantGiftDrop(c, accountId, drop, message, {
|
|
||||||
avatarItemsOnly: reward.kind === 'clothing',
|
|
||||||
rollCatalog,
|
|
||||||
})
|
|
||||||
await pushGiftReceived(c, accountId, granted, message, COACH_ACCOUNT_ID)
|
|
||||||
logger.info('level up gift granted', {
|
|
||||||
accountId,
|
|
||||||
level,
|
|
||||||
kind: reward.kind,
|
|
||||||
rarity: reward.kind === 'clothing' ? reward.rarity : null,
|
|
||||||
giftId: granted.id,
|
|
||||||
avatarItemDesc: granted.drop.AvatarItemDesc,
|
|
||||||
consumableItemDesc: granted.drop.ConsumableItemDesc,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to grant level up gift', {
|
|
||||||
accountId,
|
|
||||||
levels,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
|
|
||||||
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
|
|
||||||
* so it has to be translated before the grant path can read it (see
|
|
||||||
* {@link toChallengeGiftDrop}).
|
|
||||||
*
|
|
||||||
* `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither — the
|
|
||||||
* client resolves the reward's name from the item itself, falling back to
|
|
||||||
* `FallbackGiftName`. A rotation we publish can carry them to name the granted item
|
|
||||||
* properly without a code change.
|
|
||||||
*/
|
|
||||||
interface ChallengeGift {
|
|
||||||
AvatarItemDesc: string
|
|
||||||
AvatarItemType: number
|
|
||||||
ConsumableItemDesc: string
|
|
||||||
EquipmentPrefabName: string
|
|
||||||
EquipmentModificationGuid: string
|
|
||||||
GiftContext: number
|
|
||||||
GiftRarity: number
|
|
||||||
Xp: number
|
|
||||||
FriendlyName?: string
|
|
||||||
Tooltip?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The message on the gift box the weekly reward arrives in. */
|
|
||||||
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The star rating → `Rarity` ladder, indexed by stars - 1. Pinned by sf2's "Star Boxes"
|
|
||||||
* item set, whose three members name their own tier and carry the rarity they roll at:
|
|
||||||
* 2-Star → 10, 3-Star → 20, 4-Star → 30. The ends are extrapolated from sf3's parallel
|
|
||||||
* "Random box" family (Common 0, Uncommon 10, Rare 20, Epic 30, Legendary 50), which is the
|
|
||||||
* same ladder under the other naming.
|
|
||||||
*/
|
|
||||||
const STAR_RARITY = [0, 10, 20, 30, 50]
|
|
||||||
|
|
||||||
/** The tier a "4-Star Box" rolls at, used when a rotation's fallback name doesn't parse. */
|
|
||||||
const DEFAULT_FALLBACK_STARS = 4
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rarity the rotation's `FallbackGiftName` promises, read off the leading star count
|
|
||||||
* ("4-Star Box" → 30). That string is the whole specification of the consolation prize —
|
|
||||||
* it is what the client renders when the gift resolves to a box rather than a named item —
|
|
||||||
* so a rotation can retune the tier by renaming it, with no code change.
|
|
||||||
*/
|
|
||||||
function fallbackGiftRarity(): number {
|
|
||||||
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1])
|
|
||||||
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate the rotation's `Gift` block into the storefront gift-drop shape the grant path
|
|
||||||
* reads. The renamed fields are the whole point — feeding one shape to the other's reader
|
|
||||||
* silently drops the rarity and context.
|
|
||||||
*
|
|
||||||
* The reward carries no price, so `Currency`/`CurrencyType` are zero: the box shows an
|
|
||||||
* item, not a payout. Display strings come from the block when it carries them; a block
|
|
||||||
* that doesn't (the captured rotation names neither) borrows them from the catalog entry
|
|
||||||
* selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather
|
|
||||||
* than the name of the box it might have arrived in.
|
|
||||||
*/
|
|
||||||
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
|
|
||||||
const gift = weeklyChallenge.Gift as ChallengeGift
|
|
||||||
const sold = catalog.find(
|
|
||||||
({ GiftDrop: drop }) =>
|
|
||||||
(gift.EquipmentModificationGuid !== '' &&
|
|
||||||
drop.EquipmentModificationGuid === gift.EquipmentModificationGuid) ||
|
|
||||||
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
|
|
||||||
)?.GiftDrop
|
|
||||||
return {
|
|
||||||
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
|
|
||||||
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
|
|
||||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
|
||||||
AvatarItemDesc: gift.AvatarItemDesc,
|
|
||||||
AvatarItemType: gift.AvatarItemType,
|
|
||||||
EquipmentPrefabName: gift.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
|
||||||
// The block's own `GiftRarity` is 0 in the captured rotation even though the item it
|
|
||||||
// names sells at rarity 5, so the catalog's rarity wins where there is one.
|
|
||||||
Rarity: sold?.Rarity ?? gift.GiftRarity,
|
|
||||||
Context: gift.GiftContext,
|
|
||||||
Currency: 0,
|
|
||||||
CurrencyType: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The consolation box: a query drop at the rarity `FallbackGiftName` promises, named after
|
|
||||||
* it. Handed over instead of the rotation's item when that item would be a duplicate, which
|
|
||||||
* is what the fallback name is for — the reward reads "the Camera Skin, or a 4-Star Box".
|
|
||||||
*/
|
|
||||||
function toChallengeFallbackDrop(): StoreGiftDrop {
|
|
||||||
return {
|
|
||||||
FriendlyName: weeklyChallenge.FallbackGiftName,
|
|
||||||
Tooltip: '',
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
AvatarItemDesc: '',
|
|
||||||
AvatarItemType: null,
|
|
||||||
EquipmentPrefabName: '',
|
|
||||||
EquipmentModificationGuid: '',
|
|
||||||
Rarity: fallbackGiftRarity(),
|
|
||||||
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
|
|
||||||
Currency: 0,
|
|
||||||
CurrencyType: 0,
|
|
||||||
IsQuery: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many of a rotation's challenges earn its gift. A week presents five and asks for
|
|
||||||
* three: the reward is for playing most of the week's set, not for clearing all of it, 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.
|
|
||||||
*/
|
|
||||||
const CHALLENGES_REQUIRED_FOR_GIFT = 3
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many completions this rotation's gift needs. `CompletedRequired` makes the set
|
|
||||||
* all-or-nothing when it's true — the reading its name and the partial default suggest —
|
|
||||||
* and a rotation shorter than the threshold can only ever ask for what it publishes.
|
|
||||||
*/
|
|
||||||
function challengesRequiredForGift(): number {
|
|
||||||
const published = weeklyChallenge.Challenges.length
|
|
||||||
return weeklyChallenge.CompletedRequired
|
|
||||||
? published
|
|
||||||
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Award the rotation's `Gift` if this player has just earned it, doing nothing otherwise.
|
|
||||||
* Called after each completing progress report, since `updateProgress` is the only place a
|
|
||||||
* challenge is ever finished — there is no separate claim endpoint, and the client never
|
|
||||||
* asks for this reward.
|
|
||||||
*
|
|
||||||
* Earning it takes {@link challengesRequiredForGift} of the rotation's challenges, counted
|
|
||||||
* from `challenge_status`. Only challenges the rotation still publishes count: a report can
|
|
||||||
* carry an id this week's set no longer lists (an edited rotation under a live client), and
|
|
||||||
* three of those shouldn't buy a gift the player never worked for.
|
|
||||||
*
|
|
||||||
* What lands is the `Gift` block's item — or, if the player already owns it, the box named
|
|
||||||
* by `FallbackGiftName`, which rolls something they don't have at that tier. Finishing the
|
|
||||||
* week can't be worth nothing, and the rotation's reward is one fixed item that plenty of
|
|
||||||
* players will have bought already.
|
|
||||||
*
|
|
||||||
* A grant that throws is swallowed: the client is reporting gameplay progress, and failing
|
|
||||||
* that report (which it would then retry with the same completion) is worse than missing
|
|
||||||
* the reward — the claim row is already taken, so the miss is permanent but visible in the
|
|
||||||
* logs. An empty rotation earns nothing: its threshold clamps to zero, which every player
|
|
||||||
* would otherwise meet without playing.
|
|
||||||
*/
|
|
||||||
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (weeklyChallenge.Challenges.length === 0) return
|
|
||||||
const complete = await getCompletedChallengeIds(
|
|
||||||
c.env.DB,
|
|
||||||
accountId,
|
|
||||||
weeklyChallenge.ChallengeMapId
|
|
||||||
)
|
|
||||||
const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length
|
|
||||||
if (done < challengesRequiredForGift()) return
|
|
||||||
// Claim first: this is what stops the next report paying out a second time.
|
|
||||||
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
|
|
||||||
if (!claimed) return
|
|
||||||
const catalog = await loadRollCatalog(c)
|
|
||||||
const reward = toChallengeGiftDrop(catalog)
|
|
||||||
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
|
|
||||||
const granted = await grantGiftDrop(
|
|
||||||
c,
|
|
||||||
accountId,
|
|
||||||
duplicate ? toChallengeFallbackDrop() : reward,
|
|
||||||
CHALLENGE_GIFT_MESSAGE,
|
|
||||||
{ rollCatalog: catalog }
|
|
||||||
)
|
|
||||||
// Nobody asked for this box, so the client has no reason to re-read the gifts list:
|
|
||||||
// the notification is what makes the reward show up at the moment the set is finished.
|
|
||||||
// From "Coach", the same system sender a self-buy is attributed to — the rotation is
|
|
||||||
// the server handing something over, not another player.
|
|
||||||
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
|
|
||||||
logger.info('weekly challenge gift granted', {
|
|
||||||
accountId,
|
|
||||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
|
||||||
giftId: granted.id,
|
|
||||||
fallbackRoll: duplicate,
|
|
||||||
challengesComplete: done,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to grant weekly challenge gift', {
|
|
||||||
accountId,
|
|
||||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||||||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||||||
@@ -1290,37 +477,6 @@ const app = new Hono<App>({ strict: false })
|
|||||||
(c) => c.json([])
|
(c) => c.json([])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Report one objective's progress. The client posts the whole objective as it now
|
|
||||||
// sees it (Index/Group identify it within `myprogress`) and reads back the state of
|
|
||||||
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
|
|
||||||
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
|
|
||||||
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
|
|
||||||
// is the clear time, which for a group we didn't clear is just now.
|
|
||||||
.post(
|
|
||||||
'/api/objectives/v1/updateobjective',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Econ'],
|
|
||||||
summary: 'Report objective progress',
|
|
||||||
description: [
|
|
||||||
'Stubbed: with no objectives store we persist nothing and never complete a group.',
|
|
||||||
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
|
|
||||||
'gets a well-formed body.',
|
|
||||||
].join(' '),
|
|
||||||
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
|
|
||||||
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const body = await c.req
|
|
||||||
.json<{ Group?: string | number }>()
|
|
||||||
.catch(() => ({}) as Record<string, never>)
|
|
||||||
return c.json({
|
|
||||||
group: Number(body.Group) || 0,
|
|
||||||
isCompleted: false,
|
|
||||||
clearedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
||||||
// to the default outfit when they haven't saved one — the client's parser NREs
|
// to the default outfit when they haven't saved one — the client's parser NREs
|
||||||
// on an empty OutfitSelections (real RecNet never returns one).
|
// on an empty OutfitSelections (real RecNet never returns one).
|
||||||
@@ -1840,9 +996,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
'still matches, debits the buyer atomically, grants the item (into the inventory or',
|
'still matches, debits the buyer atomically, grants the item (into the inventory or',
|
||||||
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
|
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
|
||||||
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
||||||
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
|
'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.',
|
||||||
'buyer’s account-wide bucket to the RESULTING total, so the frame, this body and a',
|
|
||||||
'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
||||||
@@ -1918,32 +1072,62 @@ const app = new Hono<App>({ strict: false })
|
|||||||
)
|
)
|
||||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||||
|
|
||||||
// Grant the item to the recipient, with the gift box that renders it. A box (an
|
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
||||||
// `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here, and
|
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant
|
||||||
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
|
// whichever it actually has.
|
||||||
// the box, or a query purchase answers with every item field empty and the client
|
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
||||||
// draws an empty box.
|
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
||||||
const { id: giftId, drop: granted } = await grantGiftDrop(
|
}
|
||||||
c,
|
if (
|
||||||
|
typeof item.GiftDrop.EquipmentModificationGuid === 'string' &&
|
||||||
|
item.GiftDrop.EquipmentModificationGuid !== ''
|
||||||
|
) {
|
||||||
|
await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop))
|
||||||
|
}
|
||||||
|
const isConsumable =
|
||||||
|
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||||
|
item.GiftDrop.ConsumableItemDesc !== ''
|
||||||
|
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||||||
|
// Capture the granted consumable's row id and the player's pre-existing count so
|
||||||
|
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||||||
|
let consumableMappingId = 0
|
||||||
|
let consumablePreExisting = 0
|
||||||
|
if (isConsumable) {
|
||||||
|
consumablePreExisting = await countConsumable(
|
||||||
|
c.env.DB,
|
||||||
|
receiverId,
|
||||||
|
item.GiftDrop.ConsumableItemDesc
|
||||||
|
)
|
||||||
|
consumableMappingId = await grantConsumable(
|
||||||
|
c.env.DB,
|
||||||
|
receiverId,
|
||||||
|
item.GiftDrop.ConsumableItemDesc,
|
||||||
|
consumableCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { id: giftId } = await createGift(
|
||||||
|
c.env.DB,
|
||||||
receiverId,
|
receiverId,
|
||||||
item.GiftDrop,
|
toGiftContent(
|
||||||
message
|
item.GiftDrop,
|
||||||
|
message,
|
||||||
|
consumableCount,
|
||||||
|
consumableMappingId,
|
||||||
|
consumablePreExisting
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
|
// Push the buyer's new (reduced) balance over the socket so their client updates the
|
||||||
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
|
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
|
||||||
// SETS the account-wide bucket to the resulting total read back from D1, so it agrees
|
// spent. Best-effort; the HTTP response still carries the change either way.
|
||||||
// with both the response body below and any re-fetch instead of compounding with them
|
|
||||||
// — see the frame rule above pushBalanceUpdate. Best-effort.
|
|
||||||
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
||||||
await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
|
await pushBalanceUpdate(c, id, currencyType as number, newBalance)
|
||||||
|
|
||||||
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
||||||
// negated price), not the resulting balance (the client reads its new total from
|
// negated price), not the resulting balance (the client reads its new total from
|
||||||
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
|
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
|
||||||
// entry is the gift-drop the client RECEIVED — the rolled item for a query box, the
|
// entry is the gift-drop the client received — it carries no FriendlyName or
|
||||||
// bought drop otherwise — and it carries no FriendlyName or consumable count (the
|
// consumable count (the count is a getUnlocked concept; each box is one instance).
|
||||||
// count is a getUnlocked concept; each box is one instance).
|
|
||||||
return c.json({
|
return c.json({
|
||||||
BalanceUpdates: [
|
BalanceUpdates: [
|
||||||
{
|
{
|
||||||
@@ -1952,22 +1136,22 @@ const app = new Hono<App>({ strict: false })
|
|||||||
{
|
{
|
||||||
Id: giftId,
|
Id: giftId,
|
||||||
FromPlayerId: fromPlayerId,
|
FromPlayerId: fromPlayerId,
|
||||||
ConsumableItemDesc: granted.ConsumableItemDesc,
|
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
|
||||||
AvatarItemDesc: granted.AvatarItemDesc,
|
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
|
||||||
AvatarItemType: granted.AvatarItemType ?? 0,
|
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
|
||||||
EquipmentPrefabName: granted.EquipmentPrefabName,
|
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||||||
EquipmentModificationGuid: granted.EquipmentModificationGuid,
|
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||||||
CurrencyType: granted.CurrencyType,
|
CurrencyType: item.GiftDrop.CurrencyType,
|
||||||
Currency: granted.Currency,
|
Currency: item.GiftDrop.Currency,
|
||||||
Xp: granted.Xp ?? 0,
|
Xp: 0,
|
||||||
Level: 0,
|
Level: 0,
|
||||||
Platform: -1,
|
Platform: -1,
|
||||||
PlatformsToSpawnOn: -1,
|
PlatformsToSpawnOn: -1,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
GiftContext: Number.isInteger(gift?.GiftContext)
|
GiftContext: Number.isInteger(gift?.GiftContext)
|
||||||
? (gift?.GiftContext as number)
|
? (gift?.GiftContext as number)
|
||||||
: granted.Context,
|
: item.GiftDrop.Context,
|
||||||
GiftRarity: granted.Rarity,
|
GiftRarity: item.GiftDrop.Rarity,
|
||||||
Message: message,
|
Message: message,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1980,165 +1164,6 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
|
|
||||||
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
|
|
||||||
//
|
|
||||||
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
|
|
||||||
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
|
|
||||||
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
|
|
||||||
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
|
|
||||||
// against the price the client rendered first, so a stale or tampered client can't buy
|
|
||||||
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
|
|
||||||
// the same "Insufficient balance" buyItem answers with.
|
|
||||||
//
|
|
||||||
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
|
||||||
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
|
||||||
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
|
|
||||||
// bumped: that column lives on the `invention` table the `api` worker owns, and this
|
|
||||||
// worker only reads it.
|
|
||||||
.get(
|
|
||||||
'/api/storefronts/v2/buyInvention',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Storefront'],
|
|
||||||
summary: 'Buy an invention',
|
|
||||||
description: [
|
|
||||||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
|
||||||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
|
||||||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
|
||||||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
|
||||||
'balance. When tokens moved, both players get a socket push carrying their RESULTING',
|
|
||||||
'total — the buyer a StorefrontBalancePurchase, the CREATOR a StorefrontBalanceUpdate —',
|
|
||||||
'which sets the account-wide bucket their client shows, agreeing with this body.',
|
|
||||||
'A GET because that is how the client sends it.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'inventionId',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
description: 'Invention id; missing or non-numeric is 400',
|
|
||||||
schema: { type: 'integer' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'requestedPrice',
|
|
||||||
in: 'query',
|
|
||||||
required: false,
|
|
||||||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
|
||||||
schema: { type: 'integer' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
|
||||||
400: json(
|
|
||||||
ErrorResponse,
|
|
||||||
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
|
|
||||||
404: json(ErrorResponse, 'No such invention'),
|
|
||||||
409: json(ErrorResponse, 'Already owned, or the price has changed'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
|
||||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
|
||||||
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
|
|
||||||
// a priced one then fails the confirmation below rather than selling for nothing.
|
|
||||||
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
|
||||||
|
|
||||||
const invention = await getInventionById(c.env.DB, inventionId)
|
|
||||||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
|
||||||
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
|
||||||
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
|
||||||
if (invention.CreatorPlayerId === id) {
|
|
||||||
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
|
||||||
}
|
|
||||||
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
|
||||||
return c.json({ error: 'Already owned' }, 409)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The price the client rendered must still be the stored one: a mismatch is a stale
|
|
||||||
// catalog or a tampered request, never a sale.
|
|
||||||
if (invention.Price !== requestedPrice) {
|
|
||||||
return c.json({ error: 'Price has changed' }, 409)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
|
||||||
// Inventions are priced in RecCenterTokens only — the store shows no other currency
|
|
||||||
// for them, and `Price` carries no currency of its own to pick a different one from.
|
|
||||||
const price = invention.Price
|
|
||||||
if (price > 0) {
|
|
||||||
// Debit the buyer atomically; false means they couldn't afford it and nothing
|
|
||||||
// changed, so no ownership is recorded and the creator is not paid.
|
|
||||||
const paid = await spendCurrency(
|
|
||||||
c.env.DB,
|
|
||||||
id,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
price,
|
|
||||||
startingTokens
|
|
||||||
)
|
|
||||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant before paying out: these are three separate D1 writes with no transaction
|
|
||||||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
|
||||||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
|
||||||
// is not.
|
|
||||||
await grantInvention(c.env.DB, id, inventionId)
|
|
||||||
|
|
||||||
if (price > 0) {
|
|
||||||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
|
||||||
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
|
|
||||||
// creator who had never touched their balance would otherwise have the row created
|
|
||||||
// here and lose their starting tokens forever.
|
|
||||||
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
|
|
||||||
const creatorBalance = await creditCurrency(
|
|
||||||
c.env.DB,
|
|
||||||
invention.CreatorPlayerId,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
price,
|
|
||||||
startingTokens
|
|
||||||
)
|
|
||||||
// The creator is a different, probably-online player with no response to read:
|
|
||||||
// push the sale so it lands on their shown balance without a re-fetch. The frame
|
|
||||||
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
|
|
||||||
// sending the payout would set their whole balance to it. A plain update rather
|
|
||||||
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
|
|
||||||
await pushBalanceUpdate(
|
|
||||||
c,
|
|
||||||
invention.CreatorPlayerId,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
creatorBalance
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlike buyItem — whose `Balance` is the change applied — the reference server
|
|
||||||
// answers this one with the RESULTING total (a first read seeds the buyer's starting
|
|
||||||
// grant, as everywhere else). The buyer's frame carries that same total, so the body
|
|
||||||
// and the push land the client on one number.
|
|
||||||
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
|
||||||
// A free invention moved nothing, so there is no purchase to report.
|
|
||||||
if (price > 0) {
|
|
||||||
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
|
||||||
}
|
|
||||||
return c.json({
|
|
||||||
BalanceUpdateResponse: {
|
|
||||||
Balance: balance,
|
|
||||||
BalanceType: ALL_PLATFORMS,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
|
||||||
},
|
|
||||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
|
||||||
// save/read endpoints serve — the client re-renders the invention from it.
|
|
||||||
InventionResponse: toSaveResult(invention),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||||
// placeholder banner with no purchasable items until real promo data exists.
|
// placeholder banner with no purchasable items until real promo data exists.
|
||||||
.get(
|
.get(
|
||||||
@@ -2147,104 +1172,51 @@ const app = new Hono<App>({ strict: false })
|
|||||||
(c) => c.json(adCarouselItems)
|
(c) => c.json(adCarouselItems)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
|
// Current weekly challenge. Served from the bundled static JSON until
|
||||||
// is documented in the README) but each challenge's `Complete` is per-player, so the
|
// per-rotation challenge data is wired up.
|
||||||
// caller's rows from `challenge_status` are stamped over the static `false`s.
|
|
||||||
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged
|
|
||||||
// rather than 401, since the rotation is public information and a 404/401 on this
|
|
||||||
// route can stall the client's load orchestration.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/challenge/v2/getCurrent',
|
'/api/challenge/v2/getCurrent',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Current weekly challenge',
|
summary: 'Current weekly challenge',
|
||||||
description: [
|
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
|
||||||
'The bundled static rotation, with each challenge’s `Complete` stamped from the',
|
|
||||||
'caller’s progress rows. Auth is optional — unauthenticated callers get the static',
|
|
||||||
'catalog with every `Complete` false.',
|
|
||||||
].join(' '),
|
|
||||||
security: OPTIONAL_AUTHED,
|
|
||||||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
(c) => c.json(weeklyChallenge)
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return c.json(weeklyChallenge)
|
|
||||||
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId)
|
|
||||||
if (complete.size === 0) return c.json(weeklyChallenge)
|
|
||||||
// Rebuild rather than mutate: the static import is module state shared by every
|
|
||||||
// request this isolate serves, so stamping it in place would leak one player's
|
|
||||||
// completions to the next caller.
|
|
||||||
return c.json({
|
|
||||||
...weeklyChallenge,
|
|
||||||
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
|
|
||||||
...challenge,
|
|
||||||
Complete: complete.has(challenge.ChallengeId),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
|
// Report progress on a weekly challenge. The client evaluates the challenge's rule
|
||||||
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
|
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
|
||||||
// `Config`, and whether it now considers the challenge `Complete`. Only the
|
// whether it now considers the challenge `Complete`. Stubbed: with no challenge-
|
||||||
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
|
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
||||||
// own definition plus the client's running count, so storing it would duplicate
|
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
||||||
// static data. Echoes the identifying fields back with the completion the row now
|
// so the client gets a well-formed, non-null body to deserialize.
|
||||||
// holds — which is not always what was posted, since completion latches within a
|
|
||||||
// rotation.
|
|
||||||
.post(
|
.post(
|
||||||
'/api/challenge/v2/updateProgress',
|
'/api/challenge/v2/updateProgress',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Report weekly-challenge progress',
|
summary: 'Report weekly-challenge progress',
|
||||||
description: [
|
description: [
|
||||||
'Persists the reported completion into `challenge_status`, keyed by account +',
|
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
|
||||||
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
|
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
|
||||||
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
|
'client gets a well-formed body.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
|
||||||
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
||||||
responses: {
|
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
|
||||||
200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
const body = await c.req
|
const body = await c.req
|
||||||
.json<{
|
.json<{
|
||||||
ChallengeMapId?: string | number
|
ChallengeMapId?: string | number
|
||||||
ChallengeId?: string | number
|
ChallengeId?: string | number
|
||||||
Config?: string
|
Config?: string
|
||||||
Complete?: string | boolean
|
|
||||||
}>()
|
}>()
|
||||||
.catch(() => ({}) as Record<string, never>)
|
.catch(() => ({}) as Record<string, never>)
|
||||||
const challengeMapId = Number(body.ChallengeMapId) || 0
|
|
||||||
const challengeId = Number(body.ChallengeId) || 0
|
|
||||||
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
|
|
||||||
const complete =
|
|
||||||
challengeId === 0
|
|
||||||
? parseBool(body.Complete)
|
|
||||||
: await recordChallengeProgress(c.env.DB, id, {
|
|
||||||
challengeMapId,
|
|
||||||
challengeId,
|
|
||||||
complete: parseBool(body.Complete),
|
|
||||||
})
|
|
||||||
// This report may have been the last one of the set. Only a completing report on
|
|
||||||
// the LIVE rotation can be — an old rotation's set can no longer be finished, and
|
|
||||||
// an unfinished challenge means the set isn't either, so neither is worth a read.
|
|
||||||
// The response is unchanged whether or not a gift was won: the client learns about
|
|
||||||
// the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
|
|
||||||
// inventing response shape the client never sent us.
|
|
||||||
if (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) {
|
|
||||||
await awardChallengeGift(c, id)
|
|
||||||
}
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ChallengeMapId: challengeMapId,
|
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
||||||
ChallengeId: challengeId,
|
ChallengeId: Number(body.ChallengeId) || 0,
|
||||||
Config: typeof body.Config === 'string' ? body.Config : '',
|
Config: typeof body.Config === 'string' ? body.Config : '',
|
||||||
Complete: complete,
|
Complete: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2254,83 +1226,13 @@ const app = new Hono<App>({ strict: false })
|
|||||||
c.json([])
|
c.json([])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
|
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
||||||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
||||||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
||||||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
|
||||||
// here, from `reward_status`: one claim per type per activity per hour, atomically.
|
|
||||||
//
|
|
||||||
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
|
|
||||||
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
|
||||||
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
|
|
||||||
// nothing and pays nothing.
|
|
||||||
//
|
|
||||||
// The response stays `[]` either way. It is what the client already accepts, and the box
|
|
||||||
// is how a reward is delivered, so there is no captured shape to put the payout in — the
|
|
||||||
// reference answers its own (different, selection-based) flow with a success envelope,
|
|
||||||
// not a list of rewards.
|
|
||||||
//
|
|
||||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown 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. An ask that
|
|
||||||
// sends no context keys on `''`.
|
|
||||||
.post(
|
.post(
|
||||||
'/api/gamerewards/v1/request',
|
'/api/gamerewards/v1/request',
|
||||||
describeRoute({
|
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
|
||||||
tags: ['Econ'],
|
(c) => c.json([])
|
||||||
summary: 'Request a game reward',
|
|
||||||
description: [
|
|
||||||
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
|
|
||||||
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
|
|
||||||
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
|
|
||||||
'the empty context. The reward rides in a gift box, so a claim and a rejected',
|
|
||||||
'(on-cooldown) ask both answer `[]`.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
|
||||||
responses: {
|
|
||||||
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
|
|
||||||
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 rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
|
|
||||||
// No type, nothing to gate: don't write a row keyed on an empty string.
|
|
||||||
if (rewardType === '') return c.json([])
|
|
||||||
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
|
|
||||||
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
|
|
||||||
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
|
||||||
if (claimed === null) return c.json([])
|
|
||||||
const message =
|
|
||||||
typeof body.Message === 'string' && body.Message !== ''
|
|
||||||
? body.Message
|
|
||||||
: DEFAULT_GAME_REWARD_MESSAGE
|
|
||||||
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
|
|
||||||
// A failure here must not leave a box promising XP that was never credited.
|
|
||||||
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
|
||||||
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
|
|
||||||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
|
||||||
// Every grant moves the bar, whether or not it crossed a level.
|
|
||||||
await pushProgressionUpdate(c, id, progression)
|
|
||||||
// …and every level crossed is worth a box of its own tier.
|
|
||||||
await grantLevelUpGifts(c, id, { progression, levelsGained })
|
|
||||||
logger.info('game reward claimed', {
|
|
||||||
accountId: id,
|
|
||||||
rewardType,
|
|
||||||
giftContext,
|
|
||||||
grantCount: claimed,
|
|
||||||
message,
|
|
||||||
xp: GAME_REWARD_XP,
|
|
||||||
level: progression.Level,
|
|
||||||
levelsGained,
|
|
||||||
levelXp: progression.XP,
|
|
||||||
giftId: granted.id,
|
|
||||||
})
|
|
||||||
return c.json([])
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The player's room keys. Returns "[]".
|
// The player's room keys. Returns "[]".
|
||||||
@@ -2342,42 +1244,16 @@ const app = new Hono<App>({ strict: false })
|
|||||||
c.json([])
|
c.json([])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
|
// Subscription lookup. Returns both fields null with no auth.
|
||||||
// buy one from, so the `developer` role stands in for a paid subscription: a developer
|
|
||||||
// reports an active Gold year, everyone else reports none. Nothing is stored — see
|
|
||||||
// `developerSubscription`.
|
|
||||||
//
|
|
||||||
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
|
|
||||||
// 401: the client posts this while loading, so an error here can stall its load
|
|
||||||
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
|
|
||||||
// caller anyway. The role is read from the token's `role` claim, never from the body.
|
|
||||||
.post(
|
.post(
|
||||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Subscription lookup',
|
summary: 'Subscription lookup',
|
||||||
description: [
|
description: 'No subscriptions yet — both fields null. No auth.',
|
||||||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
|
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
||||||
'operator-granted `developer` role stands in for one: a developer’s token reports an',
|
|
||||||
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
|
|
||||||
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
|
|
||||||
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
|
|
||||||
'the role IS the subscription, so revoking it revokes this.',
|
|
||||||
].join(' '),
|
|
||||||
responses: {
|
|
||||||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||||
const roles = await authedRoles(c)
|
|
||||||
if (!roles?.includes(DEVELOPER_ROLE)) return c.json({})
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return c.json({})
|
|
||||||
return c.json({
|
|
||||||
Subscription: developerSubscription(id),
|
|
||||||
PlatformAccountSubscribedPlayerId: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The generated spec. Documentation only — no request is validated against it (see
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
|
|||||||
+8
-122
@@ -50,13 +50,6 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
|
|||||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||||
export const AUTHED = [{ bearerAuth: [] }]
|
export const AUTHED = [{ bearerAuth: [] }]
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional bearer JWT — the empty requirement object makes "no credentials" a valid
|
|
||||||
* alternative. For routes that serve public data but personalise it for a known caller
|
|
||||||
* (the weekly challenge's per-player `Complete`) instead of 401ing.
|
|
||||||
*/
|
|
||||||
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
|
|
||||||
|
|
||||||
// ---- Loose shapes ----------------------------------------------------------
|
// ---- Loose shapes ----------------------------------------------------------
|
||||||
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
||||||
// empty-list stubs. Modelling every catalog field adds noise without value, so these
|
// empty-list stubs. Modelling every catalog field adds noise without value, so these
|
||||||
@@ -105,65 +98,18 @@ export const CustomAvatarItemsResponse = z.object({
|
|||||||
TotalResults: z.int(),
|
TotalResults: z.int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||||
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
export const SubscriptionResponse = z.object({
|
||||||
* so this is the complimentary subscription a `developer` account reports — see
|
subscription: z.null(),
|
||||||
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
|
platformAccountSubscribedPlayerId: z.null(),
|
||||||
*/
|
|
||||||
export const SubscriptionDto = z.object({
|
|
||||||
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
|
||||||
RecNetPlayerId: z.int().describe('The subscribed player: the caller'),
|
|
||||||
PlatformType: z
|
|
||||||
.int()
|
|
||||||
.nullable()
|
|
||||||
.describe(
|
|
||||||
'Which store sold it: -1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, ' +
|
|
||||||
'5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico. -1 here — no store did'
|
|
||||||
),
|
|
||||||
PlatformId: z.string().describe('Empty — no store account behind it'),
|
|
||||||
PlatformPurchaseId: z.string().describe('Empty — nothing was purchased'),
|
|
||||||
Level: z.int().describe('0 Gold, 1 Platinum'),
|
|
||||||
Period: z.int().describe('0 Month, 1 Year, 2 ThreeMonth, 3 SixMonth'),
|
|
||||||
ExpirationDate: z.string().describe('ISO 8601 UTC; a year out, recomputed per call'),
|
|
||||||
IsAutoRenewing: z.boolean(),
|
|
||||||
CreatedAt: z.string(),
|
|
||||||
ModifiedAt: z.string(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}`
|
|
||||||
* when they have none (which is everyone without the `developer` role). `{}` rather than a
|
|
||||||
* `Subscription: null` envelope: an absent key is how the client reads "not subscribed".
|
|
||||||
*/
|
|
||||||
export const SubscriptionResponse = z.union([
|
|
||||||
z.object({
|
|
||||||
Subscription: SubscriptionDto,
|
|
||||||
PlatformAccountSubscribedPlayerId: z
|
|
||||||
.null()
|
|
||||||
.describe('The platform account holding the sub, when it is shared. Never set here'),
|
|
||||||
}),
|
|
||||||
z.object({}).describe('`{}` — no subscription'),
|
|
||||||
])
|
|
||||||
|
|
||||||
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||||
export const ChallengeProgressResponse = z.object({
|
export const ChallengeProgressResponse = z.object({
|
||||||
ChallengeMapId: z.int(),
|
ChallengeMapId: z.int(),
|
||||||
ChallengeId: z.int(),
|
ChallengeId: z.int(),
|
||||||
Config: z.string().describe('Echoed back verbatim; not stored'),
|
Config: z.string(),
|
||||||
Complete: z
|
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
||||||
.boolean()
|
|
||||||
.describe('The STORED completion — latches true within a rotation, so it may differ'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /api/objectives/v1/updateobjective` — the group the objective belongs to, after
|
|
||||||
* the update. camelCase, unlike the PascalCase body the client posts and the PascalCase
|
|
||||||
* `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group.
|
|
||||||
*/
|
|
||||||
export const UpdateObjectiveResponse = z.object({
|
|
||||||
group: z.int().describe('Echoed back from the request'),
|
|
||||||
isCompleted: z.boolean().describe('Always false — no objectives store yet'),
|
|
||||||
clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,34 +130,7 @@ export const BuyItemResponse = z.object({
|
|||||||
BalanceType: z.int().describe('-2 = account-wide'),
|
BalanceType: z.int().describe('-2 = account-wide'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
||||||
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
|
|
||||||
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
|
|
||||||
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
|
|
||||||
* and the invention envelope the invention endpoints already serve.
|
|
||||||
*/
|
|
||||||
export const BuyInventionResponse = z.object({
|
|
||||||
BalanceUpdateResponse: z.object({
|
|
||||||
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
|
|
||||||
BalanceType: z.int().describe('-2 = account-wide'),
|
|
||||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
|
||||||
BalanceUpdates: z.array(
|
|
||||||
z.object({
|
|
||||||
UpdateResponse: z.int(),
|
|
||||||
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
InventionResponse: z
|
|
||||||
.object({
|
|
||||||
Status: z.int(),
|
|
||||||
Invention: JsonObject,
|
|
||||||
InventionVersion: JsonObject,
|
|
||||||
})
|
|
||||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
|
||||||
export const ErrorResponse = z.object({ error: z.string() })
|
export const ErrorResponse = z.object({ error: z.string() })
|
||||||
|
|
||||||
// ---- Request schemas -------------------------------------------------------
|
// ---- Request schemas -------------------------------------------------------
|
||||||
@@ -249,40 +168,7 @@ export const ConsumeGiftRequest = z.object({
|
|||||||
export const ChallengeProgressRequest = z.object({
|
export const ChallengeProgressRequest = z.object({
|
||||||
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
||||||
ChallengeId: z.union([z.string(), z.int()]).optional(),
|
ChallengeId: z.union([z.string(), z.int()]).optional(),
|
||||||
Config: z
|
Config: z.string().optional().describe('The client-evaluated rule tree'),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('The client-evaluated rule tree, with its running count in `cc`; not stored'),
|
|
||||||
Complete: z
|
|
||||||
.union([z.string(), z.boolean()])
|
|
||||||
.optional()
|
|
||||||
.describe('The client’s verdict — sent as .NET’s `"True"`/`"False"`'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** `POST /api/gamerewards/v1/request` form body. */
|
|
||||||
export const GameRewardRequest = z.object({
|
|
||||||
rewardType: z
|
|
||||||
.string()
|
|
||||||
.describe('The reward being asked for, e.g. `FirstActivityOfDay`, `PostGameActivity`'),
|
|
||||||
Message: z.string().optional().describe('The message to show for the reward'),
|
|
||||||
giftContext: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the
|
|
||||||
* client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the
|
|
||||||
* progress it wants persisted.
|
|
||||||
*/
|
|
||||||
export const UpdateObjectiveRequest = z.object({
|
|
||||||
Index: z.int().describe('Which objective within the group'),
|
|
||||||
Group: z.int().describe('Which objective group'),
|
|
||||||
Progress: z.int().optional(),
|
|
||||||
VisualProgress: z.int().optional().describe('What the client animates towards'),
|
|
||||||
IsCompleted: z.boolean().optional(),
|
|
||||||
HasClaimedReward: z.boolean().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
|
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Game-reward eligibility on the shared `recflare` D1 database — one row per (account,
|
|
||||||
* reward type, gift context), written by `POST /api/gamerewards/v1/request`.
|
|
||||||
*
|
|
||||||
* The client asks for a reward whenever it thinks one is due ("First Game of the Day"
|
|
||||||
* after an activity, "Activity completed!" after a match), so the server, not the client,
|
|
||||||
* has to decide whether one is actually owed: this table is what makes a second ask for
|
|
||||||
* the same reward a no-op instead of a second payout.
|
|
||||||
*
|
|
||||||
* The `giftContext` the client sends (the activity, e.g. `Soccer`) is PART of the key: a
|
|
||||||
* cooldown is per (type, activity), so the same activity can't pay twice inside the hour
|
|
||||||
* but a different one can. An ask with no context keys on `''` — see `claimReward` for why
|
|
||||||
* that isn't NULL.
|
|
||||||
*
|
|
||||||
* The `econ` worker owns this table and its migrations
|
|
||||||
* (apps/econ/migrations/0010_reward_status.sql, widened by
|
|
||||||
* apps/econ/migrations/0013_reward_status_gift_context.sql).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Schema DDL (mirror of the migrations above) — also builds the table in tests. */
|
|
||||||
export const REWARD_STATUS_SCHEMA_DDL: string[] = [
|
|
||||||
`CREATE TABLE IF NOT EXISTS reward_status (
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
reward_type TEXT NOT NULL,
|
|
||||||
gift_context TEXT NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL,
|
|
||||||
grant_count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (account_id, reward_type, gift_context)
|
|
||||||
)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a player must wait between rewards of the same type in the same activity. One
|
|
||||||
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
|
|
||||||
* Per-type windows would be a map keyed by reward type; there's one window until a reward
|
|
||||||
* type needs its own.
|
|
||||||
*/
|
|
||||||
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim a reward if the player is due one, returning how many of that type they have now
|
|
||||||
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
|
|
||||||
* claimed.
|
|
||||||
*
|
|
||||||
* `giftContext` defaults to `''` rather than NULL for the contextless ask: 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.
|
|
||||||
*
|
|
||||||
* The check and the claim are ONE statement. The client fires these off after a match, so
|
|
||||||
* two requests can land together; a read-then-write would let both see the same stale
|
|
||||||
* `granted_at` and pay out twice. `ON CONFLICT … DO UPDATE … WHERE` gives us the atomic
|
|
||||||
* version: when the cooldown hasn't elapsed the update is skipped, no row is returned, and
|
|
||||||
* the stored `granted_at` is left alone (so a rejected claim doesn't extend the cooldown).
|
|
||||||
*
|
|
||||||
* `granted_at` holds `toISOString()` output — fixed-width UTC, so the lexical `<=` against
|
|
||||||
* the cutoff is a chronological comparison with no date parsing in SQL.
|
|
||||||
*/
|
|
||||||
export async function claimReward(
|
|
||||||
db: D1Database,
|
|
||||||
accountId: number,
|
|
||||||
rewardType: string,
|
|
||||||
giftContext = '',
|
|
||||||
now: Date = new Date()
|
|
||||||
): Promise<number | null> {
|
|
||||||
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, 1)
|
|
||||||
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
|
|
||||||
granted_at = excluded.granted_at,
|
|
||||||
grant_count = reward_status.grant_count + 1
|
|
||||||
WHERE reward_status.granted_at <= ?5
|
|
||||||
RETURNING grant_count`
|
|
||||||
)
|
|
||||||
.bind(accountId, rewardType, giftContext, now.toISOString(), cutoff)
|
|
||||||
.first<{ grant_count: number }>()
|
|
||||||
return row?.grant_count ?? null
|
|
||||||
}
|
|
||||||
@@ -4,23 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../econ.app'
|
import '../../econ.app'
|
||||||
|
|
||||||
import {
|
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||||
getOwnedInventionIds,
|
|
||||||
getProgression,
|
|
||||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
|
||||||
PROGRESSION_SCHEMA_DDL,
|
|
||||||
RECEIVED_GIFT_SCHEMA_DDL,
|
|
||||||
} from '@repo/domain'
|
|
||||||
|
|
||||||
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
|
||||||
// is built here too (see the same cross-worker import in econ.app.ts).
|
|
||||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
|
||||||
// The notification-type ids the hub carries, from the worker that owns them — asserting
|
|
||||||
// against the enum rather than a copied number is what keeps these frames honest.
|
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
|
||||||
// The live weekly rotation, so the challenge tests exercise whatever it currently holds
|
|
||||||
// instead of hard-coded ids from a rotation that has since been replaced.
|
|
||||||
import weeklyChallenge from '../../../static/weekly-challenge.json'
|
|
||||||
import { SCHEMA_DDL } from '../../avatar-db'
|
import { SCHEMA_DDL } from '../../avatar-db'
|
||||||
import {
|
import {
|
||||||
BALANCE_SCHEMA_DDL,
|
BALANCE_SCHEMA_DDL,
|
||||||
@@ -29,12 +14,10 @@ import {
|
|||||||
getBalance,
|
getBalance,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} from '../../balance-db'
|
||||||
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
|
|
||||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||||
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
|
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||||
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -44,9 +27,6 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
const ORIGIN = 'https://example.com'
|
||||||
|
|
||||||
/** The first challenge of the live rotation — the progress tests report against it. */
|
|
||||||
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
|
|
||||||
|
|
||||||
// Build the accounts table and seed the test player (the default token's sub, 42)
|
// Build the accounts table and seed the test player (the default token's sub, 42)
|
||||||
// so avatar reads/writes have a row to attach to.
|
// so avatar reads/writes have a row to attach to.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -55,84 +35,15 @@ beforeAll(async () => {
|
|||||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
for (const stmt of CHALLENGE_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||||
.run()
|
.run()
|
||||||
for (const invention of SEEDED_INVENTIONS) {
|
|
||||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
|
||||||
.bind(JSON.stringify(invention))
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
|
|
||||||
* reads are meaningful — id, creator, published flag and price — but the record is
|
|
||||||
* shaped like a real stored `RRInvention` so the response envelope is realistic.
|
|
||||||
*/
|
|
||||||
function invention(
|
|
||||||
inventionId: number,
|
|
||||||
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
InventionId: inventionId,
|
|
||||||
ReplicationId: `replication-${inventionId}`,
|
|
||||||
CreatorPlayerId: 999,
|
|
||||||
Name: `Invention ${inventionId}`,
|
|
||||||
Description: 'A test invention',
|
|
||||||
ImageName: '',
|
|
||||||
CurrentVersionNumber: 1,
|
|
||||||
CurrentVersion: {
|
|
||||||
InventionId: inventionId,
|
|
||||||
ReplicationId: `version-${inventionId}`,
|
|
||||||
VersionNumber: 1,
|
|
||||||
BlobName: `invention-${inventionId}.inv`,
|
|
||||||
BlobHash: null,
|
|
||||||
InstantiationCost: 0,
|
|
||||||
LightsCost: 0,
|
|
||||||
ChipsCost: 0,
|
|
||||||
CloudVariablesCost: 0,
|
|
||||||
AICost: 0,
|
|
||||||
},
|
|
||||||
Accessibility: 0,
|
|
||||||
IsPublished: true,
|
|
||||||
IsFeatured: false,
|
|
||||||
ModifiedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
CreatedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
CreationRoomId: 0,
|
|
||||||
NumPlayersHaveUsedInRoom: 0,
|
|
||||||
NumDownloads: 0,
|
|
||||||
CheerCount: 0,
|
|
||||||
CreatorPermission: 100,
|
|
||||||
GeneralPermission: 20,
|
|
||||||
IsAGInvention: false,
|
|
||||||
IsCertifiedInvention: false,
|
|
||||||
Price: 0,
|
|
||||||
AllowTrial: true,
|
|
||||||
HideFromPlayer: false,
|
|
||||||
ReferencedInventions: [],
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEEDED_INVENTIONS = [
|
|
||||||
invention(8), // free, published, someone else's — the sellable one
|
|
||||||
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
|
|
||||||
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
|
|
||||||
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
||||||
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
||||||
@@ -162,17 +73,10 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||||
* A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an
|
|
||||||
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
|
|
||||||
* is no claim at all, which reads as no roles.
|
|
||||||
*/
|
|
||||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const claims =
|
|
||||||
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
|
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
JSON.stringify(claims)
|
JSON.stringify({ sub, exp: now + 3600 })
|
||||||
)}`
|
)}`
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -366,37 +270,6 @@ describe('econ endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
Index: 2,
|
|
||||||
Group: 3,
|
|
||||||
Progress: 1,
|
|
||||||
VisualProgress: 0,
|
|
||||||
IsCompleted: true,
|
|
||||||
HasClaimedReward: false,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string }
|
|
||||||
expect(body.group).toBe(3)
|
|
||||||
expect(body.isCompleted).toBe(false)
|
|
||||||
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: 'not json',
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { group: number; isCompleted: boolean }
|
|
||||||
expect(body.group).toBe(0)
|
|
||||||
expect(body.isCompleted).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
|
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
|
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
|
||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
@@ -756,7 +629,6 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
|
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
|
||||||
// Account 20: fresh, so its first balance touch grants the 10000 default.
|
// Account 20: fresh, so its first balance touch grants the 10000 default.
|
||||||
await drainFrames()
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
|
||||||
@@ -784,28 +656,6 @@ describe('econ endpoints', () => {
|
|||||||
expect(gift.AvatarItemDesc).not.toBe('')
|
expect(gift.AvatarItemDesc).not.toBe('')
|
||||||
expect(gift.Id).toBeGreaterThan(0)
|
expect(gift.Id).toBeGreaterThan(0)
|
||||||
|
|
||||||
// A purchase pushes StorefrontBalancePurchase, which SETS one (CurrencyType, Platform)
|
|
||||||
// bucket to an absolute value: `Balance` is the resulting total (10000 - 450) and `Delta`
|
|
||||||
// is display-only. The bucket key is `Platform`, and it MUST be the -2 the balance
|
|
||||||
// endpoint reports below — the client sums its buckets, so a frame naming any other
|
|
||||||
// platform (or spelling the key `BalanceType`, which the client's decoder drops) invents
|
|
||||||
// a second balance beside the real one. That is what showed a live player 34,100 tokens
|
|
||||||
// after spending 900 of 17,500, then 33,200 once the body's -900 landed.
|
|
||||||
expect(await drainFrames()).toEqual([
|
|
||||||
{
|
|
||||||
accountId: 20,
|
|
||||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
|
||||||
payload: {
|
|
||||||
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
|
|
||||||
BalanceAddType: 1400,
|
|
||||||
Delta: -450,
|
|
||||||
Balance: 9550,
|
|
||||||
Platform: -2,
|
|
||||||
CurrencyType: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
||||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
headers: await bearer('20'),
|
headers: await bearer('20'),
|
||||||
@@ -1070,162 +920,6 @@ describe('econ endpoints', () => {
|
|||||||
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
|
|
||||||
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
|
|
||||||
* best-effort — the worker logs and swallows a hub failure — so this is the only way a
|
|
||||||
* test sees what was actually pushed.
|
|
||||||
*/
|
|
||||||
const drainFrames = async (): Promise<
|
|
||||||
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
|
|
||||||
> =>
|
|
||||||
(
|
|
||||||
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
|
|
||||||
drainFrames(): Promise<
|
|
||||||
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
|
|
||||||
>
|
|
||||||
}
|
|
||||||
).drainFrames()
|
|
||||||
|
|
||||||
// buyInvention is a GET with query params — that is how the client sends it.
|
|
||||||
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
|
|
||||||
exports.default.fetch(
|
|
||||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
|
|
||||||
{ headers: await bearer(sub) }
|
|
||||||
)
|
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
|
|
||||||
const res = await exports.default.fetch(
|
|
||||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
|
|
||||||
const res = await buyInvention('50', 8)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
BalanceUpdateResponse: {
|
|
||||||
Balance: number
|
|
||||||
BalanceType: number
|
|
||||||
CurrencyType: number
|
|
||||||
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
|
|
||||||
}
|
|
||||||
InventionResponse: {
|
|
||||||
Status: number
|
|
||||||
Invention: { InventionId: number; Name: string }
|
|
||||||
InventionVersion: { InventionId: number; VersionNumber: number }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
|
|
||||||
// grant — not a change, unlike buyItem's.
|
|
||||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
|
|
||||||
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
|
|
||||||
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
|
|
||||||
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
|
|
||||||
expect(body.InventionResponse.Status).toBe(0)
|
|
||||||
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
|
|
||||||
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
|
|
||||||
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
|
||||||
|
|
||||||
// Owning an invention is boolean: buying it again is a conflict, not a second row.
|
|
||||||
expect((await buyInvention('50', 8)).status).toBe(409)
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => {
|
|
||||||
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
|
|
||||||
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
|
|
||||||
await drainFrames()
|
|
||||||
const res = await buyInvention('51', 9, 250)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
|
|
||||||
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
|
|
||||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
|
|
||||||
expect(
|
|
||||||
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
|
||||||
).toBe(DEFAULT_STARTING_TOKENS - 250)
|
|
||||||
// The creator had never touched their balance: they keep their starting grant AND get
|
|
||||||
// paid, rather than the payout standing in for the grant.
|
|
||||||
expect(
|
|
||||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
|
||||||
).toBe(DEFAULT_STARTING_TOKENS + 250)
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
|
|
||||||
|
|
||||||
// Both sides get a frame carrying their RESULTING TOTAL, into the same -2 bucket the
|
|
||||||
// balance endpoint reports — a StorefrontBalance* push SETS that bucket, so sending the
|
|
||||||
// change (250 / -250) would set their whole balance to it. The creator sold, so theirs is
|
|
||||||
// a plain update; the buyer bought, so theirs is a purchase frame with a display-only
|
|
||||||
// `Delta`. Note the key is `Platform`: the client renames `BalanceType` away and drops it.
|
|
||||||
expect(await drainFrames()).toEqual([
|
|
||||||
{
|
|
||||||
accountId: 999,
|
|
||||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
|
||||||
payload: {
|
|
||||||
Balance: DEFAULT_STARTING_TOKENS + 250,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
Platform: -2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: 51,
|
|
||||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
|
||||||
payload: {
|
|
||||||
BalanceAddType: 1400,
|
|
||||||
Delta: -250,
|
|
||||||
Balance: DEFAULT_STARTING_TOKENS - 250,
|
|
||||||
Platform: -2,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
|
|
||||||
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
|
|
||||||
expect((await buyInvention('53', 9, 0)).status).toBe(409)
|
|
||||||
|
|
||||||
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
|
|
||||||
await spendCurrency(
|
|
||||||
env.DB,
|
|
||||||
54,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
DEFAULT_STARTING_TOKENS,
|
|
||||||
DEFAULT_STARTING_TOKENS
|
|
||||||
)
|
|
||||||
const creatorBefore = await getBalance(
|
|
||||||
env.DB,
|
|
||||||
999,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
DEFAULT_STARTING_TOKENS
|
|
||||||
)
|
|
||||||
expect((await buyInvention('54', 9, 250)).status).toBe(400)
|
|
||||||
expect(
|
|
||||||
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
|
||||||
).toBe(0)
|
|
||||||
expect(
|
|
||||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
|
||||||
).toBe(creatorBefore)
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
|
|
||||||
// Unpublished — a draft is not on sale, free or not.
|
|
||||||
expect((await buyInvention('52', 10)).status).toBe(403)
|
|
||||||
// Account 60 created invention 11; a creator already owns it.
|
|
||||||
expect((await buyInvention('60', 11)).status).toBe(400)
|
|
||||||
expect((await buyInvention('52', 9999)).status).toBe(404)
|
|
||||||
// Missing/non-numeric inventionId.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
|
|
||||||
headers: await bearer('52'),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||||
// Buy an item for account 24, then consume the box the way the client does: on the
|
// Buy an item for account 24, then consume the box the way the client does: on the
|
||||||
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
||||||
@@ -1387,554 +1081,36 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => {
|
test('POST /api/challenge/v2/updateProgress echoes the challenge, never complete (stub)', async () => {
|
||||||
// Post the live rotation's own challenge and rule tree — what the client actually
|
const config =
|
||||||
// sends — so editing static/weekly-challenge.json can't quietly stale this test.
|
'{"ct":1,"ipc":false,"ctc":[{"ct":0,"ipc":false,"wc":[{"ct":6,"vs":[2]},{"ct":7,"vs":[{"l":"a673712c-877f-4749-b69a-4a4c6310d545"}]}]}],"t":5,"cc":1}'
|
||||||
const challenge = CURRENT_CHALLENGE
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
ChallengeMapId: '17',
|
||||||
ChallengeId: String(challenge.ChallengeId),
|
ChallengeId: '49',
|
||||||
Config: challenge.Config,
|
Config: config,
|
||||||
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
|
|
||||||
// would read as complete.
|
|
||||||
Complete: 'False',
|
Complete: 'False',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
ChallengeMapId: weeklyChallenge.ChallengeMapId,
|
ChallengeMapId: 17,
|
||||||
ChallengeId: challenge.ChallengeId,
|
ChallengeId: 49,
|
||||||
Config: challenge.Config,
|
Config: config,
|
||||||
Complete: false,
|
Complete: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => {
|
test('POST /api/gamerewards/v1/request returns [] (stub)', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ChallengeMapId: '17', ChallengeId: '49', Complete: 'True' }),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a completed challenge persists and getCurrent stamps it for that player only', async () => {
|
|
||||||
const completedId = CURRENT_CHALLENGE.ChallengeId
|
|
||||||
const bearerHeaders = await bearer('71')
|
|
||||||
const posted = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
|
||||||
ChallengeId: completedId,
|
|
||||||
Complete: 'True',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(posted.status).toBe(200)
|
|
||||||
|
|
||||||
const mine = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
|
|
||||||
headers: bearerHeaders,
|
|
||||||
})
|
|
||||||
const body = (await mine.json()) as {
|
|
||||||
Challenges: Array<{ ChallengeId: number; Complete: boolean }>
|
|
||||||
}
|
|
||||||
// Only the reported one is stamped; the rest of the rotation is untouched.
|
|
||||||
expect(body.Challenges.filter((ch) => ch.Complete).map((ch) => ch.ChallengeId)).toEqual([
|
|
||||||
completedId,
|
|
||||||
])
|
|
||||||
|
|
||||||
// A different player, and an anonymous caller, still see the static catalog.
|
|
||||||
const other = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
|
|
||||||
headers: await bearer('72'),
|
|
||||||
})
|
|
||||||
const otherBody = (await other.json()) as { Challenges: Array<{ Complete: boolean }> }
|
|
||||||
expect(otherBody.Challenges.some((ch) => ch.Complete)).toBe(false)
|
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
|
||||||
const anonBody = (await anon.json()) as { Challenges: Array<{ Complete: boolean }> }
|
|
||||||
expect(anonBody.Challenges.some((ch) => ch.Complete)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('completion latches within a rotation but resets on a new one', async () => {
|
|
||||||
const headers = { ...(await bearer('73')), 'Content-Type': 'application/json' }
|
|
||||||
// A challenge id of its own, so this says nothing about the live rotation.
|
|
||||||
const post = (ChallengeMapId: string, Complete: string) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ ChallengeMapId, ChallengeId: '9001', Complete }),
|
|
||||||
})
|
|
||||||
const completeOf = async (res: Response) =>
|
|
||||||
((await res.json()) as { Complete: boolean }).Complete
|
|
||||||
|
|
||||||
expect(await completeOf(await post('17', 'True'))).toBe(true)
|
|
||||||
// A later report that says "not complete" must not un-finish it.
|
|
||||||
expect(await completeOf(await post('17', 'False'))).toBe(true)
|
|
||||||
// …but the same challenge id in the NEXT rotation starts over.
|
|
||||||
expect(await completeOf(await post('18', 'False'))).toBe(false)
|
|
||||||
expect(await completeOf(await post('18', 'True'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many of the rotation's challenges earn the gift — three, unless the rotation
|
|
||||||
* publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
|
|
||||||
*/
|
|
||||||
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired
|
|
||||||
? weeklyChallenge.Challenges.length
|
|
||||||
: Math.min(3, weeklyChallenge.Challenges.length)
|
|
||||||
|
|
||||||
/** Report the live rotation's challenges complete, for one player. */
|
|
||||||
async function finishTheRotation(sub: string) {
|
|
||||||
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' }
|
|
||||||
const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId)
|
|
||||||
const report = (challengeId: number) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
|
||||||
ChallengeId: String(challengeId),
|
|
||||||
Complete: 'True',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return { ids, report }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A player's unopened gift boxes, as the client reads them back. */
|
|
||||||
async function giftBoxes(sub: string) {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
|
||||||
headers: await bearer(sub),
|
|
||||||
})
|
|
||||||
return (await res.json()) as Array<{
|
|
||||||
Id: number
|
|
||||||
Message: string
|
|
||||||
EquipmentModificationGuid: string
|
|
||||||
AvatarItemDesc: string
|
|
||||||
ConsumableItemDesc: string
|
|
||||||
GiftRarity: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
test('completing enough of the rotation grants its gift, once', async () => {
|
|
||||||
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
|
|
||||||
const { ids, report } = await finishTheRotation('74')
|
|
||||||
// The whole point of the threshold: the gift lands before the set is finished (the
|
|
||||||
// published week is five challenges for three).
|
|
||||||
expect(REQUIRED_FOR_GIFT).toBeLessThan(ids.length)
|
|
||||||
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
|
|
||||||
expect((await report(id)).status).toBe(200)
|
|
||||||
}
|
|
||||||
// One short of the threshold — the gift isn't due yet, even though challenges remain
|
|
||||||
// unfinished either way.
|
|
||||||
expect(await giftBoxes('74')).toEqual([])
|
|
||||||
await drainFrames()
|
|
||||||
|
|
||||||
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
|
|
||||||
const won = await giftBoxes('74')
|
|
||||||
expect(won).toHaveLength(1)
|
|
||||||
expect(won[0]?.Message).toBe('Weekly challenge complete!')
|
|
||||||
expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid)
|
|
||||||
|
|
||||||
// The client is told the moment the set is finished, rather than finding the box the
|
|
||||||
// next time it reads the gifts list. `Immediate` (31), from Coach (1).
|
|
||||||
const frames = await drainFrames()
|
|
||||||
expect(frames).toHaveLength(1)
|
|
||||||
expect(frames[0]?.accountId).toBe(74)
|
|
||||||
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
|
|
||||||
expect(frames[0]?.payload).toEqual({
|
|
||||||
Id: won[0]?.Id,
|
|
||||||
FromGiftDropId: 0,
|
|
||||||
FromPlayerId: 1,
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
|
|
||||||
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
|
|
||||||
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
|
||||||
CurrencyType: 0,
|
|
||||||
Currency: 0,
|
|
||||||
Xp: 0,
|
|
||||||
Level: 0,
|
|
||||||
Platform: -1,
|
|
||||||
PlatformsToSpawnOn: -1,
|
|
||||||
BalanceType: -2,
|
|
||||||
GiftContext: weeklyChallenge.Gift.GiftContext,
|
|
||||||
// The catalog's rarity for the item, not the block's `GiftRarity` of 0.
|
|
||||||
GiftRarity: 5,
|
|
||||||
Message: 'Weekly challenge complete!',
|
|
||||||
})
|
|
||||||
|
|
||||||
// The reward is the item, not the box: it lands in the inventory unopened.
|
|
||||||
const unlocked = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
|
|
||||||
headers: await bearer('74'),
|
|
||||||
})
|
|
||||||
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
|
|
||||||
expect(owned.map((e) => e.ModificationGuid)).toContain(
|
|
||||||
weeklyChallenge.Gift.EquipmentModificationGuid
|
|
||||||
)
|
|
||||||
|
|
||||||
// Finishing the REST of the set, and re-reporting what's already done (which the client
|
|
||||||
// keeps doing), must not mint a second reward.
|
|
||||||
for (const id of ids) expect((await report(id)).status).toBe(200)
|
|
||||||
expect(await giftBoxes('74')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a player who already owns the rotation’s gift rolls the fallback box instead', async () => {
|
|
||||||
// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
|
|
||||||
await grantEquipment(env.DB, 75, {
|
|
||||||
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
|
||||||
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
|
||||||
FriendlyName: 'Camera Skin (Comic)',
|
|
||||||
Tooltip: '',
|
|
||||||
Rarity: 5,
|
|
||||||
PlatformMask: -1,
|
|
||||||
Favorited: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { ids, report } = await finishTheRotation('75')
|
|
||||||
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
|
|
||||||
expect((await report(id)).status).toBe(200)
|
|
||||||
}
|
|
||||||
await drainFrames()
|
|
||||||
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
|
|
||||||
|
|
||||||
const won = await giftBoxes('75')
|
|
||||||
expect(won).toHaveLength(1)
|
|
||||||
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
|
|
||||||
// rather than a second copy of the gift.
|
|
||||||
const rolled = won[0]
|
|
||||||
expect(rolled?.EquipmentModificationGuid).not.toBe(
|
|
||||||
weeklyChallenge.Gift.EquipmentModificationGuid
|
|
||||||
)
|
|
||||||
expect(rolled?.GiftRarity).toBe(30)
|
|
||||||
expect(
|
|
||||||
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
// The frame announces what was ROLLED, not the box that promised it — so the client
|
|
||||||
// pops the item they actually won.
|
|
||||||
const frames = await drainFrames()
|
|
||||||
expect(frames).toHaveLength(1)
|
|
||||||
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
|
|
||||||
expect(frames[0]?.payload).toMatchObject({
|
|
||||||
Id: rolled?.Id,
|
|
||||||
FromPlayerId: 1,
|
|
||||||
GiftRarity: 30,
|
|
||||||
AvatarItemDesc: rolled?.AvatarItemDesc,
|
|
||||||
EquipmentModificationGuid: rolled?.EquipmentModificationGuid,
|
|
||||||
Message: 'Weekly challenge complete!',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('buying a query drop rolls a real item into the buyer’s inventory', async () => {
|
|
||||||
// sf2's "4-Star Unique Box" (539) — an `IsQuery` drop with no item fields of its own,
|
|
||||||
// which before the roll existed debited the buyer and granted nothing.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
StorefrontType: 2,
|
|
||||||
PurchasableItemId: 539,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
RequestedPrice: 800,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
|
|
||||||
// The RESPONSE describes what the roll landed on, not the box that was bought: the
|
|
||||||
// client draws the purchase from this entry, and the box's own fields are all empty.
|
|
||||||
const bought = (await res.json()) as {
|
|
||||||
BalanceUpdates: Array<{
|
|
||||||
Data: Array<{
|
|
||||||
AvatarItemDesc: string
|
|
||||||
EquipmentModificationGuid: string
|
|
||||||
GiftRarity: number
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
const entry = bought.BalanceUpdates[0]?.Data[0]
|
|
||||||
expect(entry?.GiftRarity).toBe(30)
|
|
||||||
expect(`${entry?.AvatarItemDesc ?? ''}${entry?.EquipmentModificationGuid ?? ''}`).not.toBe('')
|
|
||||||
|
|
||||||
const boxes = await giftBoxes('76')
|
|
||||||
expect(boxes).toHaveLength(1)
|
|
||||||
// The box shows what was rolled — a real 4-star item, not the empty box drop.
|
|
||||||
expect(boxes[0]?.GiftRarity).toBe(30)
|
|
||||||
expect(entry?.AvatarItemDesc).toBe(boxes[0]?.AvatarItemDesc)
|
|
||||||
const key = (box?: { AvatarItemDesc: string; EquipmentModificationGuid: string }) =>
|
|
||||||
`${box?.AvatarItemDesc ?? ''}|${box?.EquipmentModificationGuid ?? ''}`
|
|
||||||
expect(key(boxes[0])).not.toBe('|')
|
|
||||||
|
|
||||||
// …and it is already in their inventory, unopened box or not.
|
|
||||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
|
||||||
headers: await bearer('76'),
|
|
||||||
})
|
|
||||||
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
|
|
||||||
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
|
|
||||||
expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second box can't roll the same prize: "an item that you don't have" excludes what
|
|
||||||
// the first roll just granted. Two draws from a 244-item pool could collide by chance,
|
|
||||||
// so this only holds because the pool is filtered by ownership.
|
|
||||||
const second = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
StorefrontType: 2,
|
|
||||||
PurchasableItemId: 539,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
RequestedPrice: 800,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(second.status).toBe(200)
|
|
||||||
const after = await giftBoxes('76')
|
|
||||||
expect(after).toHaveLength(2)
|
|
||||||
expect(key(after[0])).not.toBe(key(after[1]))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('buying sf3’s Uncommon Random box answers with the rolled item', async () => {
|
|
||||||
// The purchase that came back as an empty box: an sf3 query drop, rolled out of the very
|
|
||||||
// catalog it sells in.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(await bearer('77')), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
StorefrontType: 3,
|
|
||||||
PurchasableItemId: 2455,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
|
||||||
RequestedPrice: 200,
|
|
||||||
CouponConsumablePlayerMappingId: null,
|
|
||||||
Gift: null,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
BalanceUpdates: Array<{
|
|
||||||
Data: Array<{ Id: number; AvatarItemDesc: string; GiftRarity: number }>
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
const entry = body.BalanceUpdates[0]?.Data[0]
|
|
||||||
// Uncommon: rarity 10, and a real item rather than the box's empty fields.
|
|
||||||
expect(entry?.GiftRarity).toBe(10)
|
|
||||||
expect(entry?.AvatarItemDesc).not.toBe('')
|
|
||||||
|
|
||||||
const boxes = await giftBoxes('77')
|
|
||||||
expect(boxes).toHaveLength(1)
|
|
||||||
expect(boxes[0]?.Id).toBe(entry?.Id)
|
|
||||||
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => {
|
|
||||||
const headers = {
|
|
||||||
...(await bearer('80')),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
}
|
|
||||||
const request = (body: string) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
const statusOf = (rewardType: string, giftContext = '') =>
|
|
||||||
env.DB.prepare(
|
|
||||||
`SELECT granted_at, grant_count FROM reward_status
|
|
||||||
WHERE account_id = 80 AND reward_type = ?1 AND gift_context = ?2`
|
|
||||||
)
|
|
||||||
.bind(rewardType, giftContext)
|
|
||||||
.first<{ granted_at: string; grant_count: number }>()
|
|
||||||
|
|
||||||
// A claim answers the empty list the client accepts — the reward rides in a gift box.
|
|
||||||
const first = await request(
|
|
||||||
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
|
|
||||||
)
|
|
||||||
expect(first.status).toBe(200)
|
|
||||||
expect(await first.json()).toEqual([])
|
|
||||||
const claimed = await statusOf('FirstActivityOfDay')
|
|
||||||
expect(claimed?.grant_count).toBe(1)
|
|
||||||
|
|
||||||
// Asking again inside the hour claims nothing — and must not push the cooldown out,
|
|
||||||
// or a client that retries in a loop would never become eligible.
|
|
||||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
|
||||||
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
|
|
||||||
|
|
||||||
// A different type has its own cooldown — and so does each `giftContext` within a type:
|
|
||||||
// Soccer and Paintball are separate rows that each claim once.
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await request(
|
|
||||||
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer'
|
|
||||||
)
|
|
||||||
).status
|
|
||||||
).toBe(200)
|
|
||||||
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(1)
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await request(
|
|
||||||
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball'
|
|
||||||
)
|
|
||||||
).status
|
|
||||||
).toBe(200)
|
|
||||||
expect((await statusOf('PostGameActivity', 'Paintball'))?.grant_count).toBe(1)
|
|
||||||
|
|
||||||
// …but the same activity again inside the hour claims nothing.
|
|
||||||
const soccer = await statusOf('PostGameActivity', 'Soccer')
|
|
||||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
|
||||||
expect(await statusOf('PostGameActivity', 'Soccer')).toEqual(soccer)
|
|
||||||
|
|
||||||
// A contextless ask is its own bucket (`''`), not a wildcard over the two above.
|
|
||||||
expect((await request('rewardType=PostGameActivity&Message=no%20context')).status).toBe(200)
|
|
||||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
|
||||||
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
|
|
||||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
|
||||||
|
|
||||||
// Once the hour has passed, the same type claims again.
|
|
||||||
await env.DB.prepare(
|
|
||||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
|
|
||||||
)
|
|
||||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
|
||||||
.run()
|
|
||||||
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
|
|
||||||
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a claimed game reward pays XP into a gift box, and announces it', async () => {
|
|
||||||
const request = async (body: string) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(await bearer('82')),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
/** Age the cooldown so the next ask is eligible again. */
|
|
||||||
const passAnHour = () =>
|
|
||||||
env.DB.prepare(
|
|
||||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
|
|
||||||
)
|
|
||||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
|
||||||
.run()
|
|
||||||
|
|
||||||
await drainFrames()
|
|
||||||
expect((await getProgression(env.DB, 82)).XP).toBe(0)
|
|
||||||
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual([])
|
|
||||||
|
|
||||||
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
|
|
||||||
// bar without levelling anyone up.
|
|
||||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
|
||||||
|
|
||||||
// One box: the XP reward itself, carrying the message the client asked to show and no
|
|
||||||
// item — a game reward is not an item.
|
|
||||||
const first = await giftBoxes('82')
|
|
||||||
expect(first).toHaveLength(1)
|
|
||||||
expect(first[0]).toMatchObject({
|
|
||||||
Xp: 5,
|
|
||||||
Message: 'First Game of the Day',
|
|
||||||
AvatarItemDesc: '',
|
|
||||||
EquipmentModificationGuid: '',
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// The box, then the bar — no level-up box, since no level was crossed.
|
|
||||||
const frames = await drainFrames()
|
|
||||||
expect(frames.map((f) => f.notificationType)).toEqual([
|
|
||||||
NotificationType.GiftPackageReceivedImmediate,
|
|
||||||
NotificationType.PlayerProgressionLevelUpdate,
|
|
||||||
])
|
|
||||||
expect(frames[0]?.accountId).toBe(82)
|
|
||||||
expect(frames[0]?.payload).toMatchObject({
|
|
||||||
Id: first[0]?.Id,
|
|
||||||
FromPlayerId: 1,
|
|
||||||
Xp: 5,
|
|
||||||
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
|
|
||||||
GiftContext: 50,
|
|
||||||
Message: 'First Game of the Day',
|
|
||||||
})
|
|
||||||
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
|
||||||
|
|
||||||
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
|
|
||||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
|
||||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
|
||||||
expect(await giftBoxes('82')).toHaveLength(1)
|
|
||||||
expect(await drainFrames()).toEqual([])
|
|
||||||
|
|
||||||
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
|
|
||||||
// is the pacing the smaller grant buys.
|
|
||||||
await passAnHour()
|
|
||||||
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
|
|
||||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
|
||||||
|
|
||||||
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
|
|
||||||
// equipment skin, which is what the avatar-only roll is for.
|
|
||||||
const afterLevel2 = await giftBoxes('82')
|
|
||||||
expect(afterLevel2).toHaveLength(3)
|
|
||||||
const clothingBox = afterLevel2[2]
|
|
||||||
expect(clothingBox?.Message).toBe('Level 2!')
|
|
||||||
expect(clothingBox?.AvatarItemDesc).not.toBe('')
|
|
||||||
expect(clothingBox?.EquipmentModificationGuid).toBe('')
|
|
||||||
expect(clothingBox?.ConsumableItemDesc).toBe('')
|
|
||||||
expect(clothingBox?.GiftRarity).toBe(10)
|
|
||||||
|
|
||||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
|
||||||
headers: await bearer('82'),
|
|
||||||
})
|
|
||||||
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
|
|
||||||
expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
|
|
||||||
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
|
|
||||||
NotificationType.GiftPackageReceivedImmediate,
|
|
||||||
NotificationType.PlayerProgressionLevelUpdate,
|
|
||||||
NotificationType.GiftPackageReceivedImmediate,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
|
|
||||||
// clothing — rolled without a rarity, since the table names none for them.
|
|
||||||
for (const message of ['Third', 'Fourth']) {
|
|
||||||
await passAnHour()
|
|
||||||
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
|
|
||||||
}
|
|
||||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
|
|
||||||
|
|
||||||
const afterLevel3 = await giftBoxes('82')
|
|
||||||
const consumableBox = afterLevel3[afterLevel3.length - 1]
|
|
||||||
expect(consumableBox?.Message).toBe('Level 3!')
|
|
||||||
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
|
|
||||||
expect(consumableBox?.AvatarItemDesc).toBe('')
|
|
||||||
expect(consumableBox?.EquipmentModificationGuid).toBe('')
|
|
||||||
|
|
||||||
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
|
|
||||||
headers: await bearer('82'),
|
|
||||||
})
|
|
||||||
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
|
|
||||||
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
|
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
|
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
|
||||||
})
|
})
|
||||||
expect(anon.status).toBe(401)
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual([])
|
||||||
// No reward type: nothing to gate, so no row keyed on an empty string.
|
|
||||||
const typeless = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(await bearer('81')),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: 'Message=First%20Game%20of%20the%20Day',
|
|
||||||
})
|
|
||||||
expect(typeless.status).toBe(200)
|
|
||||||
expect(await typeless.json()).toEqual([])
|
|
||||||
const rows = await env.DB.prepare(
|
|
||||||
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
|
|
||||||
).first<{ count: number }>()
|
|
||||||
expect(rows?.count).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/roomkeys/v1/mine returns []', async () => {
|
test('GET /api/roomkeys/v1/mine returns []', async () => {
|
||||||
@@ -1943,53 +1119,18 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
const getSubscription = async (headers: Record<string, string> = {}) =>
|
test('POST /api/CampusCard/v1/UpdateAndGetSubscription returns null fields', async () => {
|
||||||
exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, {
|
const res = await exports.default.fetch(
|
||||||
method: 'POST',
|
`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`,
|
||||||
headers,
|
{
|
||||||
})
|
method: 'POST',
|
||||||
|
}
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
|
)
|
||||||
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
expect(await res.json()).toEqual({
|
||||||
Subscription: Record<string, unknown>
|
subscription: null,
|
||||||
PlatformAccountSubscribedPlayerId: null
|
platformAccountSubscribedPlayerId: null,
|
||||||
}
|
|
||||||
expect(body.PlatformAccountSubscribedPlayerId).toBeNull()
|
|
||||||
expect(body.Subscription).toMatchObject({
|
|
||||||
SubscriptionId: 1,
|
|
||||||
// The subscribed player is the caller, not a fixed id.
|
|
||||||
RecNetPlayerId: 205,
|
|
||||||
// -1 All: no store sold this. 0 = Gold (1 is Platinum), 1 = Year.
|
|
||||||
PlatformType: -1,
|
|
||||||
PlatformId: '',
|
|
||||||
PlatformPurchaseId: '',
|
|
||||||
Level: 0,
|
|
||||||
Period: 1,
|
|
||||||
IsAutoRenewing: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// The subscription runs a year from the call rather than to a hard-coded date, so it
|
|
||||||
// cannot lapse on a day nobody is expecting.
|
|
||||||
const created = new Date(body.Subscription.CreatedAt as string)
|
|
||||||
const expires = new Date(body.Subscription.ExpirationDate as string)
|
|
||||||
expect(body.Subscription.ModifiedAt).toBe(body.Subscription.CreatedAt)
|
|
||||||
expect(expires.getTime()).toBeGreaterThan(Date.now())
|
|
||||||
expect(expires.getUTCFullYear()).toBe(created.getUTCFullYear() + 1)
|
|
||||||
expect(expires.getUTCMonth()).toBe(created.getUTCMonth())
|
|
||||||
expect(expires.getUTCDate()).toBe(created.getUTCDate())
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
|
||||||
// A plain player's token: valid, but no elevated role.
|
|
||||||
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
|
|
||||||
// A token with no `role` claim at all.
|
|
||||||
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
|
||||||
// No token: "not subscribed" rather than 401, so a loading client isn't stalled.
|
|
||||||
const anon = await getSubscription()
|
|
||||||
expect(anon.status).toBe(200)
|
|
||||||
expect(await anon.json()).toEqual({})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unknown path returns 404', async () => {
|
test('unknown path returns 404', async () => {
|
||||||
@@ -2041,7 +1182,6 @@ describe('econ endpoints', () => {
|
|||||||
'GET /api/roomkeys/v1/mine',
|
'GET /api/roomkeys/v1/mine',
|
||||||
'GET /api/roomkeys/v1/room',
|
'GET /api/roomkeys/v1/room',
|
||||||
'GET /api/storefronts/v1/adcarouselitems',
|
'GET /api/storefronts/v1/adcarouselitems',
|
||||||
'GET /api/storefronts/v2/buyInvention',
|
|
||||||
'GET /api/storefronts/v3/giftdropstore/{id}',
|
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||||
'GET /api/storefronts/v4/balance/{currencyType}',
|
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||||
'GET /econ/customAvatarItems/v1/owned',
|
'GET /econ/customAvatarItems/v1/owned',
|
||||||
@@ -2054,7 +1194,6 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/consumables/v1/consume',
|
'POST /api/consumables/v1/consume',
|
||||||
'POST /api/gamerewards/v1/request',
|
'POST /api/gamerewards/v1/request',
|
||||||
'POST /api/objectives/v1/cleargroup',
|
'POST /api/objectives/v1/cleargroup',
|
||||||
'POST /api/objectives/v1/updateobjective',
|
|
||||||
'POST /api/storefronts/v2/buyItem',
|
'POST /api/storefronts/v2/buyItem',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
"ChallengeId": 37,
|
"ChallengeId": 37,
|
||||||
"Name": "CompleteJT",
|
"Name": "CompleteJT",
|
||||||
"Config": "{\"ct\":1,\"c\":true,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"6d5eea4b-f069-4ed0-9916-0e2f07df0d03\"},{\"l\":\"4078dfed-24bb-4db7-863f-578ba48d726b\"}]}]}],\"t\":1,\"cc\":1}",
|
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":9,\"vs\":[true],\"v\":\"won\"},{\"ct\":7,\"vs\":[{\"l\":\"acc06e66-c2d0-4361-b0cd-46246a4c455c\"}]}]}",
|
||||||
"Description": "Complete ^TheRiseOfJumbotron quest",
|
"Description": "Complete ^TheRiseOfJumbotron quest",
|
||||||
"Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!",
|
"Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!",
|
||||||
"Complete": false
|
"Complete": false
|
||||||
@@ -60,5 +60,5 @@
|
|||||||
"GiftRarity": 0
|
"GiftRarity": 0
|
||||||
},
|
},
|
||||||
"FallbackGiftName": "4-Star Box",
|
"FallbackGiftName": "4-Star Box",
|
||||||
"ChallengeThemeString": ""
|
"ChallengeThemeString": "\"do like \"kapow\"-like its a punch to the face that we're doing weekly challenges\" - fexlar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ export default defineConfig({
|
|||||||
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
||||||
// RPC surface — enough for the runtime to start and for notification sends to
|
// RPC surface — enough for the runtime to start and for notification sends to
|
||||||
// no-op.
|
// no-op.
|
||||||
//
|
|
||||||
// The stub RECORDS what it was sent (`drainFrames`) rather than discarding it.
|
|
||||||
// Pushes are best-effort and swallow their own errors, so a frame carrying the
|
|
||||||
// wrong payload is otherwise invisible here — which is exactly how
|
|
||||||
// StorefrontBalanceUpdate shipped with the resulting total in a field the
|
|
||||||
// client adds to what it is already showing.
|
|
||||||
workers: [
|
workers: [
|
||||||
{
|
{
|
||||||
name: 'notify',
|
name: 'notify',
|
||||||
@@ -30,18 +24,8 @@ export default defineConfig({
|
|||||||
script: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
frames = []
|
async notifyPlayer() { return { delivered: 0, queued: true } }
|
||||||
async notifyPlayer(accountId, notificationType, payload) {
|
|
||||||
this.frames.push({ accountId, notificationType, payload })
|
|
||||||
return { delivered: 0, queued: true }
|
|
||||||
}
|
|
||||||
async broadcast() { return { delivered: 0 } }
|
async broadcast() { return { delivered: 0 } }
|
||||||
/** Everything pushed since the last call, then forget it. */
|
|
||||||
async drainFrames() {
|
|
||||||
const drained = this.frames
|
|
||||||
this.frames = []
|
|
||||||
return drained
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export default { fetch() { return new Response('ok') } }
|
export default { fetch() { return new Response('ok') } }
|
||||||
`,
|
`,
|
||||||
|
|||||||
+5
-24
@@ -13,33 +13,14 @@ key:
|
|||||||
back to the bundled `static/DefaultProfileImage.jpg` asset (served `200` via
|
back to the bundled `static/DefaultProfileImage.jpg` asset (served `200` via
|
||||||
the `ASSETS` binding), so clients always get a valid image. The fallback also
|
the `ASSETS` binding), so clients always get a valid image. The fallback also
|
||||||
honours `?sig=p1` and returns a `Content-Signature` header.
|
honours `?sig=p1` and returns a `Content-Signature` header.
|
||||||
- `GET /<key>?sig=p1` — same, plus a
|
- `GET /<key>?sig=p1` — same, but the response body is RSA-SHA1 signed and the
|
||||||
`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>` header. By default
|
signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`
|
||||||
that value is a **placeholder, not a real signature** — see below.
|
header. The client uses this to verify image integrity. Signing buffers the
|
||||||
|
whole object.
|
||||||
|
|
||||||
The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`).
|
The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`).
|
||||||
|
|
||||||
## Response signing
|
## Response signing key
|
||||||
|
|
||||||
The client requires a `Content-Signature` header to be present when it asks for
|
|
||||||
`?sig=p1`, but it never verifies the value. Signing for real is this worker's
|
|
||||||
dominant CPU cost: it has to buffer the whole object into the isolate rather than
|
|
||||||
streaming it out of R2, then hash the full body with SHA-1 and run an RSA-2048
|
|
||||||
private-key operation — on every request the edge cache misses.
|
|
||||||
|
|
||||||
So by default (`IMG_SIGNING_ENABLED: false` in `wrangler.jsonc`) the header is
|
|
||||||
filled with a placeholder derived from the object key: FNV-1a seeds an xorshift32
|
|
||||||
PRNG that emits 256 bytes, the length of a real RSA-2048 signature, so the value
|
|
||||||
is structurally indistinguishable to the client's parser and stable for a given
|
|
||||||
key. It costs no body access, so untransformed images keep streaming.
|
|
||||||
|
|
||||||
Set the var to `true` for genuine RSA-SHA1 signatures over the returned bytes.
|
|
||||||
Note this is a **placeholder, not a downgrade of a security control** — nothing
|
|
||||||
in the system authenticates images either way. Turn it on before relying on the
|
|
||||||
header for integrity. (Resizes buffer regardless — the Photon codec needs the
|
|
||||||
whole image.)
|
|
||||||
|
|
||||||
### Signing key
|
|
||||||
|
|
||||||
`?sig=p1` signs with the RSA-2048 key in `env.IMG_SIGNING_KEY` (PKCS8 DER,
|
`?sig=p1` signs with the RSA-2048 key in `env.IMG_SIGNING_KEY` (PKCS8 DER,
|
||||||
base64). `wrangler.jsonc` ships an **insecure dev key** for local dev / tests;
|
base64). `wrangler.jsonc` ships an **insecure dev key** for local dev / tests;
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ export type Env = SharedHonoEnv & {
|
|||||||
DB: D1Database
|
DB: D1Database
|
||||||
/** R2 bucket holding the served image objects, keyed by filename. */
|
/** R2 bucket holding the served image objects, keyed by filename. */
|
||||||
IMAGES: R2Bucket
|
IMAGES: R2Bucket
|
||||||
/**
|
|
||||||
* Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images
|
|
||||||
* uploaded through the `storage` worker are stored extensionless under
|
|
||||||
* `image/<date>/<uuid>` and requested from this worker by the bare name.
|
|
||||||
*/
|
|
||||||
CDN_ASSETS: R2Bucket
|
|
||||||
/** Static assets (fallback images) served from `static/`. */
|
/** Static assets (fallback images) served from `static/`. */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
@@ -19,14 +13,6 @@ export type Env = SharedHonoEnv & {
|
|||||||
* requested with `?sig=p1`. Optional — when absent, responses are unsigned.
|
* requested with `?sig=p1`. Optional — when absent, responses are unsigned.
|
||||||
*/
|
*/
|
||||||
IMG_SIGNING_KEY?: string
|
IMG_SIGNING_KEY?: string
|
||||||
/**
|
|
||||||
* Feature flag for REAL response signing. `?sig=p1` always returns a
|
|
||||||
* `Content-Signature` header, but only when this is true is the value an
|
|
||||||
* actual RSA-SHA1 signature over the body; when false (the default) it is a
|
|
||||||
* cheap placeholder derived from the object key, which keeps the response on
|
|
||||||
* the streaming path. See `stubSignature()` in `img.app.ts`.
|
|
||||||
*/
|
|
||||||
IMG_SIGNING_ENABLED?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||||
|
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||||
|
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||||
|
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||||
|
*
|
||||||
|
* The `img` worker owns this schema/migration (migrations/0001_images.sql, applied
|
||||||
|
* with its own `migrations_table` so it doesn't clash with the other workers'
|
||||||
|
* migrations on the shared database). The `api` worker writes a row on upload and
|
||||||
|
* reads it back, keeping its own copy of these helpers in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`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,
|
||||||
|
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||||
|
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||||
|
// A player's interaction with a saved image — one row per (player, image). Only
|
||||||
|
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||||
|
// favorited) can be added as columns. The `api` worker writes it (cheer endpoints)
|
||||||
|
// and keeps the image's denormalized `CheerCount` in sync from it.
|
||||||
|
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
saved_image_id INTEGER NOT NULL,
|
||||||
|
cheered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
PRIMARY KEY (player_id, saved_image_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A stored image record (the client-facing SavedImage shape). */
|
||||||
|
export interface SavedImage {
|
||||||
|
Id: number
|
||||||
|
Type: number
|
||||||
|
Accessibility: number
|
||||||
|
AccessibilityLocked: boolean
|
||||||
|
ImageName: string
|
||||||
|
Description: string | null
|
||||||
|
PlayerId: number
|
||||||
|
TaggedPlayerIds: number[]
|
||||||
|
RoomId: number | null
|
||||||
|
PlayerEventId: number | null
|
||||||
|
CreatedAt: string
|
||||||
|
CheerCount: number
|
||||||
|
CommentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||||
|
export interface NewImage {
|
||||||
|
imageName: string
|
||||||
|
playerId: number
|
||||||
|
type?: number
|
||||||
|
accessibility?: number
|
||||||
|
roomId?: number | null
|
||||||
|
description?: string | null
|
||||||
|
taggedPlayerIds?: number[]
|
||||||
|
playerEventId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a new image record for an upload, returning the stored row. */
|
||||||
|
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||||
|
// 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 image')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const image: SavedImage = {
|
||||||
|
Id: row?.next ?? 1,
|
||||||
|
Type: input.type ?? 1,
|
||||||
|
Accessibility: input.accessibility ?? 1,
|
||||||
|
AccessibilityLocked: false,
|
||||||
|
ImageName: input.imageName,
|
||||||
|
Description: input.description ?? null,
|
||||||
|
PlayerId: input.playerId,
|
||||||
|
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||||
|
RoomId: input.roomId ?? null,
|
||||||
|
PlayerEventId: input.playerEventId ?? null,
|
||||||
|
CreatedAt: new Date().toISOString(),
|
||||||
|
CheerCount: 0,
|
||||||
|
CommentCount: 0,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||||
|
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||||
|
.bind(name)
|
||||||
|
.first<ImageRow>()
|
||||||
|
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||||
|
}
|
||||||
+31
-177
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withCleanSpec, withNotFound, withOnError, writeContentRange } from '@repo/hono-helpers'
|
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { imageBytes, json, ServiceStatus } from './openapi'
|
import { imageBytes, json, ServiceStatus } from './openapi'
|
||||||
|
|
||||||
@@ -15,9 +15,6 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net'
|
|||||||
/** Static asset served (200) when the requested key is missing from R2. */
|
/** Static asset served (200) when the requested key is missing from R2. */
|
||||||
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
||||||
|
|
||||||
/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */
|
|
||||||
const CDN_IMAGE_PREFIX = 'image/'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-Control for served images. Uploaded images are immutable once written,
|
* Cache-Control for served images. Uploaded images are immutable once written,
|
||||||
* so cache for a year and mark `immutable` so browsers never revalidate. A new
|
* so cache for a year and mark `immutable` so browsers never revalidate. A new
|
||||||
@@ -104,23 +101,6 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Which bucket (and under which key) a requested path resolves in.
|
|
||||||
*
|
|
||||||
* Every object the `api` worker writes to `recflare-img` keeps a file extension
|
|
||||||
* (`.jpg` is forced when the upload has none), so an extensionless key can only be
|
|
||||||
* a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as
|
|
||||||
* `image/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
|
|
||||||
* name it got back. That makes the extension a reliable discriminator —
|
|
||||||
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
|
|
||||||
*/
|
|
||||||
function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } {
|
|
||||||
const filename = key.slice(key.lastIndexOf('/') + 1)
|
|
||||||
return filename.includes('.')
|
|
||||||
? { bucket: env.IMAGES, objectKey: key }
|
|
||||||
: { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the signing key once per isolate. The key material is constant for the
|
// Import the signing key once per isolate. The key material is constant for the
|
||||||
// lifetime of the Worker, so caching the promise is safe.
|
// lifetime of the Worker, so caching the promise is safe.
|
||||||
let signingKey: Promise<CryptoKey | null> | undefined
|
let signingKey: Promise<CryptoKey | null> | undefined
|
||||||
@@ -152,88 +132,17 @@ async function signImage(env: Env, bytes: BufferSource): Promise<string | null>
|
|||||||
return btoa(binary)
|
return btoa(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Length of an RSA-2048 signature, matched by the placeholder below. */
|
|
||||||
const SIGNATURE_BYTES = 256
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A placeholder `Content-Signature` value derived from the object key.
|
|
||||||
*
|
|
||||||
* The client requires the header to be PRESENT when it asks for `?sig=p1` — it
|
|
||||||
* does not check the value — and a real signature is this worker's dominant CPU
|
|
||||||
* cost, so by default we fabricate one. Being a pure function of the key it needs
|
|
||||||
* no access to the body, which is the whole point: the response still streams out
|
|
||||||
* of R2 instead of being buffered into the isolate to be hashed.
|
|
||||||
*
|
|
||||||
* FNV-1a over the key seeds an xorshift32 PRNG that fills a full RSA-2048-length
|
|
||||||
* signature, so the value looks structurally right and is stable for a given key
|
|
||||||
* (a cached response and a fresh one agree). It is NOT verifiable: turn on
|
|
||||||
* `IMG_SIGNING_ENABLED` if anything ever needs to check it.
|
|
||||||
*/
|
|
||||||
function stubSignature(key: string): string {
|
|
||||||
let state = 0x811c9dc5
|
|
||||||
for (let i = 0; i < key.length; i++) {
|
|
||||||
state = Math.imul(state ^ key.charCodeAt(i), 0x01000193) >>> 0
|
|
||||||
}
|
|
||||||
// xorshift32 is a fixed point at zero; the FNV basis makes this unreachable in
|
|
||||||
// practice, but a degenerate all-zero signature is worth ruling out outright.
|
|
||||||
if (state === 0) state = 0x811c9dc5
|
|
||||||
|
|
||||||
let binary = ''
|
|
||||||
for (let i = 0; i < SIGNATURE_BYTES; i++) {
|
|
||||||
state = (state ^ (state << 13)) >>> 0
|
|
||||||
state = state ^ (state >>> 17)
|
|
||||||
state = (state ^ (state << 5)) >>> 0
|
|
||||||
binary += String.fromCharCode(state & 0xff)
|
|
||||||
}
|
|
||||||
return btoa(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How this request's `Content-Signature` header gets produced.
|
|
||||||
*
|
|
||||||
* - `none` — no `?sig=p1` was asked for; no header.
|
|
||||||
* - `stub` — the value is a pure function of the object key and is already
|
|
||||||
* computed, so the body never has to be read. The default.
|
|
||||||
* - `rsa` — a real RSA-SHA1 signature over the bytes actually returned, which
|
|
||||||
* forces the whole body through the isolate.
|
|
||||||
*/
|
|
||||||
type Signing = { mode: 'none' } | { mode: 'stub'; value: string } | { mode: 'rsa' }
|
|
||||||
|
|
||||||
function resolveSigning(env: Env, sig: string | undefined, key: string): Signing {
|
|
||||||
if (sig !== 'p1') return { mode: 'none' }
|
|
||||||
if (env.IMG_SIGNING_ENABLED === true) return { mode: 'rsa' }
|
|
||||||
return { mode: 'stub', value: stubSignature(key) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function signatureHeader(value: string): string {
|
|
||||||
return `key-id=${SIGNATURE_KEY_ID}; data=${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the key-derived placeholder signature, if that's the mode in play. Called
|
|
||||||
* before the body is touched — a stub never forces buffering.
|
|
||||||
*/
|
|
||||||
function applyStubSignature(headers: Headers, signing: Signing): void {
|
|
||||||
if (signing.mode === 'stub') headers.set('content-signature', signatureHeader(signing.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether serving this response requires the full body in the isolate. */
|
|
||||||
function needsBody(transform: Transform | null, signing: Signing): boolean {
|
|
||||||
return transform !== null || signing.mode === 'rsa'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the full image bytes and prepared response `headers`, optionally resize
|
* Given the full image bytes and prepared response `headers`, optionally resize
|
||||||
* (Photon) and/or RSA-SHA1 sign before returning the `Response`. Both operations
|
* (Photon) and/or RSA-SHA1 sign (`?sig=p1`) before returning the `Response`.
|
||||||
* need the whole body, so callers buffer before calling this. A `stub` signature
|
* Both operations need the whole body, so callers buffer before calling this.
|
||||||
* is already on `headers` by this point.
|
|
||||||
*/
|
*/
|
||||||
async function finalizeImage(
|
async function finalizeImage(
|
||||||
env: Env,
|
env: Env,
|
||||||
bytes: ArrayBuffer,
|
bytes: ArrayBuffer,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
transform: Transform | null,
|
transform: Transform | null,
|
||||||
signing: Signing
|
wantsSignature: boolean
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
let body: BufferSource = bytes
|
let body: BufferSource = bytes
|
||||||
if (transform) {
|
if (transform) {
|
||||||
@@ -243,9 +152,11 @@ async function finalizeImage(
|
|||||||
headers.delete('etag')
|
headers.delete('etag')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signing.mode === 'rsa') {
|
if (wantsSignature) {
|
||||||
const signature = await signImage(env, body)
|
const signature = await signImage(env, body)
|
||||||
if (signature) headers.set('content-signature', signatureHeader(signature))
|
if (signature) {
|
||||||
|
headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(body, { headers })
|
return new Response(body, { headers })
|
||||||
@@ -253,25 +164,23 @@ async function finalizeImage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve a static asset `Response` with our standard cache headers, honouring
|
* Serve a static asset `Response` with our standard cache headers, honouring
|
||||||
* `?width`/`?height` (resize) and `?sig=p1` (signing). A transform or a real
|
* `?width`/`?height` (resize) and `?sig=p1` (signing). Either requires the full
|
||||||
* signature requires the full body, so the asset is buffered; otherwise it is
|
* body, so the asset is buffered; otherwise it is streamed through untouched.
|
||||||
* streamed through untouched.
|
|
||||||
*/
|
*/
|
||||||
async function serveStaticAsset(
|
async function serveStaticAsset(
|
||||||
env: Env,
|
env: Env,
|
||||||
asset: Response,
|
asset: Response,
|
||||||
transform: Transform | null,
|
transform: Transform | null,
|
||||||
signing: Signing
|
wantsSignature: boolean
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const contentType = asset.headers.get('content-type')
|
const contentType = asset.headers.get('content-type')
|
||||||
if (contentType) headers.set('content-type', contentType)
|
if (contentType) headers.set('content-type', contentType)
|
||||||
headers.set('cache-control', CACHE_CONTROL)
|
headers.set('cache-control', CACHE_CONTROL)
|
||||||
applyStubSignature(headers, signing)
|
|
||||||
|
|
||||||
if (needsBody(transform, signing)) {
|
if (transform || wantsSignature) {
|
||||||
const bytes = await asset.arrayBuffer()
|
const bytes = await asset.arrayBuffer()
|
||||||
return finalizeImage(env, bytes, headers, transform, signing)
|
return finalizeImage(env, bytes, headers, transform, wantsSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(asset.body, { headers })
|
return new Response(asset.body, { headers })
|
||||||
@@ -322,14 +231,11 @@ app.get(
|
|||||||
description: [
|
description: [
|
||||||
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
||||||
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
||||||
'club banners and the photo feed — out of R2, with bundled static assets',
|
'club banners and the photo feed — from an R2 bucket, with bundled static assets',
|
||||||
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
|
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
|
||||||
'as the fallback when a key is missing. Keys with an extension come from the',
|
'as the fallback when a key is missing. Optional on-the-fly center-crop and resize',
|
||||||
'`recflare-img` bucket; extensionless ones are `storage` uploads and come from the',
|
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
|
||||||
'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize',
|
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
||||||
'run through the Photon WASM codec; `?sig=p1` adds the `Content-Signature` header',
|
|
||||||
'the client expects against `KEY:RSA:p1.rec.net` — a key-derived placeholder',
|
|
||||||
'unless the `IMG_SIGNING_ENABLED` flag turns on real RSA-SHA1 signing.',
|
|
||||||
'',
|
'',
|
||||||
'Note that this worker only serves bytes: the image metadata the client lists (the',
|
'Note that this worker only serves bytes: the image metadata the client lists (the',
|
||||||
'`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which',
|
'`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which',
|
||||||
@@ -360,12 +266,6 @@ app.get(
|
|||||||
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
|
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
|
||||||
'with a 200 rather than a 404, so the client never renders a broken image.',
|
'with a 200 rather than a 404, so the client never renders a broken image.',
|
||||||
'',
|
'',
|
||||||
'Which bucket the key resolves in depends on its extension. A key with one (always',
|
|
||||||
'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is',
|
|
||||||
'a `storage` upload and comes from the shared `recflare-cdn` bucket under its',
|
|
||||||
'`image/` prefix, so `/2028-06-01/<uuid>` here serves `image/2028-06-01/<uuid>`',
|
|
||||||
'there.',
|
|
||||||
'',
|
|
||||||
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||||
'image is never rewritten in place, a new image gets a new key.',
|
'image is never rewritten in place, a new image gets a new key.',
|
||||||
'',
|
'',
|
||||||
@@ -373,12 +273,6 @@ app.get(
|
|||||||
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
||||||
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
||||||
'ignored and the original is served — never an error.',
|
'ignored and the original is served — never an error.',
|
||||||
'',
|
|
||||||
'A `Range` is honoured (206) only on the untouched stream, which is the only response',
|
|
||||||
'that advertises `Accept-Ranges`. A transform decodes the whole image and a real',
|
|
||||||
'signature covers the whole body, so those serve the entire result and ignore the',
|
|
||||||
'header. Where a range does apply, a `bytes=` request is never answered with a bare',
|
|
||||||
'200: the `Content-Range` always states which bytes the body holds.',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
@@ -421,13 +315,10 @@ app.get(
|
|||||||
in: 'query',
|
in: 'query',
|
||||||
required: false,
|
required: false,
|
||||||
description: [
|
description: [
|
||||||
'`p1` returns a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`',
|
'`p1` RSA-SHA1 signs the response body and returns it as',
|
||||||
'header. By default `data` is a PLACEHOLDER derived from the object key, not a',
|
'`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`. Signed over the',
|
||||||
'real signature — the client requires the header to be present but does not',
|
'bytes actually returned, i.e. the resized body when a transform applies. Omitted',
|
||||||
'verify it, and signing for real costs the streaming fast path. Set',
|
'when the worker has no `IMG_SIGNING_KEY`.',
|
||||||
'`IMG_SIGNING_ENABLED` for a true RSA-SHA1 signature over the bytes actually',
|
|
||||||
'returned (i.e. the resized body when a transform applies); that also needs an',
|
|
||||||
'`IMG_SIGNING_KEY`, without which the header is omitted entirely.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
schema: { type: 'string', enum: ['p1'] },
|
schema: { type: 'string', enum: ['p1'] },
|
||||||
},
|
},
|
||||||
@@ -439,22 +330,9 @@ app.get(
|
|||||||
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
|
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
|
||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Range',
|
|
||||||
in: 'header',
|
|
||||||
required: false,
|
|
||||||
description: [
|
|
||||||
'A single byte range, parsed by R2 itself. Honoured with a 206 on the untouched',
|
|
||||||
'stream only — ignored when a transform or a real signature applies, since both',
|
|
||||||
'need the whole image. A `bytes=` value never yields a bare 200: the',
|
|
||||||
'`Content-Range` names the bytes enclosed even where that is all of them.',
|
|
||||||
].join(' '),
|
|
||||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
|
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
|
||||||
206: imageBytes('A byte range of the stored image, when the request carried a `Range`'),
|
|
||||||
304: { description: 'If-None-Match matched the stored object etag; no body' },
|
304: { description: 'If-None-Match matched the stored object etag; no body' },
|
||||||
400: { description: 'The key contained `..`; no body' },
|
400: { description: 'The key contained `..`; no body' },
|
||||||
},
|
},
|
||||||
@@ -463,11 +341,7 @@ app.get(
|
|||||||
const key = c.req.param('key')
|
const key = c.req.param('key')
|
||||||
if (key.includes('..')) return c.body(null, 400)
|
if (key.includes('..')) return c.body(null, 400)
|
||||||
|
|
||||||
// `?sig=p1` always answers with a Content-Signature header — the client needs
|
const wantsSignature = c.req.query('sig') === 'p1'
|
||||||
// one to be there — but by default the value is a cheap placeholder derived
|
|
||||||
// from the key rather than a real RSA-SHA1 signature over the body. See
|
|
||||||
// stubSignature(); IMG_SIGNING_ENABLED switches back to real signing.
|
|
||||||
const signing = resolveSigning(c.env, c.req.query('sig'), key)
|
|
||||||
const transform = parseTransform(
|
const transform = parseTransform(
|
||||||
c.req.query('width'),
|
c.req.query('width'),
|
||||||
c.req.query('height'),
|
c.req.query('height'),
|
||||||
@@ -479,30 +353,23 @@ app.get(
|
|||||||
// that always win over whatever, if anything, is in the bucket.
|
// that always win over whatever, if anything, is in the bucket.
|
||||||
const staticAsset = await c.env.ASSETS.fetch(new URL(`/${key}`, c.req.url))
|
const staticAsset = await c.env.ASSETS.fetch(new URL(`/${key}`, c.req.url))
|
||||||
if (staticAsset.ok) {
|
if (staticAsset.ok) {
|
||||||
return serveStaticAsset(c.env, staticAsset, transform, signing)
|
return serveStaticAsset(c.env, staticAsset, transform, wantsSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditional requests only make sense for the untransformed object: a
|
// Conditional requests only make sense for the untransformed object: a
|
||||||
// resized response carries no etag, so the client can never send a matching
|
// resized response carries no etag, so the client can never send a matching
|
||||||
// one. Skip the precondition when a transform is requested.
|
// one. Skip the precondition when a transform is requested.
|
||||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||||
const { bucket, objectKey } = resolveObject(c.env, key)
|
const object = await c.env.IMAGES.get(
|
||||||
// A `Range` applies only to the untouched stream. Resizing decodes the whole image
|
key,
|
||||||
// and an RSA signature covers the whole body, so a ranged read there would produce
|
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||||
// bytes that are not the range asked for — ask R2 for the range only when we are
|
)
|
||||||
// going to hand its bytes straight back. R2 parses the header itself; see
|
|
||||||
// writeContentRange() below for why it is never answered with a bare 200.
|
|
||||||
const range = needsBody(transform, signing) ? undefined : c.req.raw.headers
|
|
||||||
const object = await bucket.get(objectKey, {
|
|
||||||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
|
||||||
...(range ? { range } : {}),
|
|
||||||
})
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
// Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg
|
// Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg
|
||||||
// static asset so clients still get a valid image instead of a 404. Honour
|
// static asset so clients still get a valid image instead of a 404. Honour
|
||||||
// `?sig=p1` the same way so the fallback is signed like any other image.
|
// `?sig=p1` the same way so signed clients can verify the fallback.
|
||||||
const asset = await c.env.ASSETS.fetch(new URL(FALLBACK_ASSET_PATH, c.req.url))
|
const asset = await c.env.ASSETS.fetch(new URL(FALLBACK_ASSET_PATH, c.req.url))
|
||||||
return serveStaticAsset(c.env, asset, transform, signing)
|
return serveStaticAsset(c.env, asset, transform, wantsSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
@@ -513,22 +380,9 @@ app.get(
|
|||||||
// Precondition matched (If-None-Match) → R2 returns no body.
|
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||||
|
|
||||||
// Set after the 304 above so both signing modes behave alike: the header only
|
if (transform || wantsSignature) {
|
||||||
// ever rides a response that actually carries bytes.
|
|
||||||
applyStubSignature(headers, signing)
|
|
||||||
|
|
||||||
if (needsBody(transform, signing)) {
|
|
||||||
const bytes = await object.arrayBuffer()
|
const bytes = await object.arrayBuffer()
|
||||||
return finalizeImage(c.env, bytes, headers, transform, signing)
|
return finalizeImage(c.env, bytes, headers, transform, wantsSignature)
|
||||||
}
|
|
||||||
|
|
||||||
// Only the untouched stream can honour a range, so only it advertises the fact.
|
|
||||||
// The transformed and static-asset paths above serve the whole thing regardless,
|
|
||||||
// which is the legal answer to a range you cannot honour — but claiming
|
|
||||||
// `accept-ranges` there would invite a client to expect otherwise.
|
|
||||||
headers.set('accept-ranges', 'bytes')
|
|
||||||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
|
||||||
return new Response(object.body, { status: 206, headers })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(object.body, { headers })
|
return new Response(object.body, { headers })
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { PhotonImage } from '@cf-wasm/photon'
|
import { PhotonImage } from '@cf-wasm/photon'
|
||||||
import { createExecutionContext, env, SELF, waitOnExecutionContext } from 'cloudflare:test'
|
import { env, SELF } from 'cloudflare:test'
|
||||||
import { beforeAll, describe, expect, it } from 'vitest'
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import app from '../../img.app'
|
import '../../img.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -34,29 +34,10 @@ const PUBLIC_SPKI_B64 =
|
|||||||
// bucket path rather than a static asset.
|
// bucket path rather than a static asset.
|
||||||
const R2_KEY = 'user-photo.jpg'
|
const R2_KEY = 'user-photo.jpg'
|
||||||
|
|
||||||
// An extensionless name, as returned by the `storage` worker for a FileType 3
|
|
||||||
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
|
|
||||||
const CDN_NAME = '2028-06-01/12345-67890-12345'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch with real signing turned OFF — the deployed default. `vitest.config.ts`
|
|
||||||
* binds `IMG_SIGNING_ENABLED` on so the RSA path stays covered, so the placeholder
|
|
||||||
* path has to drive the app directly with an overridden env.
|
|
||||||
*/
|
|
||||||
async function unsignedFetch(url: string): Promise<Response> {
|
|
||||||
const ctx = createExecutionContext()
|
|
||||||
const res = await app.fetch(new Request(url), { ...env, IMG_SIGNING_ENABLED: false }, ctx)
|
|
||||||
await waitOnExecutionContext(ctx)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
})
|
})
|
||||||
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
|
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
|
||||||
})
|
|
||||||
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
||||||
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
@@ -77,38 +58,6 @@ describe('img endpoints', () => {
|
|||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves an extensionless key from the cdn bucket under image/', async () => {
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
|
||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not look for an extensionless key in the image bucket', async () => {
|
|
||||||
// Same bare name seeded into `recflare-img` instead: extensionless keys only
|
|
||||||
// ever resolve against `recflare-cdn`, so this falls through to the default.
|
|
||||||
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = new Uint8Array(await res.arrayBuffer())
|
|
||||||
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resizes an extensionless cdn image', async () => {
|
|
||||||
// Exercises the transform path against the cdn bucket, not just the stream-through.
|
|
||||||
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
|
|
||||||
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
|
||||||
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
|
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
|
||||||
expect(res.headers.get('etag')).toBeNull()
|
|
||||||
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -140,62 +89,6 @@ describe('img endpoints', () => {
|
|||||||
expect(res.status).toBe(304)
|
expect(res.status).toBe(304)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('honors a Range request on the stored image with a 206', async () => {
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'bytes=2-4' } })
|
|
||||||
expect(res.status).toBe(206)
|
|
||||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/8')
|
|
||||||
expect(res.headers.get('accept-ranges')).toBe('bytes')
|
|
||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES.slice(2, 5))
|
|
||||||
})
|
|
||||||
|
|
||||||
// R2 resolves a range it cannot parse or satisfy to the WHOLE object rather than
|
|
||||||
// failing. Handing that back as a bare 200 is the shape that corrupts a chunked
|
|
||||||
// download — the client wrote a whole file where it expected a slice — so every one
|
|
||||||
// of these still states what the body holds.
|
|
||||||
it('never answers a bytes range with a whole-object 200', async () => {
|
|
||||||
for (const range of ['bytes=100-200', 'bytes=abc', 'bytes=0-1,3-4', 'bytes=0-7']) {
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: range } })
|
|
||||||
expect(res.status, range).toBe(206)
|
|
||||||
expect(res.headers.get('content-range'), range).toBe('bytes 0-7/8')
|
|
||||||
}
|
|
||||||
|
|
||||||
// A unit other than bytes must be ignored outright (RFC 9110), not answered with
|
|
||||||
// a byte-denominated Content-Range.
|
|
||||||
const other = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'items=0-1' } })
|
|
||||||
expect(other.status).toBe(200)
|
|
||||||
expect(other.headers.get('content-range')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
// A resize decodes the whole image, so there is no meaningful slice of the source to
|
|
||||||
// read — the range is ignored and the whole transformed result served, which is the
|
|
||||||
// legal answer. What it must NOT do is claim a 206 over bytes it rebuilt. Runs against
|
|
||||||
// the R2 path (a decodable JPEG borrowed from `static/`), since that is the one that
|
|
||||||
// has a range to suppress; the static-asset path is never handed one at all.
|
|
||||||
it('ignores a Range when a transform rebuilds the body', async () => {
|
|
||||||
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
|
||||||
await env.IMAGES.put('ranged-transform.jpg', real, {
|
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?width=128`, {
|
|
||||||
headers: { Range: 'bytes=0-9' },
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(res.headers.get('content-range')).toBeNull()
|
|
||||||
expect(res.headers.get('accept-ranges')).toBeNull()
|
|
||||||
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
|
||||||
|
|
||||||
// Same for a real RSA signature, which covers the whole body (the test env binds
|
|
||||||
// IMG_SIGNING_ENABLED on, so `?sig=p1` takes the signing path rather than the stub).
|
|
||||||
const signed = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?sig=p1`, {
|
|
||||||
headers: { Range: 'bytes=0-9' },
|
|
||||||
})
|
|
||||||
expect(signed.status).toBe(200)
|
|
||||||
expect(signed.headers.get('content-range')).toBeNull()
|
|
||||||
expect(signed.headers.get('content-signature')).toContain('key-id=KEY:RSA:p1.rec.net')
|
|
||||||
expect(new Uint8Array(await signed.arrayBuffer()).byteLength).toBe(real.byteLength)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
|
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
|
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -256,48 +149,6 @@ describe('img endpoints', () => {
|
|||||||
expect(res.headers.get('content-signature')).toBeNull()
|
expect(res.headers.get('content-signature')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a placeholder signature when IMG_SIGNING_ENABLED is off', async () => {
|
|
||||||
// The deployed default (see wrangler.jsonc). The header must still be there —
|
|
||||||
// the client requires it — but the value is derived from the key, so the body
|
|
||||||
// is neither buffered nor hashed and streams straight out of R2.
|
|
||||||
const res = await unsignedFetch(`${ORIGIN}/${R2_KEY}?sig=p1`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
|
|
||||||
const header = res.headers.get('content-signature')
|
|
||||||
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
|
|
||||||
// Same shape as a real RSA-2048 signature, so the client's parser sees no
|
|
||||||
// difference between the two modes.
|
|
||||||
const signature = Uint8Array.from(atob(header!.split('data=')[1]), (ch) => ch.charCodeAt(0))
|
|
||||||
expect(signature.length).toBe(256)
|
|
||||||
expect(signature.some((b) => b !== 0)).toBe(true)
|
|
||||||
|
|
||||||
// Still on the streaming path: the source etag survives and the bytes are the
|
|
||||||
// stored object, untouched.
|
|
||||||
expect(res.headers.get('etag')).toBeTruthy()
|
|
||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives the placeholder signature from the key, stably', async () => {
|
|
||||||
const sigFor = async (path: string) =>
|
|
||||||
(await unsignedFetch(`${ORIGIN}/${path}?sig=p1`)).headers.get('content-signature')
|
|
||||||
|
|
||||||
// Stable for a key, so a cached response and a fresh one agree...
|
|
||||||
expect(await sigFor(R2_KEY)).toBe(await sigFor(R2_KEY))
|
|
||||||
// ...and distinct across keys, so it isn't a single hardcoded constant.
|
|
||||||
expect(await sigFor(R2_KEY)).not.toBe(await sigFor(CDN_NAME))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('signs the fallback and resized bodies with a placeholder too', async () => {
|
|
||||||
// The fallback (missing key) and the transform path both go through
|
|
||||||
// serveStaticAsset/finalizeImage — the header must survive both.
|
|
||||||
const fallback = await unsignedFetch(`${ORIGIN}/missing.png?sig=p1`)
|
|
||||||
expect(fallback.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
|
|
||||||
|
|
||||||
const resized = await unsignedFetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`)
|
|
||||||
expect(resized.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
|
|
||||||
expect(jpegSize(new Uint8Array(await resized.arrayBuffer())).width).toBe(512)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resizes a static asset to ?width, preserving aspect ratio', async () => {
|
it('resizes a static asset to ?width, preserving aspect ratio', async () => {
|
||||||
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
||||||
const original = jpegSize(full)
|
const original = jpegSize(full)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
@@ -8,10 +8,6 @@ export default defineConfig({
|
|||||||
miniflare: {
|
miniflare: {
|
||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
ENVIRONMENT: 'VITEST',
|
||||||
// Signing is off in `wrangler.jsonc`; turn it on here so the `?sig=p1`
|
|
||||||
// path stays covered. The flag-off behaviour is tested by calling the
|
|
||||||
// app directly with an overridden env.
|
|
||||||
IMG_SIGNING_ENABLED: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
+1
-17
@@ -9,7 +9,7 @@
|
|||||||
// so image requests still hit R2/signing; assets are only fetched explicitly
|
// so image requests still hit R2/signing; assets are only fetched explicitly
|
||||||
// via the ASSETS binding.
|
// via the ASSETS binding.
|
||||||
"cache": {
|
"cache": {
|
||||||
"enabled": false
|
"enabled": true
|
||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"directory": "./static",
|
"directory": "./static",
|
||||||
@@ -17,18 +17,10 @@
|
|||||||
"run_worker_first": true
|
"run_worker_first": true
|
||||||
},
|
},
|
||||||
// Images are stored as objects in an R2 bucket and streamed back by key.
|
// Images are stored as objects in an R2 bucket and streamed back by key.
|
||||||
// `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound
|
|
||||||
// alongside it: uploads posted to `storage` as FileType 3 land under its
|
|
||||||
// `image/` prefix with no extension, and the client asks THIS worker for them
|
|
||||||
// by the bare name — see the extensionless-key branch in src/img.app.ts.
|
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "IMAGES",
|
"binding": "IMAGES",
|
||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
},
|
|
||||||
{
|
|
||||||
"binding": "CDN_ASSETS",
|
|
||||||
"bucket_name": "recflare-cdn"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
||||||
@@ -54,14 +46,6 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||||
// Feature flag for REAL `?sig=p1` signing. OFF: the client only needs a
|
|
||||||
// Content-Signature header to EXIST, and never checks it, while signing for
|
|
||||||
// real buffers the whole object into the isolate instead of streaming it from
|
|
||||||
// R2 and pays a SHA-1 over the full body plus an RSA-2048 private-key op on
|
|
||||||
// every edge-cache miss. So the header is filled with a placeholder derived
|
|
||||||
// from the object key (see stubSignature in src/img.app.ts). Flip to true if
|
|
||||||
// anything ever needs to verify it.
|
|
||||||
"IMG_SIGNING_ENABLED": false,
|
|
||||||
// RSA-2048 private key (PKCS8 DER, base64) used to sign image responses
|
// RSA-2048 private key (PKCS8 DER, base64) used to sign image responses
|
||||||
// requested with ?sig=p1. This is an INSECURE DEV KEY committed for local
|
// requested with ?sig=p1. This is an INSECURE DEV KEY committed for local
|
||||||
// dev / tests — override in production with `wrangler secret put IMG_SIGNING_KEY`.
|
// dev / tests — override in production with `wrangler secret put IMG_SIGNING_KEY`.
|
||||||
|
|||||||
+28
-56
@@ -6,31 +6,29 @@ instances and presence all live in the shared `recflare` D1 database.
|
|||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
| ------ | ------------------------------------ | ---- | ----------------------------------------------------- |
|
| ------ | ------------------------------------ | ---- | ------------------------------------------------ |
|
||||||
| POST | `/player/login` | | Login ack (no-op; must not touch presence) |
|
| POST | `/player/login` | | Login ack (no-op; must not touch presence) |
|
||||||
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
|
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
|
||||||
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
|
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
|
||||||
| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) |
|
| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) |
|
||||||
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
|
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
|
||||||
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
|
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
|
||||||
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
|
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
|
||||||
| GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` |
|
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
|
||||||
| PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value |
|
| POST | `/matchmake/none` | | Preserve current instance, else dorm |
|
||||||
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
|
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
|
||||||
| POST | `/matchmake/none` | | Preserve current instance, else dorm |
|
| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) |
|
||||||
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
|
| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) |
|
||||||
| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) |
|
| POST | `/goto/none` | | Go to the dorm |
|
||||||
| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) |
|
| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) |
|
||||||
| POST | `/goto/none` | | Go to the dorm |
|
| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) |
|
||||||
| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) |
|
| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) |
|
||||||
| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) |
|
| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag |
|
||||||
| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) |
|
| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) |
|
||||||
| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag |
|
| GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` |
|
||||||
| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) |
|
| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` |
|
||||||
| GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` |
|
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||||
| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` |
|
|
||||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
|
||||||
|
|
||||||
\* `logout` and `statusvisibility` read the token when present but never 401 — an
|
\* `logout` and `statusvisibility` read the token when present but never 401 — an
|
||||||
unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when
|
unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when
|
||||||
@@ -93,41 +91,15 @@ Several behaviours are load-bearing and reverse-engineered from the client:
|
|||||||
solo Orientation room) and only falls back to the dorm when the player has none.
|
solo Orientation room) and only falls back to the dorm when the player has none.
|
||||||
`goto/none` always goes to the dorm.
|
`goto/none` always goes to the dorm.
|
||||||
|
|
||||||
### Switching a room out (`ROOM_REDIRECTS`)
|
|
||||||
|
|
||||||
An operator can substitute one room for another at matchmake time — the way to replace a
|
|
||||||
stock RRO room, typically the Rec Center (room 2), with a room of their own without
|
|
||||||
touching the client. The knob is `RECFLARE_ROOM_REDIRECTS` in the root `.env` (see
|
|
||||||
`.env.example`), comma-separated `<fromRoomId>=<to>` pairs where `<to>` is a room id or
|
|
||||||
name: `2=MyHub`, or `2=100,3=MyHub`.
|
|
||||||
|
|
||||||
Substitution happens where a matchmake resolves a named room, so it covers every route
|
|
||||||
that names one — the two- and three-segment room matchmakes and a club's clubhouse — and
|
|
||||||
everything downstream (the ban check, presence, the visit count) sees only the room
|
|
||||||
actually entered. Matching is on the resolved room id, so asking by name (`RecCenter`)
|
|
||||||
substitutes the same as asking by id.
|
|
||||||
|
|
||||||
- **A requested subroom is dropped** when a substitution fires: the id addresses a subroom
|
|
||||||
of the room the client asked for, so entry falls back to the substitute's default one.
|
|
||||||
- **One hop only** — `2=3,3=2` swaps the two rooms rather than looping.
|
|
||||||
- **An unresolvable target leaves the original room in place** (logged), so a typo doesn't
|
|
||||||
make a room unreachable.
|
|
||||||
- **Following a friend and joining a specific instance are unaffected** — those enter a
|
|
||||||
live instance, which is already in whichever room it was created in.
|
|
||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
| -------------------------- | ------------- | ------------------------------------------------------------ |
|
| ------------ | ------------- | ------------------------------------------------------------ |
|
||||||
| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence |
|
| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence |
|
||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||||
| `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` |
|
|
||||||
|
|
||||||
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
|
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
|
||||||
this worker has no migrations of its own. The settings KV is owned by the
|
this worker has no migrations of its own.
|
||||||
`playersettings` worker; this worker touches exactly one key in it, the "avoid juniors"
|
|
||||||
preference, and its write merges (as that worker's own PUT does) so the rest of the
|
|
||||||
player's settings survive.
|
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
|
|||||||
@@ -20,36 +20,6 @@ export type Env = SharedHonoEnv & {
|
|||||||
* player. Used by `POST /invite` to deliver the game-invite message to the invitee.
|
* player. Used by `POST /invite` to deliver the game-invite message to the invitee.
|
||||||
*/
|
*/
|
||||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
/**
|
|
||||||
* The per-player settings map the `playersettings` worker owns (`player:<id>` → JSON
|
|
||||||
* `{ key: value }`). Read-only here, and only by `GET /player/avoidjuniors`: the
|
|
||||||
* "avoid juniors" preference is a matchmaking question the client asks this worker,
|
|
||||||
* but it is stored with the rest of the player's settings, not in presence.
|
|
||||||
*/
|
|
||||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
|
||||||
/**
|
|
||||||
* Room substitutions applied at matchmake time, as comma-separated `<fromRoomId>=<to>`
|
|
||||||
* pairs — e.g. `2=100` or `2=MyHub,3=100` — where `from` is the room id the client
|
|
||||||
* asks for and `to` is the room it actually enters (id or room name). Optional; unset
|
|
||||||
* means every matchmake enters the room it asked for.
|
|
||||||
*
|
|
||||||
* The point of it is swapping out a stock RRO room for a custom one: `2=MyHub` sends
|
|
||||||
* everyone who matchmakes into the Rec Center (room 2) to `MyHub` instead, without
|
|
||||||
* touching the client. See `roomRedirects` in match.app.ts.
|
|
||||||
*/
|
|
||||||
ROOM_REDIRECTS?: string
|
|
||||||
/**
|
|
||||||
* 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, 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 `auth` worker reads the same knob.
|
|
||||||
*/
|
|
||||||
BAN_EVASION_MATCH?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
+48
-654
@@ -3,11 +3,9 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Accessibility,
|
|
||||||
areFriends,
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
deleteEmptyRoomInstances,
|
|
||||||
deleteExpiredPresence,
|
deleteExpiredPresence,
|
||||||
deletePresence,
|
deletePresence,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
@@ -23,36 +21,22 @@ import {
|
|||||||
getRoomByName,
|
getRoomByName,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
getRoomInstancesByRoom,
|
getRoomInstancesByRoom,
|
||||||
getRoomInstanceSummariesByRoom,
|
|
||||||
isClubMember,
|
isClubMember,
|
||||||
isPlayerBannedFromRoom,
|
|
||||||
MatchmakingErrorCode,
|
|
||||||
MessageType,
|
MessageType,
|
||||||
recordRoomVisit,
|
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
setRoomInstancePrivate,
|
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its
|
|
||||||
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
|
|
||||||
// same way econ reads api's inventions-db).
|
|
||||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
|
||||||
// The player-event tables are the api worker's too (same plain-D1 shape as bans-db):
|
|
||||||
// `/matchmake/event` needs the event's room and the caller's invite row.
|
|
||||||
import { getEventById, getEventResponse } from '../../api/src/events-db'
|
|
||||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
AvoidJuniorsRequest,
|
|
||||||
AvoidJuniorsResponse,
|
|
||||||
EMPTY_OK,
|
EMPTY_OK,
|
||||||
ExclusiveLoginResponse,
|
ExclusiveLoginResponse,
|
||||||
form,
|
form,
|
||||||
@@ -66,7 +50,6 @@ import {
|
|||||||
NotifyDisconnectRequest,
|
NotifyDisconnectRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
RoomInstanceDto,
|
RoomInstanceDto,
|
||||||
RoomInstanceSummaryDto,
|
|
||||||
StatusVisibilityRequest,
|
StatusVisibilityRequest,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
@@ -139,110 +122,6 @@ function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The "avoid juniors" preference, spelled the way the client posts it — the key a NEW
|
|
||||||
* setting is written under, and the one every stored spelling is matched against.
|
|
||||||
*
|
|
||||||
* The player's settings are a free-form `{ key: value }` bag written by the client through
|
|
||||||
* the `playersettings` worker, and the exact spelling it writes this key under is
|
|
||||||
* reverse-engineered, so the lookup is case- and separator-insensitive (`avoidJuniors`,
|
|
||||||
* `AvoidJuniors`, `AVOID_JUNIORS` all resolve to this one preference) rather than betting on
|
|
||||||
* one casing and silently reading false forever if it's wrong. The write then overwrites
|
|
||||||
* whichever spelling is already there, so a player never ends up with two keys for the one
|
|
||||||
* preference — which would make the read depend on their order in the map.
|
|
||||||
*/
|
|
||||||
const AVOID_JUNIORS_KEY = 'avoidJuniors'
|
|
||||||
|
|
||||||
/** Lowercase and drop separators, so keys compare on their letters alone. */
|
|
||||||
function normalizeSettingKey(key: string): string {
|
|
||||||
return key.toLowerCase().replaceAll(/[^a-z0-9]/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The player's existing spelling of the setting key, if their map has one. */
|
|
||||||
function findAvoidJuniorsKey(stored: Record<string, unknown>): string | undefined {
|
|
||||||
const wanted = normalizeSettingKey(AVOID_JUNIORS_KEY)
|
|
||||||
return Object.keys(stored).find((key) => normalizeSettingKey(key) === wanted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings values are strings, so a boolean arrives as `True`/`false`/`1`/`0` (the client
|
|
||||||
* isn't consistent about which). `undefined` for anything unrecognized, which the read and
|
|
||||||
* the write treat differently: a stored value that won't parse is a false preference, but a
|
|
||||||
* posted one that won't parse is a body worth ignoring rather than a write of `false`.
|
|
||||||
*/
|
|
||||||
function parseSettingBool(value: unknown): boolean | undefined {
|
|
||||||
if (typeof value === 'boolean') return value
|
|
||||||
switch (String(value).trim().toLowerCase()) {
|
|
||||||
case 'true':
|
|
||||||
case '1':
|
|
||||||
case 'yes':
|
|
||||||
return true
|
|
||||||
case 'false':
|
|
||||||
case '0':
|
|
||||||
case 'no':
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The player's settings map from the KV the `playersettings` worker owns. */
|
|
||||||
async function getPlayerSettings(
|
|
||||||
env: Env,
|
|
||||||
accountId: number
|
|
||||||
): Promise<Record<string, string> | null> {
|
|
||||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
|
||||||
`player:${accountId}`,
|
|
||||||
'json'
|
|
||||||
).catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a player's "avoid juniors" preference. Absent settings, an absent key, and an
|
|
||||||
* unparseable value are all false: the client asks this before matchmaking, so a read that
|
|
||||||
* can't answer must not keep a player out of rooms.
|
|
||||||
*/
|
|
||||||
async function readAvoidJuniors(env: Env, accountId: number): Promise<boolean> {
|
|
||||||
const stored = await getPlayerSettings(env, accountId)
|
|
||||||
if (!stored) return false
|
|
||||||
|
|
||||||
const key = findAvoidJuniorsKey(stored)
|
|
||||||
return key === undefined ? false : (parseSettingBool(stored[key]) ?? false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a player's "avoid juniors" preference back into their settings map.
|
|
||||||
*
|
|
||||||
* The write MERGES, exactly as the `playersettings` worker's own PUT does: the map holds
|
|
||||||
* every setting the player has (OOBE state, tutorial mask, …), so storing this one on its
|
|
||||||
* own would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true of
|
|
||||||
* the settings worker, and two writers racing over one player's own settings means that
|
|
||||||
* player toggling two options in the same instant.
|
|
||||||
*/
|
|
||||||
async function writeAvoidJuniors(env: Env, accountId: number, value: boolean): Promise<void> {
|
|
||||||
const stored = (await getPlayerSettings(env, accountId)) ?? {}
|
|
||||||
const merged: Record<string, string> = { ...stored }
|
|
||||||
merged[findAvoidJuniorsKey(merged) ?? AVOID_JUNIORS_KEY] = value ? 'True' : 'False'
|
|
||||||
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The posted preference, out of a form (`avoidJuniors=True`, what the client sends) or a
|
|
||||||
* JSON body. The field name is matched the same loose way the stored key is, so the casing
|
|
||||||
* the client picks can't silently miss. `undefined` when the body carries no readable
|
|
||||||
* value — the caller leaves the setting alone rather than writing a guess.
|
|
||||||
*/
|
|
||||||
async function readAvoidJuniorsBody(c: Context<App>): Promise<boolean | undefined> {
|
|
||||||
const contentType = c.req.header('content-type') ?? ''
|
|
||||||
const body = contentType.includes('application/json')
|
|
||||||
? await c.req.json<unknown>().catch(() => null)
|
|
||||||
: await c.req.parseBody().catch(() => null)
|
|
||||||
if (body === null || typeof body !== 'object') return undefined
|
|
||||||
|
|
||||||
const key = findAvoidJuniorsKey(body as Record<string, unknown>)
|
|
||||||
return key === undefined ? undefined : parseSettingBool((body as Record<string, unknown>)[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A synthesized room instance (same shape for dorm and other rooms). */
|
/** A synthesized room instance (same shape for dorm and other rooms). */
|
||||||
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
|
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
|
||||||
|
|
||||||
@@ -350,8 +229,7 @@ async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store the room instance the player just matchmade into, preserving status, and count
|
* Store the room instance the player just matchmade into, preserving status.
|
||||||
* the visit against the room.
|
|
||||||
*
|
*
|
||||||
* With no live presence to carry forward (the player's first matchmake after login,
|
* With no live presence to carry forward (the player's first matchmake after login,
|
||||||
* or one after their presence lapsed) the device fields would otherwise default —
|
* or one after their presence lapsed) the device fields would otherwise default —
|
||||||
@@ -376,22 +254,6 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
// and the heartbeat can keep verifying against it.
|
// and the heartbeat can keep verifying against it.
|
||||||
loginLock: prev?.loginLock,
|
loginLock: prev?.loginLock,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Count the visit. Every matchmake route funnels through here with the instance the
|
|
||||||
// player landed in, and a matchmake is the only way into a room, so this is the one
|
|
||||||
// place a visit can be recorded once — whether they got here by room id, by subroom,
|
|
||||||
// by following a friend, from a club's clubhouse, or into their own dorm. Bumps the
|
|
||||||
// room's `visits` column, which is served as `Stats.VisitCount`. Best-effort: a
|
|
||||||
// counter is not worth failing the matchmake over.
|
|
||||||
try {
|
|
||||||
await recordRoomVisit(c.env.DB, roomInstance.roomId)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to record room visit', {
|
|
||||||
roomId: roomInstance.roomId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the destination instance's is_full flag in sync with live presence (the
|
// Keep the destination instance's is_full flag in sync with live presence (the
|
||||||
// player's own presence, just written, is counted). Then re-evaluate the
|
// player's own presence, just written, is counted). Then re-evaluate the
|
||||||
// instance they left — its head-count dropped — so a full room frees up when
|
// instance they left — its head-count dropped — so a full room frees up when
|
||||||
@@ -408,25 +270,8 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
await notifyFriendsPresence(c, id)
|
await notifyFriendsPresence(c, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returned when a room isn't in the DB — and for every other opaque refusal. */
|
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||||
const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom
|
const NO_SUCH_ROOM = 20
|
||||||
|
|
||||||
/**
|
|
||||||
* "You are banned from this room". Unlike the opaque NoSuchRoom every other refusal
|
|
||||||
* answers, a banned player is told why: they already know the room exists, so there's
|
|
||||||
* nothing to hide, and the client can say so instead of showing a room that
|
|
||||||
* mysteriously fails to load.
|
|
||||||
*/
|
|
||||||
const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
|
||||||
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
|
||||||
* reaching this already holds the event id from somewhere that showed it to them, so
|
|
||||||
* the only thing withholding the reason buys is a room that fails to load for no
|
|
||||||
* visible reason.
|
|
||||||
*/
|
|
||||||
const EVENT_IS_PRIVATE = MatchmakingErrorCode.EventIsPrivate
|
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
@@ -631,112 +476,25 @@ async function inviteParty(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
|
|
||||||
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
|
|
||||||
* there (NoSuchRoom) from one the caller is banned from — those answer different codes.
|
|
||||||
*/
|
|
||||||
type ResolvedInstance =
|
|
||||||
| { instance: RoomInstance; errorCode: MatchmakingErrorCode.Success }
|
|
||||||
| { instance: null; errorCode: MatchmakingErrorCode }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The operator's room substitutions, parsed from the `ROOM_REDIRECTS` var: a map of
|
|
||||||
* the room id the client asks for to the room it actually enters (id or room name).
|
|
||||||
* The var is comma-separated `<fromRoomId>=<to>` pairs, e.g. `2=MyHub,3=100`.
|
|
||||||
*
|
|
||||||
* Keyed on the source's numeric id rather than the path segment because the client can
|
|
||||||
* matchmake by either id or name (`/matchmake/room/2` and `/matchmake/room/RecCenter`
|
|
||||||
* are the same room), so the substitution is matched against the room D1 resolved —
|
|
||||||
* one entry then covers both spellings. Unparseable pairs are skipped rather than
|
|
||||||
* failing the matchmake: a typo in the knob must not take room entry down.
|
|
||||||
*/
|
|
||||||
function roomRedirects(env: Env): Map<number, string> {
|
|
||||||
const map = new Map<number, string>()
|
|
||||||
if (typeof env.ROOM_REDIRECTS !== 'string') return map
|
|
||||||
for (const pair of env.ROOM_REDIRECTS.split(',')) {
|
|
||||||
const eq = pair.indexOf('=')
|
|
||||||
if (eq === -1) continue
|
|
||||||
const from = Number(pair.slice(0, eq).trim())
|
|
||||||
const to = pair.slice(eq + 1).trim()
|
|
||||||
if (!Number.isInteger(from) || to === '') continue
|
|
||||||
map.set(from, to)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the operator's `ROOM_REDIRECTS` substitution to a room the client asked for.
|
|
||||||
* Answers the room to actually enter, plus the subroom to enter it by.
|
|
||||||
*
|
|
||||||
* A substituted room drops the requested subroom: the id the client sent addresses a
|
|
||||||
* subroom of the room it *asked* for, and the same number in the target room is a
|
|
||||||
* different place entirely (or nothing at all), so entry falls back to the target's
|
|
||||||
* default subroom. Substitution is a single hop — `2=3,3=2` swaps the two rooms rather
|
|
||||||
* than looping — and an unresolvable target leaves the original room in place, so a
|
|
||||||
* typo'd knob degrades to "no substitution" instead of a dead hub.
|
|
||||||
*/
|
|
||||||
async function substituteRoom(
|
|
||||||
c: Context<App>,
|
|
||||||
room: Room,
|
|
||||||
subRoomId?: number
|
|
||||||
): Promise<{ room: Room; subRoomId?: number }> {
|
|
||||||
const fromId = typeof room.RoomId === 'number' ? room.RoomId : NaN
|
|
||||||
const to = roomRedirects(c.env).get(fromId)
|
|
||||||
if (to === undefined) return { room, subRoomId }
|
|
||||||
|
|
||||||
const toId = Number.parseInt(to, 10)
|
|
||||||
const target = Number.isNaN(toId)
|
|
||||||
? await getRoomByName(c.env.DB, to)
|
|
||||||
: await getRoomById(c.env.DB, toId)
|
|
||||||
if (!target) {
|
|
||||||
logger.warn('room redirect target not found; entering the requested room', {
|
|
||||||
roomId: fromId,
|
|
||||||
target: to,
|
|
||||||
})
|
|
||||||
return { room, subRoomId }
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('room redirected', { roomId: fromId, target: to })
|
|
||||||
return { room: target, subRoomId: undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
||||||
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||||
* table) or create a new one. A null instance carries the error code to answer:
|
* table) or create a new one. Returns null when the room isn't found.
|
||||||
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
|
|
||||||
*
|
|
||||||
* Every matchmake that names a room lands here, so this is also where the operator's
|
|
||||||
* room substitutions apply (`ROOM_REDIRECTS`) — everything downstream, from the ban
|
|
||||||
* check to presence and the visit count, sees only the room actually entered.
|
|
||||||
*/
|
*/
|
||||||
async function resolveRoomInstance(
|
async function resolveRoomInstance(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
ownerId: number,
|
ownerId: number,
|
||||||
requestedSubRoomId?: number
|
subRoomId?: number
|
||||||
): Promise<ResolvedInstance> {
|
): Promise<RoomInstance | null> {
|
||||||
const id = Number.parseInt(roomKey, 10)
|
const id = Number.parseInt(roomKey, 10)
|
||||||
const requested = Number.isNaN(id)
|
const room = Number.isNaN(id)
|
||||||
? await getRoomByName(c.env.DB, roomKey)
|
? await getRoomByName(c.env.DB, roomKey)
|
||||||
: await getRoomById(c.env.DB, id)
|
: await getRoomById(c.env.DB, id)
|
||||||
if (!requested) return { instance: null, errorCode: NO_SUCH_ROOM }
|
if (!room) return null
|
||||||
|
|
||||||
const { room, subRoomId } = await substituteRoom(c, requested, requestedSubRoomId)
|
|
||||||
|
|
||||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||||
|
|
||||||
// A banned player never gets an instance. This is the whole enforcement of a room
|
|
||||||
// ban: the Photon room id only ever reaches a player through a matchmake, so
|
|
||||||
// refusing here means they have no coordinates to join or interact with. Handled
|
|
||||||
// before any instance is created or reused so a ban can't spawn one.
|
|
||||||
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
|
|
||||||
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
|
|
||||||
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never place the player back into the instance they're already in: the client
|
// Never place the player back into the instance they're already in: the client
|
||||||
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
||||||
// your current instance (e.g. the only public instance of a room you're already in)
|
// your current instance (e.g. the only public instance of a room you're already in)
|
||||||
@@ -767,16 +525,13 @@ async function resolveRoomInstance(
|
|||||||
roomInstanceType: f.roomInstanceType,
|
roomInstanceType: f.roomInstanceType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return {
|
return roomInstanceFromRoom(
|
||||||
instance: roomInstanceFromRoom(
|
room,
|
||||||
room,
|
isPrivate,
|
||||||
isPrivate,
|
instance.roomInstanceId,
|
||||||
instance.roomInstanceId,
|
instance.photonRoomId,
|
||||||
instance.photonRoomId,
|
f.subRoomId
|
||||||
f.subRoomId
|
)
|
||||||
),
|
|
||||||
errorCode: MatchmakingErrorCode.Success,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -819,45 +574,6 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(c, next)
|
||||||
)
|
)
|
||||||
|
|
||||||
// A banned player goes nowhere. Room bans are per-room and checked per route (they
|
|
||||||
// depend on which room you're entering); a BAN isn't about a room at all, so it's
|
|
||||||
// enforced once here, across every matchmake — by room, by subroom, by instance, into
|
|
||||||
// a club's clubhouse, following a friend, and into their own dorm. A gate rather than
|
|
||||||
// six copies of the same check: a route added later inherits it, and there is no
|
|
||||||
// matchmake left that hands a banned player Photon coordinates.
|
|
||||||
//
|
|
||||||
// `resolveBan` matches the caller's own account AND the accounts they share a proven
|
|
||||||
// platform identity or an IP with, so a ban survives the evader making a new account
|
|
||||||
// (see bans-db.ts; the operator narrows the linked arms with BAN_EVASION_MATCH). The
|
|
||||||
// arm that matched is logged, because "banned" and "shares a network with somebody
|
|
||||||
// banned" are very different things to be looking at in a log.
|
|
||||||
//
|
|
||||||
// It answers the same BannedFromRoom the room bans do. The code is per-room in name
|
|
||||||
// only — it's the one refusal the client renders as "you are banned" instead of a room
|
|
||||||
// that mysteriously fails to load, and it's what the enum offers.
|
|
||||||
//
|
|
||||||
// Unauthenticated requests fall through untouched: the route's own `authedId` answers
|
|
||||||
// 401, which mustn't turn into "banned" just because the token was missing.
|
|
||||||
.use('/matchmake/*', async (c, next) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id !== null) {
|
|
||||||
const match = await resolveBan(c.env.DB, id, {
|
|
||||||
identity: { ip: c.req.header('cf-connecting-ip') },
|
|
||||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
|
||||||
})
|
|
||||||
if (match) {
|
|
||||||
logger.info('matchmake refused: player banned', {
|
|
||||||
accountId: id,
|
|
||||||
via: match.via,
|
|
||||||
bannedAccountId: match.bannedAccountId,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -1127,68 +843,6 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The caller's "avoid juniors" preference. It's asked of this worker because it's a
|
|
||||||
// matchmaking question, but it isn't matchmaking state: the setting is written by the
|
|
||||||
// client through the `playersettings` worker, so this reads that worker's KV map
|
|
||||||
// directly (read-only) rather than keeping a second copy of the same toggle here.
|
|
||||||
.get(
|
|
||||||
'/player/avoidjuniors',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Player settings'],
|
|
||||||
summary: 'The player’s “avoid juniors” preference',
|
|
||||||
description: [
|
|
||||||
'Whether the authenticated player asked to be kept away from junior accounts, read',
|
|
||||||
'from their settings map in the `playersettings` KV. The body is a bare JSON boolean',
|
|
||||||
'(`true`/`false`), not an envelope. A player who never set it reads `false`.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
responses: {
|
|
||||||
200: json(AvoidJuniorsResponse, 'The preference; `false` when never set'),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
return c.json(await readAvoidJuniors(c.env, id))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set the preference. Answers the RESULTING value rather than an empty ack, the way the
|
|
||||||
// GET does — the client has just changed a toggle it renders, and a body it can read
|
|
||||||
// back can't disagree with what was stored.
|
|
||||||
.put(
|
|
||||||
'/player/avoidjuniors',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Player settings'],
|
|
||||||
summary: 'Set the player’s “avoid juniors” preference',
|
|
||||||
description: [
|
|
||||||
'Stores the posted preference in the authenticated player’s settings map (the',
|
|
||||||
'`playersettings` KV) and answers the resulting value as a bare JSON boolean. The',
|
|
||||||
'write merges, so the player’s other settings are left alone. A body with no readable',
|
|
||||||
'`avoidJuniors` value leaves the setting as it was and answers the stored value — a',
|
|
||||||
'no-op 200, not a 400.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
requestBody: form(AvoidJuniorsRequest, 'The preference to store'),
|
|
||||||
responses: {
|
|
||||||
200: json(AvoidJuniorsResponse, 'The preference now stored'),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
const posted = await readAvoidJuniorsBody(c)
|
|
||||||
if (posted === undefined) return c.json(await readAvoidJuniors(c.env, id))
|
|
||||||
|
|
||||||
await writeAvoidJuniors(c.env, id, posted)
|
|
||||||
return c.json(posted)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Room navigation -----------------------------------------------------
|
// ---- Room navigation -----------------------------------------------------
|
||||||
// Each matchmake persists the resulting instance as the player's presence so the
|
// Each matchmake persists the resulting instance as the player's presence so the
|
||||||
// heartbeat can replay it (keeping client presence in sync).
|
// heartbeat can replay it (keeping client presence in sync).
|
||||||
@@ -1205,8 +859,7 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Looks the club up, checks the caller is a member of it, and places them into an',
|
'Looks the club up, checks the caller is a member of it, and places them into an',
|
||||||
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
||||||
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
'club is unknown, has no clubhouse set, or the caller isn’t a member.',
|
||||||
'55 when they are banned from the clubhouse room.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||||
@@ -1222,7 +875,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)'
|
'The clubhouse instance (or errorCode 20 with null when it can’t be entered)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -1242,100 +895,18 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
const { instance, errorCode } = await resolveRoomInstance(
|
const instance = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
String(club.clubhouseRoomId),
|
String(club.clubhouseRoomId),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Matchmake into a player event (`/matchmake/event/{playerEventId}`) — the "join" on
|
|
||||||
// an event. The event names the room (and optionally the subroom) to enter, so this
|
|
||||||
// is a room matchmake behind an access check on the EVENT.
|
|
||||||
.post(
|
|
||||||
'/matchmake/event/:eventId{[0-9]+}',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Navigation'],
|
|
||||||
summary: 'Matchmake into a player event',
|
|
||||||
description: [
|
|
||||||
'Places the caller into an instance of the event’s room — its subroom too, when the',
|
|
||||||
'event pins one. Who may join: anyone, if the event is Public (1) or Unlisted (2),',
|
|
||||||
'since unlisted only keeps an event out of the listings rather than closing it; and',
|
|
||||||
'otherwise only the event’s creator or a player who has been invited to it (any',
|
|
||||||
'`event_attendee` row, whatever their answer — being able to decline and change your',
|
|
||||||
'mind is the point). Everyone else gets errorCode 35 (EventIsPrivate) with a null',
|
|
||||||
'instance; an unknown event is the opaque errorCode 20, and 55 when the caller is',
|
|
||||||
'banned from the room the event runs in.',
|
|
||||||
'',
|
|
||||||
'The event’s start and end times are NOT enforced — the reference has codes for',
|
|
||||||
'both (4 EventNotStarted, 5 EventAlreadyFinished) but nothing here has been observed',
|
|
||||||
'sending them, and locking a creator out of their own room before the hour would be',
|
|
||||||
'worse than letting people in early.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'eventId',
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
description: 'Player event id (digits only)',
|
|
||||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: json(
|
|
||||||
MatchmakeResponse,
|
|
||||||
'The event’s instance (or a null instance with errorCode 20 / 35 / 55)'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
|
||||||
const event = await getEventById(c.env.DB, eventId)
|
|
||||||
// Opaque, like the club path: an unknown event and one the caller can't see
|
|
||||||
// shouldn't be distinguishable by probing ids.
|
|
||||||
if (event === null) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
|
||||||
|
|
||||||
const open =
|
|
||||||
event.Accessibility === Accessibility.Public ||
|
|
||||||
event.Accessibility === Accessibility.Unlisted
|
|
||||||
// An `event_attendee` row is the invite: bulkInvite writes one, and so does
|
|
||||||
// responding, so anyone who was invited or answered passes. The creator is checked
|
|
||||||
// separately so an event whose creator deleted their own response still lets them in.
|
|
||||||
if (
|
|
||||||
!open &&
|
|
||||||
event.CreatorPlayerId !== id &&
|
|
||||||
(await getEventResponse(c.env.DB, eventId, id)) === null
|
|
||||||
) {
|
|
||||||
logger.info('matchmake refused: not invited to private event', { eventId, id })
|
|
||||||
return c.json({ errorCode: EVENT_IS_PRIVATE, roomInstance: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
|
||||||
const { instance, errorCode } = await resolveRoomInstance(
|
|
||||||
c,
|
|
||||||
String(event.RoomId),
|
|
||||||
joinMode === 2,
|
|
||||||
id,
|
|
||||||
event.SubRoomId ?? undefined
|
|
||||||
)
|
|
||||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
|
||||||
await enterRoom(c, id, instance)
|
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends
|
// Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends
|
||||||
// ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise
|
// ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise
|
||||||
// anyone could read a player's presence and warp to them. Reads the friend's current
|
// anyone could read a player's presence and warp to them. Reads the friend's current
|
||||||
@@ -1354,9 +925,7 @@ const app = new Hono<App>()
|
|||||||
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||||
'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is',
|
'caller themselves, or isn’t currently in a room.',
|
||||||
'banned from the room the friend is in — this path hands out join coordinates without',
|
|
||||||
'going through the room resolver, so it carries its own ban check.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1371,7 +940,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -1392,14 +961,6 @@ const app = new Hono<App>()
|
|||||||
const instance = targetPresence?.roomInstance ?? null
|
const instance = targetPresence?.roomInstance ?? null
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
|
||||||
// This path hands out a Photon room id without going through
|
|
||||||
// resolveRoomInstance, so the room's bans have to be checked here too —
|
|
||||||
// otherwise following a friend in is a way around a ban.
|
|
||||||
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
|
||||||
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
|
|
||||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join that same instance (same id + Photon room) and store it as the caller's
|
// Join that same instance (same id + Photon room) and store it as the caller's
|
||||||
// presence, so the heartbeat replays it and their own friend fan-out fires.
|
// presence, so the heartbeat replays it and their own friend fan-out fires.
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
@@ -1407,93 +968,6 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
|
|
||||||
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
|
|
||||||
// where they pick a session of their room and drop into it. Unlike every other
|
|
||||||
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
|
|
||||||
// and a full or in-progress instance is still entered (moderating a full instance
|
|
||||||
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
|
|
||||||
// listing — the Photon room id is the join coordinate, so an open version of this
|
|
||||||
// would let anyone warp into any private session by guessing an id. Registered
|
|
||||||
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
|
|
||||||
.post(
|
|
||||||
'/matchmake/instance/:instanceId{[0-9]+}',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Navigation'],
|
|
||||||
summary: 'Join a specific instance (owner only)',
|
|
||||||
description: [
|
|
||||||
'Places the caller into one specific live instance of their own room, picked by id',
|
|
||||||
'from the owner’s instance listing. Gated to the room’s creator or a co-owner.',
|
|
||||||
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
|
|
||||||
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
|
|
||||||
'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55',
|
|
||||||
'when banned.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'instanceId',
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
description: 'Room instance id (digits only)',
|
|
||||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: json(
|
|
||||||
MatchmakeResponse,
|
|
||||||
'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
|
|
||||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
|
||||||
// One opaque refusal for "no such instance", "no such room" and "not yours":
|
|
||||||
// a distinct code for the last would confirm which instance ids are live.
|
|
||||||
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
|
||||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
|
||||||
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
|
||||||
if (!canManageRoom(room, id)) {
|
|
||||||
logger.info('instance matchmake refused: not the room’s owner', {
|
|
||||||
roomInstanceId: instanceId,
|
|
||||||
roomId: stored.roomId,
|
|
||||||
accountId: id,
|
|
||||||
})
|
|
||||||
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Like the follow-a-friend path, this hands out a Photon room id without going
|
|
||||||
// through resolveRoomInstance, so the room's bans are checked here too. An owner
|
|
||||||
// can't ban themselves out of their own room in practice, but a co-owner can be
|
|
||||||
// banned, and a ban must beat every route that yields join coordinates.
|
|
||||||
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
|
|
||||||
logger.info('instance matchmake refused: player banned from room', {
|
|
||||||
roomId: stored.roomId,
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
|
|
||||||
// this instance's own id and Photon room, so the owner lands in exactly the
|
|
||||||
// session they picked rather than a new one alongside it.
|
|
||||||
const instance = roomInstanceFromRoom(
|
|
||||||
room,
|
|
||||||
stored.isPrivate,
|
|
||||||
stored.roomInstanceId,
|
|
||||||
stored.photonRoomId,
|
|
||||||
stored.subRoomId
|
|
||||||
)
|
|
||||||
await enterRoom(c, id, instance)
|
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
||||||
// — the client uses this to enter a room's other scenes). The subroom decides the
|
// — the client uses this to enter a room's other scenes). The subroom decides the
|
||||||
// scene the client loads and which instances are joinable, so it must be carried
|
// scene the client loads and which instances are joinable, so it must be carried
|
||||||
@@ -1520,10 +994,7 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||||
MatchmakeResponse,
|
|
||||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1532,14 +1003,14 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
const { instance, errorCode } = await resolveRoomInstance(
|
const instance = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
c.req.param('roomId'),
|
c.req.param('roomId'),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id,
|
id,
|
||||||
subRoomId
|
subRoomId
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
@@ -1562,10 +1033,7 @@ const app = new Hono<App>()
|
|||||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||||
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||||
MatchmakeResponse,
|
|
||||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
|
||||||
),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1573,13 +1041,8 @@ const app = new Hono<App>()
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const { instance, errorCode } = await resolveRoomInstance(
|
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
||||||
c,
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
c.req.param('roomId'),
|
|
||||||
joinMode === 2,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
@@ -1594,12 +1057,11 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
||||||
'client only ever calls this with the `dorm` keyword — real rooms go through',
|
'client only ever calls this with the `dorm` keyword — real rooms go through',
|
||||||
'`/matchmake/room/:roomId`. Returns errorCode 55 with a null instance when the',
|
'`/matchmake/room/:roomId`.',
|
||||||
'account is banned: a ban keeps a player out of their own dorm too.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The dorm instance (or a null instance with errorCode 55)'),
|
200: json(MatchmakeResponse, 'The player’s personal dorm instance'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1703,20 +1165,16 @@ const app = new Hono<App>()
|
|||||||
(c) => c.body(null, 200)
|
(c) => c.body(null, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
|
// The room owner flips the instance's in-progress flag once the session starts
|
||||||
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
|
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
|
||||||
// this is set by whoever in the room starts the game, not by the room's owner — a
|
|
||||||
// gate here would break game starts for everyone else. Body is a form post:
|
|
||||||
// `inProgress=True|False`.
|
|
||||||
.put(
|
.put(
|
||||||
'/roominstance/:id/inprogress',
|
'/roominstance/:id/inprogress',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'Set instance in-progress flag',
|
summary: 'Set instance in-progress flag',
|
||||||
description: [
|
description: [
|
||||||
'Flips the instance’s in-progress flag when a session starts (e.g. a round begins).',
|
'The room owner flips the instance’s in-progress flag when a session starts (e.g. a',
|
||||||
'Set by whoever in the room starts the game — any authenticated player, not just the',
|
'round begins). Body is `inProgress=True|False`.',
|
||||||
'room’s owner. Body is `inProgress=True|False`.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
||||||
@@ -1744,72 +1202,18 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
|
|
||||||
// makes the session they're running private, so public matchmaking stops feeding new
|
|
||||||
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
|
||||||
// already inside stays put; this shuts the door rather than clearing the room.
|
|
||||||
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
|
|
||||||
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
|
|
||||||
.post(
|
|
||||||
'/roominstance/:id/markprivate',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Room instance'],
|
|
||||||
summary: 'Mark an instance private (owner only)',
|
|
||||||
description: [
|
|
||||||
'Marks a live instance private, so public matchmaking stops placing new players',
|
|
||||||
'into it. Players already inside are unaffected. Auth-gated and gated to the',
|
|
||||||
'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'id',
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
description: 'Room instance id',
|
|
||||||
schema: { type: 'string' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
200: EMPTY_OK,
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
|
||||||
404: { description: 'Non-numeric id or no such instance (empty body)' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
|
|
||||||
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
|
||||||
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
|
||||||
|
|
||||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
|
||||||
if (!stored) return c.body(null, 404)
|
|
||||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
|
||||||
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
|
|
||||||
|
|
||||||
await setRoomInstancePrivate(c.env.DB, instanceId, true)
|
|
||||||
return c.body(null, 200)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// The room's live instances — the owner's view of active sessions of their room.
|
// The room's live instances — the owner's view of active sessions of their room.
|
||||||
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
||||||
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
|
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
|
||||||
// summary per instance (empty when the room has no live instances) — id, subroom,
|
// bare RoomInstance DTO array (empty when the room has no live instances).
|
||||||
// fullness, creation time and who's currently in it — not the client's
|
|
||||||
// RoomInstance DTO: this is a management listing, so it answers "who's in there"
|
|
||||||
// and withholds the connection details of a session the owner isn't joining.
|
|
||||||
.get(
|
.get(
|
||||||
'/room/:roomId{[0-9]+}/instances',
|
'/room/:roomId{[0-9]+}/instances',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'A room’s live instances',
|
summary: 'A room’s live instances',
|
||||||
description: [
|
description: [
|
||||||
'The owner’s view of active sessions of their room — each instance with the',
|
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
||||||
'players currently in it. Auth-gated and gated to the room’s creator or a',
|
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
||||||
'co-owner (403 otherwise). Unknown room → 404.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1822,7 +1226,7 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
|
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
404: { description: 'No such room (empty body)' },
|
404: { description: 'No such room (empty body)' },
|
||||||
@@ -1839,7 +1243,7 @@ const app = new Hono<App>()
|
|||||||
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
if (!canManageRoom(room, id)) return c.body(null, 403)
|
||||||
|
|
||||||
return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId))
|
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1869,33 +1273,24 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron: sweep presence that has aged past its TTL, then the instances left empty.
|
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
|
||||||
*
|
* so this isn't about correctness of `/player` — it's that a player who crashed or
|
||||||
* The presence purge isn't about correctness of `/player` — reads already ignore
|
* hard-quit never matchmakes out of their instance, so nothing recomputes that
|
||||||
* expired rows. It's that a player who crashed or hard-quit never matchmakes out of
|
* instance's fullness and it can stay flagged full (and unjoinable) with nobody in it.
|
||||||
* their instance, so nothing recomputes that instance's fullness and it can stay
|
* Recompute the instances the expiring rows point at, *then* delete: the sweep is the
|
||||||
* flagged full (and unjoinable) with nobody in it. Note the instances the expiring
|
* only thing that notices those departures. Fullness is recomputed after the delete so
|
||||||
* rows point at *before* deleting: the sweep is the only thing that notices those
|
* the head-count no longer sees them.
|
||||||
* departures.
|
|
||||||
*
|
|
||||||
* Emptying an instance is what makes it garbage — nothing ever reuses it, and a
|
|
||||||
* joiner handed one would land alone in a Photon room everyone left — so the empty
|
|
||||||
* sweep runs next. It reads presence without consulting expiry, so it depends on
|
|
||||||
* running after the purge above: this order is what makes a lapsed row count as a
|
|
||||||
* departure. Fullness is recomputed last, so it works from the final head-count and
|
|
||||||
* skips (returns null for) the instances just deleted.
|
|
||||||
*/
|
*/
|
||||||
async function sweepExpiredPresence(env: Env): Promise<void> {
|
async function sweepExpiredPresence(env: Env): Promise<void> {
|
||||||
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
||||||
const removed = await deleteExpiredPresence(env.DB)
|
const removed = await deleteExpiredPresence(env.DB)
|
||||||
const emptyInstanceIds = await deleteEmptyRoomInstances(env.DB)
|
|
||||||
for (const instanceId of staleInstanceIds) {
|
for (const instanceId of staleInstanceIds) {
|
||||||
await refreshInstanceFullness(env.DB, instanceId)
|
await refreshInstanceFullness(env.DB, instanceId)
|
||||||
}
|
}
|
||||||
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
||||||
// log plainly here — Workers observability picks it up either way.
|
// log plainly here — Workers observability picks it up either way.
|
||||||
console.log(
|
console.log(
|
||||||
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances`
|
`presence sweep: removed ${removed} expired rows, refreshed ${staleInstanceIds.length} instances`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1916,8 +1311,7 @@ app.get(
|
|||||||
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
|
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
|
||||||
'`room_instance` per session); presence — the instance each player is currently in —',
|
'`room_instance` per session); presence — the instance each player is currently in —',
|
||||||
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
|
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
|
||||||
'expired presence, frees up instances a crashed player never left, and deletes',
|
'expired presence and frees up instances a crashed player never left.',
|
||||||
'instances nobody is standing in any more.',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
|
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
|
||||||
|
|||||||
@@ -95,22 +95,6 @@ export const RoomInstanceDto = z.object({
|
|||||||
EncryptVoiceChat: z.boolean(),
|
EncryptVoiceChat: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
|
|
||||||
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
|
|
||||||
* details (photon ids, data blob, room code) of a session the owner isn't in.
|
|
||||||
*/
|
|
||||||
export const RoomInstanceSummaryDto = z.object({
|
|
||||||
roomInstanceId: z.int(),
|
|
||||||
roomId: z.int(),
|
|
||||||
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
|
|
||||||
isFull: z.boolean(),
|
|
||||||
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
|
|
||||||
playerIds: z
|
|
||||||
.array(z.int())
|
|
||||||
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
|
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
|
||||||
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
|
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
|
||||||
@@ -144,29 +128,10 @@ export const PlayerDto = z.object({
|
|||||||
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
||||||
*/
|
*/
|
||||||
export const MatchmakeResponse = z.object({
|
export const MatchmakeResponse = z.object({
|
||||||
errorCode: z
|
errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'),
|
||||||
.int()
|
|
||||||
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
|
|
||||||
roomInstance: RoomInstanceDto.nullable(),
|
roomInstance: RoomInstanceDto.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /player/avoidjuniors` — a BARE JSON boolean (`true`/`false`), not an envelope and
|
|
||||||
* not a `{ value }` wrapper. The whole body is the preference.
|
|
||||||
*/
|
|
||||||
export const AvoidJuniorsResponse = z
|
|
||||||
.boolean()
|
|
||||||
.describe('Whether the player asked to be kept away from junior accounts')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `PUT /player/avoidjuniors` form body. The client posts `avoidJuniors=True`; the field is
|
|
||||||
* matched case-insensitively and `True`/`false`/`1`/`0`/`yes`/`no` all parse, since neither
|
|
||||||
* the casing nor the spelling of the boolean is guaranteed across the client's surfaces.
|
|
||||||
*/
|
|
||||||
export const AvoidJuniorsRequest = z.object({
|
|
||||||
avoidJuniors: z.string().describe('`True`/`False` (also `1`/`0`, `yes`/`no`)'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** `POST /player/exclusivelogin` — a bare error code. */
|
/** `POST /player/exclusivelogin` — a bare error code. */
|
||||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
||||||
|
|
||||||
|
|||||||
@@ -11,23 +11,14 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
countPlayersInInstance,
|
countPlayersInInstance,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
EMPTY_INSTANCE_GRACE_SECONDS,
|
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
|
||||||
import {
|
|
||||||
banFromReport,
|
|
||||||
createReport,
|
|
||||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
|
||||||
} from '../../../../api/src/reports-db'
|
|
||||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
|
||||||
import { scheduled } from '../../match.app'
|
import { scheduled } from '../../match.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -92,9 +83,15 @@ const TEST_ROOMS = [
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
await env.DB.prepare(
|
||||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
`CREATE TABLE IF NOT EXISTS room (
|
||||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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,
|
||||||
|
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
||||||
|
)`
|
||||||
|
).run()
|
||||||
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
||||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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>)
|
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
@@ -151,49 +148,6 @@ beforeAll(async () => {
|
|||||||
insertMember.bind(5, 120, 100),
|
insertMember.bind(5, 120, 100),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Player-event tables (owned by the api worker) — matchmake/event reads the event
|
|
||||||
// for its room and the caller's invite row for access.
|
|
||||||
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
const insertEvent = env.DB.prepare('INSERT OR IGNORE INTO event (data) VALUES (?1)')
|
|
||||||
const event = (id: number, accessibility: number, extra?: Record<string, unknown>) =>
|
|
||||||
JSON.stringify({
|
|
||||||
PlayerEventId: id,
|
|
||||||
CreatorPlayerId: 300,
|
|
||||||
ImageName: null,
|
|
||||||
RoomId: 2,
|
|
||||||
SubRoomId: null,
|
|
||||||
ClubId: null,
|
|
||||||
Name: `Event ${id}`,
|
|
||||||
Description: '',
|
|
||||||
StartTime: '2020-11-29T22:00:00Z',
|
|
||||||
EndTime: '2020-11-29T23:00:00Z',
|
|
||||||
AttendeeCount: 1,
|
|
||||||
State: 0,
|
|
||||||
Accessibility: accessibility,
|
|
||||||
IsMultiInstance: false,
|
|
||||||
SupportMultiInstanceRoomChat: false,
|
|
||||||
DefaultBroadcastPermissions: 0,
|
|
||||||
CanRequestBroadcastPermissions: 0,
|
|
||||||
...extra,
|
|
||||||
})
|
|
||||||
await env.DB.batch([
|
|
||||||
insertEvent.bind(event(8, 0)), // private
|
|
||||||
insertEvent.bind(event(9, 1)), // public
|
|
||||||
insertEvent.bind(event(10, 2)), // unlisted — listings only, still joinable
|
|
||||||
// A private one in the two-subroom room, pinning the SECOND subroom.
|
|
||||||
insertEvent.bind(event(11, 0, { RoomId: 77, SubRoomId: 35 })),
|
|
||||||
])
|
|
||||||
const insertAttendee = env.DB.prepare(
|
|
||||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
|
||||||
VALUES (?1, ?2, ?3, '2020-11-29T21:00:00Z')`
|
|
||||||
)
|
|
||||||
await env.DB.batch([
|
|
||||||
insertAttendee.bind(8, 300, 0), // the creator, Going from create
|
|
||||||
insertAttendee.bind(8, 301, 0), // invited
|
|
||||||
insertAttendee.bind(8, 302, 2), // invited, but declined — still allowed in
|
|
||||||
insertAttendee.bind(11, 301, 0),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Relationship table (owned by the api worker) — matchmake reads it to push a
|
// Relationship table (owned by the api worker) — matchmake reads it to push a
|
||||||
// presence update to the player's friends. Seed friendships for player 9700.
|
// presence update to the player's friends. Seed friendships for player 9700.
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
@@ -212,24 +166,8 @@ beforeAll(async () => {
|
|||||||
insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester
|
insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester
|
||||||
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
|
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
|
||||||
])
|
])
|
||||||
|
|
||||||
// Report table (owned by the api worker) — an account-wide ban is a report row with
|
|
||||||
// `banned` set, and every matchmake is refused for a player who has one.
|
|
||||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
|
||||||
// accounts sharing a proven identity with the banned one.
|
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Ban a player account-wide the way a moderator would: file a report against them and
|
|
||||||
* convert it. `banExpires` null is a permanent ban.
|
|
||||||
*/
|
|
||||||
async function banAccount(playerId: number, banExpires: string | null = null): Promise<void> {
|
|
||||||
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
|
|
||||||
await banFromReport(env.DB, row.id, { banExpires })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||||
// match worker's validation accepts it. Kept inline to avoid a cross-package
|
// match worker's validation accepts it. Kept inline to avoid a cross-package
|
||||||
// import.
|
// import.
|
||||||
@@ -323,145 +261,6 @@ describe('public endpoints', () => {
|
|||||||
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
|
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
|
||||||
})
|
})
|
||||||
|
|
||||||
// The "avoid juniors" preference lives in the playersettings KV map, not in presence.
|
|
||||||
// The body is a BARE boolean — the client reads the whole body as the value.
|
|
||||||
describe('GET /player/avoidjuniors', () => {
|
|
||||||
const settings = async (playerId: number, map: Record<string, string>) =>
|
|
||||||
env.RECFLARE_PLAYER_SETTINGS.put(`player:${playerId}`, JSON.stringify(map))
|
|
||||||
|
|
||||||
const read = async (playerId: number) => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
|
||||||
headers: await bearer(String(playerId)),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('reads the stored setting', async () => {
|
|
||||||
await settings(3100, { avoidJuniors: 'True', 'Recroom.OOBE': '77' })
|
|
||||||
expect(await read(3100)).toBe(true)
|
|
||||||
|
|
||||||
await settings(3101, { avoidJuniors: 'False' })
|
|
||||||
expect(await read(3101)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('the key match ignores casing and separators', async () => {
|
|
||||||
await settings(3102, { AVOID_JUNIORS: '1' })
|
|
||||||
expect(await read(3102)).toBe(true)
|
|
||||||
|
|
||||||
await settings(3103, { avoidjuniors: 'yes' })
|
|
||||||
expect(await read(3103)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// A player who never touched the setting, and one whose value is junk, both read
|
|
||||||
// false — the read gates matchmaking, so it must not fail closed.
|
|
||||||
test('defaults to false when unset or unparseable', async () => {
|
|
||||||
expect(await read(3104)).toBe(false)
|
|
||||||
|
|
||||||
await settings(3105, { 'Recroom.OOBE': '77' })
|
|
||||||
expect(await read(3105)).toBe(false)
|
|
||||||
|
|
||||||
await settings(3106, { avoidJuniors: 'maybe' })
|
|
||||||
expect(await read(3106)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is auth-gated', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`)
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PUT /player/avoidjuniors', () => {
|
|
||||||
const stored = async (playerId: number) =>
|
|
||||||
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')
|
|
||||||
|
|
||||||
const write = async (playerId: number, body: string) => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...(await bearer(String(playerId))),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
const read = async (playerId: number) => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
|
||||||
headers: await bearer(String(playerId)),
|
|
||||||
})
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
// The body the client posts. The response is the resulting value, and the GET agrees.
|
|
||||||
test('stores the posted preference and answers it', async () => {
|
|
||||||
expect(await write(3200, 'avoidJuniors=True')).toBe(true)
|
|
||||||
expect(await read(3200)).toBe(true)
|
|
||||||
|
|
||||||
expect(await write(3200, 'avoidJuniors=False')).toBe(false)
|
|
||||||
expect(await read(3200)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// The map holds every setting the player has, so the write must not replace it.
|
|
||||||
test('merges into the player’s other settings', async () => {
|
|
||||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
|
||||||
'player:3201',
|
|
||||||
JSON.stringify({ 'Recroom.OOBE': '77', TUTORIAL_COMPLETE_MASK: '11' })
|
|
||||||
)
|
|
||||||
await write(3201, 'avoidJuniors=True')
|
|
||||||
expect(await stored(3201)).toEqual({
|
|
||||||
'Recroom.OOBE': '77',
|
|
||||||
TUTORIAL_COMPLETE_MASK: '11',
|
|
||||||
avoidJuniors: 'True',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Whichever spelling the player's map already carries is the one overwritten —
|
|
||||||
// two keys for one preference would make the read depend on their order.
|
|
||||||
test('overwrites an existing key rather than adding a second one', async () => {
|
|
||||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
|
||||||
'player:3202',
|
|
||||||
JSON.stringify({ AVOID_JUNIORS: 'True' })
|
|
||||||
)
|
|
||||||
expect(await write(3202, 'avoidJuniors=False')).toBe(false)
|
|
||||||
expect(await stored(3202)).toEqual({ AVOID_JUNIORS: 'False' })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('accepts a JSON body', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...(await bearer('3203')),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ avoidJuniors: true }),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toBe(true)
|
|
||||||
expect(await read(3203)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// An unreadable body leaves the stored setting alone and answers it — a no-op 200,
|
|
||||||
// not a 400 and not a write of `false`.
|
|
||||||
test('a body with no readable value is a no-op', async () => {
|
|
||||||
await write(3204, 'avoidJuniors=True')
|
|
||||||
expect(await write(3204, 'avoidJuniors=maybe')).toBe(true)
|
|
||||||
expect(await write(3204, '')).toBe(true)
|
|
||||||
expect(await stored(3204)).toEqual({ avoidJuniors: 'True' })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is auth-gated', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: 'avoidJuniors=True',
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
|
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
|
||||||
const headers = await bearer('88')
|
const headers = await bearer('88')
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||||
@@ -483,46 +282,6 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a matchmake counts a visit against the room', async () => {
|
|
||||||
const visits = async (roomId: number): Promise<number> =>
|
|
||||||
(await env.DB.prepare('SELECT visits FROM room WHERE room_id = ?1')
|
|
||||||
.bind(roomId)
|
|
||||||
.first<{ visits: number }>())!.visits
|
|
||||||
const enter = async (path: string, player: string) => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(await bearer(player)),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counted per matchmake, whichever route got the player there — the two-segment
|
|
||||||
// room form and the subroom form both land in room 77.
|
|
||||||
const before = await visits(77)
|
|
||||||
await enter('/matchmake/room/77', '94')
|
|
||||||
expect(await visits(77)).toBe(before + 1)
|
|
||||||
await enter('/matchmake/room/77/35', '95')
|
|
||||||
expect(await visits(77)).toBe(before + 2)
|
|
||||||
|
|
||||||
// Same player entering again is another visit (VisitCount is visits, not visitors),
|
|
||||||
// and it's the entered room that's counted — not every room.
|
|
||||||
const otherBefore = await visits(2)
|
|
||||||
await enter('/matchmake/room/77', '94')
|
|
||||||
expect(await visits(77)).toBe(before + 3)
|
|
||||||
expect(await visits(2)).toBe(otherBefore)
|
|
||||||
|
|
||||||
// A refused matchmake counts nothing: an unknown room has no row to bump.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('96'),
|
|
||||||
})
|
|
||||||
expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
|
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
|
||||||
// A screen player (deviceClass 2, recorded by auth at login) matchmaking with no
|
// A screen player (deviceClass 2, recorded by auth at login) matchmaking with no
|
||||||
// live presence: without the account fallback they'd enter the room as deviceClass
|
// live presence: without the account fallback they'd enter the room as deviceClass
|
||||||
@@ -697,80 +456,6 @@ describe('public endpoints', () => {
|
|||||||
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
|
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /matchmake/event/:eventId gates a private event on the invite list', async () => {
|
|
||||||
const matchmake = async (path: string, sub?: string) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(sub === undefined ? {} : await bearer(sub)),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: 'JoinMode=0',
|
|
||||||
})
|
|
||||||
type Body = {
|
|
||||||
errorCode: number
|
|
||||||
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
|
|
||||||
}
|
|
||||||
const join = async (path: string, sub?: string) =>
|
|
||||||
(await (await matchmake(path, sub)).json()) as Body
|
|
||||||
|
|
||||||
// An invited player lands in an instance of the event's room (2)...
|
|
||||||
const invited = await join('/matchmake/event/8', '301')
|
|
||||||
expect(invited.errorCode).toBe(0)
|
|
||||||
expect(invited.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
|
|
||||||
|
|
||||||
// ...recorded as their presence, like any other matchmake.
|
|
||||||
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
|
||||||
.bind(301)
|
|
||||||
.first<{ data: string }>()
|
|
||||||
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
|
|
||||||
expect(presence.roomInstance.roomInstanceId).toBe(invited.roomInstance!.roomInstanceId)
|
|
||||||
|
|
||||||
// The creator gets in, and so does someone who was invited and DECLINED — the row
|
|
||||||
// is the invite, whatever the answer.
|
|
||||||
expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0)
|
|
||||||
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
|
|
||||||
|
|
||||||
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
|
|
||||||
expect(await join('/matchmake/event/8', '399')).toEqual({
|
|
||||||
errorCode: 35,
|
|
||||||
roomInstance: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
|
|
||||||
// listings, it doesn't close it.
|
|
||||||
expect((await join('/matchmake/event/9', '399')).errorCode).toBe(0)
|
|
||||||
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
|
|
||||||
|
|
||||||
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
|
|
||||||
expect(await join('/matchmake/event/9999', '399')).toEqual({
|
|
||||||
errorCode: 20,
|
|
||||||
roomInstance: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Signed out is a 401, not a matchmaking error.
|
|
||||||
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /matchmake/event/:eventId enters the subroom the event pins', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/event/11`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(await bearer('301')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: 'JoinMode=0',
|
|
||||||
})
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
errorCode: number
|
|
||||||
roomInstance: { roomId: number; subRoomId: number; location: string } | null
|
|
||||||
}
|
|
||||||
// Room 77's SECOND subroom (35), not its first — the event pins the scene.
|
|
||||||
expect(body.errorCode).toBe(0)
|
|
||||||
expect(body.roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
subRoomId: 35,
|
|
||||||
location: SECOND_SUBROOM_SCENE,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
|
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -780,79 +465,6 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
|
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ROOM_REDIRECTS switches a matchmake out to another room', async () => {
|
|
||||||
// `env` is shared by every test in this file, so restore the knob in `finally`.
|
|
||||||
const original = env.ROOM_REDIRECTS
|
|
||||||
const matchmake = async (path: string, player: string) =>
|
|
||||||
(await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(await bearer(player)),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
// Private, so each call gets a fresh instance of whatever room it landed in.
|
|
||||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
|
||||||
})
|
|
||||||
).json()) as {
|
|
||||||
errorCode: number
|
|
||||||
roomInstance: { roomId: number; subRoomId: number; location: string; name: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
env.ROOM_REDIRECTS = '2=MultiRoom'
|
|
||||||
// The room asked for is never entered; the substitute is, scene and all.
|
|
||||||
expect((await matchmake('/matchmake/room/2', '8801')).roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
name: '^MultiRoom',
|
|
||||||
location: RECCENTER_SCENE,
|
|
||||||
})
|
|
||||||
// Matched on the resolved room, not the path segment, so the name spelling of the
|
|
||||||
// same room is substituted too.
|
|
||||||
expect((await matchmake('/matchmake/room/RecCenter', '8802')).roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
})
|
|
||||||
// The requested subroom is dropped — 35 is a subroom of the substitute, not of the
|
|
||||||
// room asked for — so entry falls back to the substitute's default subroom (34).
|
|
||||||
expect((await matchmake('/matchmake/room/2/35', '8803')).roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
subRoomId: 34,
|
|
||||||
location: RECCENTER_SCENE,
|
|
||||||
})
|
|
||||||
// Club 4's clubhouse is room 2, and it resolves through the same path: a
|
|
||||||
// substituted room is substituted wherever a matchmake names it.
|
|
||||||
expect((await matchmake('/matchmake/club/4', '121')).roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Targeting by id works the same, and substitution is a single hop: 2 and 77
|
|
||||||
// swap rather than bouncing between each other.
|
|
||||||
env.ROOM_REDIRECTS = '2=77,77=2'
|
|
||||||
expect((await matchmake('/matchmake/room/2', '8804')).roomInstance).toMatchObject({
|
|
||||||
roomId: 77,
|
|
||||||
})
|
|
||||||
expect((await matchmake('/matchmake/room/77', '8805')).roomInstance).toMatchObject({
|
|
||||||
roomId: 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
// A target that doesn't resolve leaves the requested room in place — a typo'd
|
|
||||||
// knob must not make the room unreachable.
|
|
||||||
env.ROOM_REDIRECTS = '2=NoSuchRoomHere'
|
|
||||||
expect((await matchmake('/matchmake/room/2', '8806')).roomInstance).toMatchObject({
|
|
||||||
roomId: 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Unset: everyone enters the room they asked for.
|
|
||||||
env.ROOM_REDIRECTS = undefined
|
|
||||||
expect((await matchmake('/matchmake/room/2', '8807')).roomInstance).toMatchObject({
|
|
||||||
roomId: 2,
|
|
||||||
name: '^RecCenter',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
env.ROOM_REDIRECTS = original
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('PUT /player/statusvisibility returns 200', async () => {
|
test('PUT /player/statusvisibility returns 200', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -1124,15 +736,14 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(await stale.text()).toBe('')
|
expect(await stale.text()).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Seed presence directly into D1 with a chosen instance and `expiresAt` (epoch
|
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
|
||||||
// seconds), so the TTL branches can be exercised deterministically (independent of
|
// TTL-refresh branch can be exercised deterministically (independent of timing).
|
||||||
// timing) and a player can be planted in an instance without matchmaking there.
|
const seedPresence = (id: number, expiresAt: number) =>
|
||||||
const seedPresenceInInstance = (id: number, roomInstanceId: number, expiresAt: number) =>
|
|
||||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
accountId: id,
|
accountId: id,
|
||||||
roomInstance: { roomInstanceId, roomId: 1 },
|
roomInstance: { roomInstanceId: 1000042, roomId: 1 },
|
||||||
statusVisibility: 0,
|
statusVisibility: 0,
|
||||||
deviceClass: 0,
|
deviceClass: 0,
|
||||||
vrMovementMode: 1,
|
vrMovementMode: 1,
|
||||||
@@ -1143,9 +754,6 @@ describe('auth-gated endpoints', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
const seedPresence = (id: number, expiresAt: number) =>
|
|
||||||
seedPresenceInInstance(id, 1000042, expiresAt)
|
|
||||||
|
|
||||||
const storedExpiresAt = async (id: number): Promise<number> => {
|
const storedExpiresAt = async (id: number): Promise<number> => {
|
||||||
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -1189,9 +797,24 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
|
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
|
||||||
// Three players in instance 1000099 — two live, one expired.
|
// Three players in instance 1000099 — two live, one expired.
|
||||||
await seedPresenceInInstance(710, 1000099, nowSeconds() + 800)
|
const seedInInstance = (id: number, expiresAt: number) =>
|
||||||
await seedPresenceInInstance(711, 1000099, nowSeconds() + 800)
|
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
await seedPresenceInInstance(712, 1000099, nowSeconds() - 10) // expired → not counted
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: id,
|
||||||
|
roomInstance: { roomInstanceId: 1000099, roomId: 2 },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await seedInInstance(710, nowSeconds() + 800)
|
||||||
|
await seedInInstance(711, nowSeconds() + 800)
|
||||||
|
await seedInInstance(712, nowSeconds() - 10) // already expired → not counted
|
||||||
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
|
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
|
||||||
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
|
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
|
||||||
})
|
})
|
||||||
@@ -1254,89 +877,6 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
|
|
||||||
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
|
||||||
// can be exercised without waiting out the grace window.
|
|
||||||
const backdateInstance = (id: number, secondsAgo = EMPTY_INSTANCE_GRACE_SECONDS + 60) =>
|
|
||||||
env.DB.prepare(
|
|
||||||
"UPDATE room_instance SET data = json_set(data, '$.createdAt', ?2) WHERE id = ?1"
|
|
||||||
)
|
|
||||||
.bind(id, new Date(Date.now() - secondsAgo * 1000).toISOString())
|
|
||||||
.run()
|
|
||||||
|
|
||||||
const expirePresence = (accountId: number) =>
|
|
||||||
env.DB.prepare(
|
|
||||||
"UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(accountId, nowSeconds() - 10)
|
|
||||||
.run()
|
|
||||||
|
|
||||||
test('the cron sweep deletes instances nobody is left standing in', async () => {
|
|
||||||
// Two instances built directly rather than by matchmaking, so neither is one a
|
|
||||||
// previous test's player is still standing in (public matchmakes reuse instances).
|
|
||||||
// One holds a player who crashed out — an expired row the sweep purges first,
|
|
||||||
// leaving the instance empty — the other a live player.
|
|
||||||
const abandoned = await createRoomInstance(env.DB, {
|
|
||||||
ownerAccountId: 830,
|
|
||||||
roomId: 2,
|
|
||||||
photonRoomId: 'abandoned-instance',
|
|
||||||
maxCapacity: 12,
|
|
||||||
})
|
|
||||||
await seedPresenceInInstance(830, abandoned.roomInstanceId, nowSeconds() - 10)
|
|
||||||
const occupied = await createRoomInstance(env.DB, {
|
|
||||||
ownerAccountId: 831,
|
|
||||||
roomId: 2,
|
|
||||||
photonRoomId: 'occupied-instance',
|
|
||||||
maxCapacity: 12,
|
|
||||||
})
|
|
||||||
await seedPresenceInInstance(831, occupied.roomInstanceId, nowSeconds() + 800)
|
|
||||||
await backdateInstance(abandoned.roomInstanceId)
|
|
||||||
await backdateInstance(occupied.roomInstanceId)
|
|
||||||
|
|
||||||
const ctx = createExecutionContext()
|
|
||||||
await scheduled(createScheduledController(), env, ctx)
|
|
||||||
await waitOnExecutionContext(ctx)
|
|
||||||
|
|
||||||
expect(await getRoomInstance(env.DB, abandoned.roomInstanceId)).toBeNull()
|
|
||||||
expect(await getRoomInstance(env.DB, occupied.roomInstanceId)).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('the cron sweep spares a freshly created instance nobody has joined yet', async () => {
|
|
||||||
// The instance and its creator's presence are written by the same request but not
|
|
||||||
// atomically — a sweep landing in between must not delete the instance the player
|
|
||||||
// is being handed. `createdAt` is left alone, so it's inside the grace window.
|
|
||||||
const fresh = await createRoomInstance(env.DB, {
|
|
||||||
ownerAccountId: 832,
|
|
||||||
roomId: 2,
|
|
||||||
photonRoomId: 'fresh-instance',
|
|
||||||
maxCapacity: 12,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ctx = createExecutionContext()
|
|
||||||
await scheduled(createScheduledController(), env, ctx)
|
|
||||||
await waitOnExecutionContext(ctx)
|
|
||||||
|
|
||||||
expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('the cron sweep spares an empty dorm instance', async () => {
|
|
||||||
// A dorm is backed by one persistent instance so its Photon room id survives
|
|
||||||
// re-entry — it sits empty whenever the owner is anywhere else.
|
|
||||||
const headers = await bearer('833')
|
|
||||||
const dorm = (await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
|
||||||
).json()) as { roomInstance: { roomInstanceId: number } }
|
|
||||||
const dormInstanceId = dorm.roomInstance.roomInstanceId
|
|
||||||
await expirePresence(833)
|
|
||||||
await backdateInstance(dormInstanceId)
|
|
||||||
|
|
||||||
const ctx = createExecutionContext()
|
|
||||||
await scheduled(createScheduledController(), env, ctx)
|
|
||||||
await waitOnExecutionContext(ctx)
|
|
||||||
|
|
||||||
expect(await getRoomInstance(env.DB, dormInstanceId)).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('player/login and exclusivelogin preserve presence', async () => {
|
test('player/login and exclusivelogin preserve presence', async () => {
|
||||||
const headers = await bearer('9')
|
const headers = await bearer('9')
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||||
@@ -1449,32 +989,10 @@ describe('auth-gated endpoints', () => {
|
|||||||
headers: await bearer('42'),
|
headers: await bearer('42'),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const instances = (await res.json()) as Array<{
|
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
|
||||||
roomInstanceId: number
|
|
||||||
roomId: number
|
|
||||||
subRoomId: number
|
|
||||||
isFull: boolean
|
|
||||||
createdAt: string
|
|
||||||
playerIds: number[]
|
|
||||||
}>
|
|
||||||
expect(instances.length).toBeGreaterThanOrEqual(1)
|
expect(instances.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
||||||
|
|
||||||
// The summary projection: id/subroom/fullness/createdAt plus who's in there —
|
|
||||||
// and none of the client DTO's connection fields.
|
|
||||||
const instance = instances.find((i) => i.playerIds.includes(42))
|
|
||||||
expect(instance).toBeDefined()
|
|
||||||
expect(Object.keys(instance!).sort()).toEqual([
|
|
||||||
'createdAt',
|
|
||||||
'isFull',
|
|
||||||
'playerIds',
|
|
||||||
'roomId',
|
|
||||||
'roomInstanceId',
|
|
||||||
'subRoomId',
|
|
||||||
])
|
|
||||||
expect(instance!.isFull).toBe(false)
|
|
||||||
expect(Number.isNaN(Date.parse(instance!.createdAt))).toBe(false)
|
|
||||||
|
|
||||||
// The co-owner (account 43, Role 30) may view the instances too.
|
// The co-owner (account 43, Role 30) may view the instances too.
|
||||||
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||||
headers: await bearer('43'),
|
headers: await bearer('43'),
|
||||||
@@ -1483,144 +1001,6 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
|
|
||||||
// A player with no role on room 3 spins up an instance of it, which the room's
|
|
||||||
// owner should then be able to drop into by id.
|
|
||||||
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('43'),
|
|
||||||
})
|
|
||||||
const spawned = (await spawn.json()) as {
|
|
||||||
roomInstance: { roomInstanceId: number; photonRoomId: string }
|
|
||||||
}
|
|
||||||
const instanceId = spawned.roomInstance.roomInstanceId
|
|
||||||
|
|
||||||
// No token → 401.
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
).status
|
|
||||||
).toBe(401)
|
|
||||||
|
|
||||||
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
|
||||||
// so instance ids can't be probed for live private sessions.
|
|
||||||
const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('999'),
|
|
||||||
})
|
|
||||||
expect(stranger.status).toBe(200)
|
|
||||||
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
|
|
||||||
|
|
||||||
// Unknown instance → same refusal.
|
|
||||||
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
|
|
||||||
|
|
||||||
// Park the owner somewhere else first, so this is a real transition.
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
|
|
||||||
// The owner lands in that exact instance — same id AND same Photon room as the
|
|
||||||
// player already in it, which is what makes it the same session.
|
|
||||||
const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
expect(joined.status).toBe(200)
|
|
||||||
const body = (await joined.json()) as {
|
|
||||||
errorCode: number
|
|
||||||
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number }
|
|
||||||
}
|
|
||||||
expect(body.errorCode).toBe(0)
|
|
||||||
expect(body.roomInstance.roomInstanceId).toBe(instanceId)
|
|
||||||
expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId)
|
|
||||||
expect(body.roomInstance.roomId).toBe(3)
|
|
||||||
|
|
||||||
// It's now the owner's presence, and the listing shows both of them in there.
|
|
||||||
const listed = (await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
|
||||||
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
|
||||||
const target = listed.find((i) => i.roomInstanceId === instanceId)
|
|
||||||
expect(target?.playerIds).toEqual([42, 43])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => {
|
|
||||||
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
|
|
||||||
// instances the other tests matchmake into.
|
|
||||||
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } }
|
|
||||||
const instanceId = roomInstance.roomInstanceId
|
|
||||||
|
|
||||||
// No token → 401.
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
).status
|
|
||||||
).toBe(401)
|
|
||||||
|
|
||||||
// Unknown instance → 404.
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
).status
|
|
||||||
).toBe(404)
|
|
||||||
|
|
||||||
// Room 77 has no creator and no roles, so nobody manages it → 403 even for 42.
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
).status
|
|
||||||
).toBe(403)
|
|
||||||
|
|
||||||
// Room 3 is account 42's, so its instances are theirs to close.
|
|
||||||
const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('43'),
|
|
||||||
})
|
|
||||||
const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } })
|
|
||||||
.roomInstance.roomInstanceId
|
|
||||||
const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('42'),
|
|
||||||
})
|
|
||||||
expect(marked.status).toBe(200)
|
|
||||||
expect(await marked.text()).toBe('')
|
|
||||||
|
|
||||||
// Closed to strangers: a public matchmake into room 3 no longer reuses it, so a
|
|
||||||
// new player lands in a different instance.
|
|
||||||
const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('999'),
|
|
||||||
})
|
|
||||||
const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } })
|
|
||||||
.roomInstance.roomInstanceId
|
|
||||||
expect(afterId).not.toBe(ownedId)
|
|
||||||
|
|
||||||
// The player already inside is untouched — this shuts the door, it doesn't clear
|
|
||||||
// the room.
|
|
||||||
const listed = (await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
|
||||||
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
|
||||||
expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
||||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||||
type Sent = {
|
type Sent = {
|
||||||
@@ -1866,71 +1246,6 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
// No token → 401.
|
// No token → 401.
|
||||||
expect((await follow(9801)).status).toBe(401)
|
expect((await follow(9801)).status).toBe(401)
|
||||||
|
|
||||||
// A ban on the room blocks the follow too: this path hands out a Photon room id
|
|
||||||
// without going through resolveRoomInstance, so it carries its own ban check —
|
|
||||||
// otherwise following a friend in would be a way around a ban.
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
|
||||||
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
|
|
||||||
).run()
|
|
||||||
try {
|
|
||||||
expect(await (await follow(9801, '9800')).json()).toEqual({
|
|
||||||
errorCode: 55,
|
|
||||||
roomInstance: null,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await env.DB.prepare(
|
|
||||||
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
|
|
||||||
).run()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
|
|
||||||
const matchmake = async (sub: string) =>
|
|
||||||
(await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer(sub),
|
|
||||||
})
|
|
||||||
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
|
|
||||||
|
|
||||||
// Not banned yet → a normal join.
|
|
||||||
expect((await matchmake('9700')).errorCode).toBe(0)
|
|
||||||
|
|
||||||
await env.DB.prepare(
|
|
||||||
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
|
||||||
VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')`
|
|
||||||
).run()
|
|
||||||
|
|
||||||
// The ban is the whole enforcement: no instance means no Photon room id, so there
|
|
||||||
// is nothing for the banned player to join. errorCode 55 rather than the opaque
|
|
||||||
// NoSuchRoom every other refusal answers — a banned player already knows the room
|
|
||||||
// exists, so the client can say why. Applies to the subroom path as well.
|
|
||||||
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('9701'),
|
|
||||||
})
|
|
||||||
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
|
|
||||||
// Refused before any instance is created, and no presence was recorded for them.
|
|
||||||
expect(
|
|
||||||
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first()
|
|
||||||
).toBeNull()
|
|
||||||
|
|
||||||
// The ban is per-room — another room is unaffected.
|
|
||||||
const other = (await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('9701'),
|
|
||||||
})
|
|
||||||
).json()) as { errorCode: number }
|
|
||||||
expect(other.errorCode).toBe(0)
|
|
||||||
|
|
||||||
// Lifting the ban lets them in again.
|
|
||||||
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run()
|
|
||||||
expect((await matchmake('9701')).errorCode).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
|
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
|
||||||
@@ -2018,15 +1333,12 @@ describe('auth-gated endpoints', () => {
|
|||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'GET /player',
|
'GET /player',
|
||||||
'GET /player/avoidjuniors',
|
|
||||||
'GET /room/{roomId}/instances',
|
'GET /room/{roomId}/instances',
|
||||||
'GET /rooms/requiring/developer',
|
'GET /rooms/requiring/developer',
|
||||||
'GET /rooms/requiring/rrplus',
|
'GET /rooms/requiring/rrplus',
|
||||||
'POST /invite',
|
'POST /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/dorm',
|
'POST /matchmake/dorm',
|
||||||
'POST /matchmake/event/{eventId}',
|
|
||||||
'POST /matchmake/instance/{instanceId}',
|
|
||||||
'POST /matchmake/player/{playerId}',
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
@@ -2035,9 +1347,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /player/login',
|
'POST /player/login',
|
||||||
'POST /player/logout',
|
'POST /player/logout',
|
||||||
'POST /player/notifydisconnect',
|
'POST /player/notifydisconnect',
|
||||||
'POST /roominstance/{id}/markprivate',
|
|
||||||
'POST /roominstance/{id}/reportjoinresult',
|
'POST /roominstance/{id}/reportjoinresult',
|
||||||
'PUT /player/avoidjuniors',
|
|
||||||
'PUT /player/gameserverregionpings',
|
'PUT /player/gameserverregionpings',
|
||||||
'PUT /player/photonregionpings',
|
'PUT /player/photonregionpings',
|
||||||
'PUT /player/statusvisibility',
|
'PUT /player/statusvisibility',
|
||||||
@@ -2051,191 +1361,3 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// An ACCOUNT ban (a `report` row with `banned` set, owned by the api worker) is not
|
|
||||||
// about any one room, so it is enforced across every matchmake rather than per route —
|
|
||||||
// see the /matchmake/* gate in match.app.ts. It answers the same BannedFromRoom (55) the
|
|
||||||
// per-room bans do, which is the code the client renders as "you are banned".
|
|
||||||
describe('account bans', () => {
|
|
||||||
const matchmake = async (path: string, player: string) =>
|
|
||||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer(player),
|
|
||||||
})
|
|
||||||
|
|
||||||
test('every matchmake route is refused for a banned account', async () => {
|
|
||||||
await banAccount(6001)
|
|
||||||
// One live instance of room 2 and one club membership, so each route would
|
|
||||||
// otherwise have somewhere to put them.
|
|
||||||
for (const path of [
|
|
||||||
'/matchmake/room/2',
|
|
||||||
'/matchmake/room/77/34',
|
|
||||||
'/matchmake/dorm',
|
|
||||||
'/matchmake/club/4',
|
|
||||||
'/matchmake/player/9701',
|
|
||||||
'/matchmake/instance/1',
|
|
||||||
]) {
|
|
||||||
const res = await matchmake(path, '6001')
|
|
||||||
expect(res.status, path).toBe(200)
|
|
||||||
expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
|
|
||||||
// written and the player stays where they were (nowhere).
|
|
||||||
test('a refused matchmake leaves no presence behind', async () => {
|
|
||||||
await banAccount(6002)
|
|
||||||
expect((await matchmake('/matchmake/room/2', '6002')).status).toBe(200)
|
|
||||||
|
|
||||||
const player = (await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/player?id=6002`, { headers: await bearer('6002') })
|
|
||||||
).json()) as Array<{ isOnline: boolean; roomInstance: unknown }>
|
|
||||||
expect(player[0]?.roomInstance ?? null).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
// A timed ban lifts itself once its expiry passes — nothing clears the flag.
|
|
||||||
test('an expired ban no longer blocks a matchmake', async () => {
|
|
||||||
await banAccount(6003, '2020-01-01T00:00:00.000Z')
|
|
||||||
const res = await matchmake('/matchmake/room/2', '6003')
|
|
||||||
const body = (await res.json()) as { errorCode: number; roomInstance: unknown }
|
|
||||||
expect(body.errorCode).toBe(0)
|
|
||||||
expect(body.roomInstance).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a ban that has not expired yet blocks a matchmake', async () => {
|
|
||||||
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
|
|
||||||
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({
|
|
||||||
errorCode: 55,
|
|
||||||
roomInstance: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// A report on its own is not a ban — only a moderator converting it is.
|
|
||||||
test('an unbanned report does not block a matchmake', async () => {
|
|
||||||
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6005 })
|
|
||||||
const body = (await (await matchmake('/matchmake/room/2', '6005')).json()) as {
|
|
||||||
errorCode: number
|
|
||||||
}
|
|
||||||
expect(body.errorCode).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filing the report doesn't touch the reporter, so they still play.
|
|
||||||
test('the reporter is not banned by the report they filed', async () => {
|
|
||||||
await banAccount(6006)
|
|
||||||
const body = (await (await matchmake('/matchmake/room/2', '1')).json()) as { errorCode: number }
|
|
||||||
expect(body.errorCode).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// The gate must not turn a missing token into "banned" — that's still a 401.
|
|
||||||
test('an unauthenticated matchmake is still a 401', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST' })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Only the matchmakes are gated: presence and the rest of the surface keep working,
|
|
||||||
// so a banned player's client isn't left hammering a dead heartbeat.
|
|
||||||
test('the gate does not touch non-matchmake routes', async () => {
|
|
||||||
await banAccount(6007)
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer('6007'),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// The ban follows the player past the account it was written on: a new account sharing a
|
|
||||||
// proven platform identity or an IP with a banned one is refused the same way. See
|
|
||||||
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
|
|
||||||
describe('ban evasion at matchmake', () => {
|
|
||||||
const matchmake = async (player: string, ip?: string) =>
|
|
||||||
(await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...(await bearer(player)), ...(ip ? { 'CF-Connecting-IP': ip } : {}) },
|
|
||||||
})
|
|
||||||
).json()) as { errorCode: number; roomInstance: unknown }
|
|
||||||
|
|
||||||
/** Seed an account row carrying the IPs it signed up / last logged in from. */
|
|
||||||
const account = async (id: number, ips: Record<string, string> = {}) => {
|
|
||||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
|
||||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, ...ips }))
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a new account sharing a banned account’s platform identity is refused', async () => {
|
|
||||||
await account(6201)
|
|
||||||
await link(6201, 0, 'steam-evader')
|
|
||||||
await banAccount(6201)
|
|
||||||
// The replacement account: different id, same headset.
|
|
||||||
await account(6202)
|
|
||||||
await link(6202, 0, 'steam-evader')
|
|
||||||
|
|
||||||
expect(await matchmake('6202')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a new account sharing a banned account’s signup IP is refused', async () => {
|
|
||||||
await account(6203, { signupIp: '203.0.113.203' })
|
|
||||||
await banAccount(6203)
|
|
||||||
await account(6204, { signupIp: '203.0.113.203' })
|
|
||||||
|
|
||||||
expect(await matchmake('6204')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
// The address the request arrives from counts too, so an account that has never
|
|
||||||
// logged in from the banned network before is caught on the first matchmake.
|
|
||||||
test('the request’s own IP is matched even when the account has none stored', async () => {
|
|
||||||
await account(6205, { signupIp: '203.0.113.205' })
|
|
||||||
await banAccount(6205)
|
|
||||||
await account(6206)
|
|
||||||
|
|
||||||
expect(await matchmake('6206', '203.0.113.205')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
// From anywhere else, that same account plays.
|
|
||||||
expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('an unrelated account is unaffected', async () => {
|
|
||||||
await account(6207, { signupIp: '203.0.113.207' })
|
|
||||||
await banAccount(6207)
|
|
||||||
await account(6208, { signupIp: '198.51.100.208' })
|
|
||||||
await link(6208, 0, 'steam-innocent')
|
|
||||||
|
|
||||||
expect((await matchmake('6208')).errorCode).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// BAN_EVASION_MATCH is the operator's answer to the IP arm's false positives: the
|
|
||||||
// housemate of a banned player gets back in, the evader on the same headset does not.
|
|
||||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
|
|
||||||
const original = env.BAN_EVASION_MATCH
|
|
||||||
await account(6210, { signupIp: '203.0.113.210' })
|
|
||||||
await link(6210, 0, 'steam-knob')
|
|
||||||
await banAccount(6210)
|
|
||||||
await account(6211, { signupIp: '203.0.113.210' }) // housemate
|
|
||||||
await account(6212)
|
|
||||||
await link(6212, 0, 'steam-knob') // same headset
|
|
||||||
|
|
||||||
try {
|
|
||||||
env.BAN_EVASION_MATCH = 'platform'
|
|
||||||
expect((await matchmake('6211')).errorCode).toBe(0)
|
|
||||||
expect(await matchmake('6212')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
// The banned account itself is still refused, whatever the knob says.
|
|
||||||
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
|
|
||||||
env.BAN_EVASION_MATCH = 'off'
|
|
||||||
expect((await matchmake('6211')).errorCode).toBe(0)
|
|
||||||
expect((await matchmake('6212')).errorCode).toBe(0)
|
|
||||||
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
|
|
||||||
} finally {
|
|
||||||
env.BAN_EVASION_MATCH = original
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
+2
-4
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Runtime types generated with workerd@1.20260625.1 2026-06-16 nodejs_compat
|
// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat
|
||||||
// Begin runtime types
|
// Begin runtime types
|
||||||
/*! *****************************************************************************
|
/*! *****************************************************************************
|
||||||
Copyright (c) Cloudflare. All rights reserved.
|
Copyright (c) Cloudflare. All rights reserved.
|
||||||
@@ -420,7 +420,6 @@ interface TestController {
|
|||||||
interface ExecutionContext<Props = unknown> {
|
interface ExecutionContext<Props = unknown> {
|
||||||
waitUntil(promise: Promise<any>): void;
|
waitUntil(promise: Promise<any>): void;
|
||||||
passThroughOnException(): void;
|
passThroughOnException(): void;
|
||||||
readonly exports: Cloudflare.Exports;
|
|
||||||
readonly props: Props;
|
readonly props: Props;
|
||||||
cache?: CacheContext;
|
cache?: CacheContext;
|
||||||
readonly access?: CloudflareAccessContext;
|
readonly access?: CloudflareAccessContext;
|
||||||
@@ -527,7 +526,6 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
|
|||||||
}
|
}
|
||||||
interface DurableObjectState<Props = unknown> {
|
interface DurableObjectState<Props = unknown> {
|
||||||
waitUntil(promise: Promise<any>): void;
|
waitUntil(promise: Promise<any>): void;
|
||||||
readonly exports: Cloudflare.Exports;
|
|
||||||
readonly props: Props;
|
readonly props: Props;
|
||||||
readonly id: DurableObjectId;
|
readonly id: DurableObjectId;
|
||||||
readonly storage: DurableObjectStorage;
|
readonly storage: DurableObjectStorage;
|
||||||
@@ -1645,7 +1643,7 @@ declare class Headers {
|
|||||||
value: string
|
value: string
|
||||||
]>;
|
]>;
|
||||||
}
|
}
|
||||||
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
|
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData;
|
||||||
declare abstract class Body {
|
declare abstract class Body {
|
||||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
||||||
get body(): ReadableStream | null;
|
get body(): ReadableStream | null;
|
||||||
|
|||||||
@@ -15,20 +15,10 @@
|
|||||||
"database_id": "local"
|
"database_id": "local"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Per-player settings KV, owned by the `playersettings` worker. Read-only here, for
|
|
||||||
// GET /player/avoidjuniors. The "local" id placeholder is replaced with the real id
|
|
||||||
// from RECFLARE_KV at deploy time.
|
|
||||||
"kv_namespaces": [
|
|
||||||
{
|
|
||||||
"binding": "RECFLARE_PLAYER_SETTINGS",
|
|
||||||
"id": "local"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
// Presence sweep. Rows expire on their own TTL (15m) and reads already ignore
|
// Presence sweep. Rows expire on their own TTL (15m) and reads already ignore
|
||||||
// expired ones, so this is housekeeping: it purges them, deletes the room
|
// expired ones, so this is housekeeping: it purges them and recomputes the
|
||||||
// instances left with nobody in them, and recomputes the fullness of the
|
// fullness of the instances the departed players were in (a crashed player never
|
||||||
// instances the departed players were in (a crashed player never matchmakes out,
|
// matchmakes out, so nothing else notices they left). Every 5 minutes.
|
||||||
// so nothing else notices they left). Every 5 minutes.
|
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"crons": ["*/5 * * * *"]
|
"crons": ["*/5 * * * *"]
|
||||||
},
|
},
|
||||||
@@ -61,10 +51,6 @@
|
|||||||
"head_sampling_rate": 1 // 100%
|
"head_sampling_rate": 1 // 100%
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// The room substitutions (ROOM_REDIRECTS) are deliberately NOT set here. They're
|
|
||||||
// injected at deploy time from the gitignored .env (RECFLARE_ROOM_REDIRECTS, see
|
|
||||||
// .env.example), so swapping a room out never means editing a versioned file. Unset —
|
|
||||||
// the default — means every matchmake enters the room it asked for.
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"check:lint": "run-oxlint",
|
"check:lint": "run-oxlint",
|
||||||
"check:types": "run-tsc",
|
"check:types": "run-tsc",
|
||||||
"check:workers-types": "run-wrangler-types --check",
|
"check:workers-types": "run-wrangler-types --check",
|
||||||
"deploy:mono": "run-wrangler-deploy",
|
|
||||||
"dev": "run-wrangler-dev",
|
"dev": "run-wrangler-dev",
|
||||||
"fix:workers-types": "run-wrangler-types",
|
"fix:workers-types": "run-wrangler-types",
|
||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||||
// Type-only import (erased at build) of the DO class this worker re-exports from its
|
|
||||||
// entry. The parameter has to be here, not just on `match`'s Env: `scheduled` hands this
|
|
||||||
// worker's superset Env straight to `matchScheduled`, and a bare `DurableObjectNamespace`
|
|
||||||
// is not assignable to the `DurableObjectNamespace<NotificationsHub>` that one declares.
|
|
||||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of every mounted worker's bindings.
|
* Union of every mounted worker's bindings.
|
||||||
@@ -15,24 +10,8 @@ import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
|||||||
* each app's narrower `Env`, so the sub-apps type-check unchanged.
|
* each app's narrower `Env`, so the sub-apps type-check unchanged.
|
||||||
*/
|
*/
|
||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
/**
|
|
||||||
* Base domain this worker answers on, e.g. `rec.example.com` — injected from
|
|
||||||
* `RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, with a
|
|
||||||
* placeholder default in `wrangler.jsonc` for tests and an unconfigured checkout.
|
|
||||||
*
|
|
||||||
* Read by the mounted `ns` app to build the service-discovery document — the thing a
|
|
||||||
* client is pointed at — so it has to name the host that actually reaches this worker:
|
|
||||||
* the tunnel/LAN hostname when running it locally, and the apex of the domain when
|
|
||||||
* deployed (`RECFLARE_SUBDOMAINS='{"mono":"@"}'`, `just deploy-mono`). Every service
|
|
||||||
* mounted here is served from a PATH on that one host, so the document says
|
|
||||||
* `https://<domain>/rooms` and nothing else would answer there.
|
|
||||||
*/
|
|
||||||
DOMAIN: string
|
|
||||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
|
||||||
// headset login's nonce with Meta (see apps/auth/src/meta-nonce.ts).
|
|
||||||
META_APP_SECRET: SecretsStoreSecret
|
|
||||||
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
// Image storage bucket (api, img).
|
// Image storage bucket (api, img).
|
||||||
@@ -43,7 +22,7 @@ export type Env = SharedHonoEnv & {
|
|||||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||||
// Real-time notifications hub. The class is defined in `notify` and re-exported by
|
// Real-time notifications hub. The class is defined in `notify` and re-exported by
|
||||||
// this worker's entry so the binding resolves in-process (no `script_name`).
|
// this worker's entry so the binding resolves in-process (no `script_name`).
|
||||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Variables = SharedHonoVariables
|
export type Variables = SharedHonoVariables
|
||||||
|
|||||||
+13
-39
@@ -3,31 +3,22 @@
|
|||||||
*
|
*
|
||||||
* Mounts each RecFlare worker inside a single deployable Worker WITHOUT modifying the
|
* Mounts each RecFlare worker inside a single deployable Worker WITHOUT modifying the
|
||||||
* originals: every app is imported by relative path and bundled by esbuild at build
|
* originals: every app is imported by relative path and bundled by esbuild at build
|
||||||
* time. A request selects its service two ways, and the sub-app paths (and therefore the
|
* time. Production routing mirrors the split deployment — requests are dispatched on
|
||||||
* client contract) are untouched either way.
|
* the request's subdomain (`accounts.<domain>` -> the `accounts` app), so the sub-app
|
||||||
|
* paths (and therefore the client contract) are untouched.
|
||||||
*
|
*
|
||||||
* By PATH — how this worker is meant to be deployed, at the apex of `DOMAIN`, and the
|
* Local dev has no subdomain, so the first path segment selects the service and is
|
||||||
* only way that works in local dev, which has no subdomain. The first path segment names
|
* stripped before the request is forwarded, e.g.
|
||||||
* the service and is stripped before the request is forwarded, e.g.
|
* http://localhost:8787/accounts/ -> accounts app sees /
|
||||||
* https://<domain>/accounts/ -> accounts app sees /
|
* http://localhost:8787/match/player/login -> match app sees /player/login
|
||||||
* https://<domain>/match/player/login -> match app sees /player/login
|
* http://localhost:8787/api/api/config/v2 -> api app sees /api/config/v2
|
||||||
* https://<domain>/api/api/config/v2 -> api app sees /api/config/v2
|
|
||||||
*
|
|
||||||
* By SUBDOMAIN — `accounts.<domain>` -> the `accounts` app, with the path forwarded
|
|
||||||
* unchanged. That mirrors the split deployment, so a client (or a stray DNS record) still
|
|
||||||
* pointed at the per-service hosts keeps working if they're routed here.
|
|
||||||
*
|
*
|
||||||
* A request with no path (just `/`) that selects no service serves the `ns` discovery
|
* A request with no path (just `/`) that selects no service serves the `ns` discovery
|
||||||
* document, so a bare hit to the facade root returns the service map to bootstrap from.
|
* document, so a bare hit to the facade root returns the service map to bootstrap from.
|
||||||
* The document is built in the PATH style (`https://<domain>/rooms`, every service on
|
|
||||||
* this one host) — see ENDPOINT_STYLE below — so deploy this worker at the apex of
|
|
||||||
* `DOMAIN` and point the client at nothing else.
|
|
||||||
*
|
*
|
||||||
* NOT mounted here: `www`, `img`, `econ`. Each binds a static `assets` directory and
|
* NOT mounted here: `www`, `img`, `econ`. Each binds a static `assets` directory and
|
||||||
* Cloudflare allows only one static-assets binding per Worker. Resolve that (serve
|
* Cloudflare allows only one static-assets binding per Worker. Resolve that (serve
|
||||||
* their static trees from R2, or keep those three as their own Workers) before adding.
|
* their static trees from R2, or keep those three as their own Workers) before adding.
|
||||||
* The discovery document still puts them on this host, since a single-service run is the
|
|
||||||
* whole point of this worker — so until they're mounted, their paths 404 here.
|
|
||||||
*/
|
*/
|
||||||
import accounts from '../../accounts/src/accounts.app'
|
import accounts from '../../accounts/src/accounts.app'
|
||||||
import api from '../../api/src/api.app'
|
import api from '../../api/src/api.app'
|
||||||
@@ -76,24 +67,16 @@ const services = {
|
|||||||
|
|
||||||
type ServiceName = keyof typeof services
|
type ServiceName = keyof typeof services
|
||||||
|
|
||||||
/**
|
|
||||||
* This worker is one host, so its discovery document has to name one host: every service
|
|
||||||
* is advertised as `https://<domain>/<name>`, never `https://<name>.<domain>`. Handed to
|
|
||||||
* the mounted `ns` app, which defaults to the per-host document the split deployment wants.
|
|
||||||
*/
|
|
||||||
const ENDPOINT_STYLE = 'path'
|
|
||||||
|
|
||||||
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
|
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
|
|
||||||
// Dispatch on the leftmost DNS label — accounts.<domain> -> accounts. The path is
|
// Production: dispatch on the leftmost DNS label — accounts.<domain> -> accounts.
|
||||||
// forwarded unchanged so the client contract is identical to the split deployment.
|
// The path is forwarded unchanged so the client contract is identical.
|
||||||
const sub = url.hostname.split('.')[0]
|
const sub = url.hostname.split('.')[0]
|
||||||
if (sub in services) return { name: sub as ServiceName, request }
|
if (sub in services) return { name: sub as ServiceName, request }
|
||||||
|
|
||||||
// Apex (and local dev): the first path segment selects the service and is stripped
|
// Local dev (no service subdomain): the first path segment selects the service and
|
||||||
// before forwarding — /match/player/login -> match app sees /player/login. This is
|
// is stripped before forwarding — /match/player/login -> match app sees /player/login.
|
||||||
// what the discovery document advertises; see ENDPOINT_STYLE.
|
|
||||||
const [, first, ...rest] = url.pathname.split('/')
|
const [, first, ...rest] = url.pathname.split('/')
|
||||||
if (first !== undefined && first in services) {
|
if (first !== undefined && first in services) {
|
||||||
url.pathname = `/${rest.join('/')}`
|
url.pathname = `/${rest.join('/')}`
|
||||||
@@ -120,20 +103,11 @@ export default {
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// `ns` is the one mounted app whose answer depends on this worker's own shape: the
|
|
||||||
// addresses it hands out have to be paths on this host. Passed as a var — the same
|
|
||||||
// way a deploy would — so the app itself stays free of any knowledge of mono.
|
|
||||||
if (resolved.name === 'ns') return ns.fetch(resolved.request, { ...env, ENDPOINT_STYLE }, ctx)
|
|
||||||
|
|
||||||
return services[resolved.name].fetch(resolved.request, env, ctx)
|
return services[resolved.name].fetch(resolved.request, env, ctx)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Only `match` runs a cron in the split deployment; this worker owns its presence sweep.
|
// Only `match` runs a cron in the split deployment; this worker owns its presence sweep.
|
||||||
scheduled(
|
scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> | void {
|
||||||
controller: ScheduledController,
|
|
||||||
env: Env,
|
|
||||||
ctx: ExecutionContext
|
|
||||||
): Promise<void> | void {
|
|
||||||
return matchScheduled(controller, env, ctx)
|
return matchScheduled(controller, env, ctx)
|
||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>
|
} satisfies ExportedHandler<Env>
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
const ORIGIN = 'https://example.com'
|
||||||
|
|
||||||
// Must match the DOMAIN var default in apps/mono/wrangler.jsonc.
|
|
||||||
const TEST_DOMAIN = 'rec.example.com'
|
|
||||||
|
|
||||||
// The facade's job is routing, not business logic, so one request that reaches a
|
// The facade's job is routing, not business logic, so one request that reaches a
|
||||||
// mounted app through the path prefix is enough to prove the wiring. `api` serves a
|
// mounted app through the path prefix is enough to prove the wiring. `api` serves a
|
||||||
// static game-config with no auth/DB, so it's a clean target. The api worker namespaces
|
// static game-config with no auth/DB, so it's a clean target. The api worker namespaces
|
||||||
@@ -27,22 +24,8 @@ describe('mono routing', () => {
|
|||||||
test('root path (no service, no prefix) serves the ns discovery document', async () => {
|
test('root path (no service, no prefix) serves the ns discovery document', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
// The ns worker serves the service-discovery document. This worker is one host, so
|
// The ns worker serves the service-discovery document.
|
||||||
// every service in it is a path on the base domain (the DOMAIN var default in
|
expect(await res.json()).toHaveProperty('Auth')
|
||||||
// wrangler.jsonc) — no per-service subdomains anywhere in the document.
|
|
||||||
const doc = (await res.json()) as Record<string, string>
|
|
||||||
expect(doc).toMatchObject({
|
|
||||||
Auth: `https://${TEST_DOMAIN}/auth`,
|
|
||||||
Rooms: `https://${TEST_DOMAIN}/rooms`,
|
|
||||||
Matchmaking: `https://${TEST_DOMAIN}/match`,
|
|
||||||
})
|
|
||||||
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('the ns service prefix serves that same document', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/ns/`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toMatchObject({ Rooms: `https://${TEST_DOMAIN}/rooms` })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unknown service prefix returns the facade 404', async () => {
|
test('unknown service prefix returns the facade 404', async () => {
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers'
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
|
||||||
// The facade bundles all 13 worker apps (see mono.app.ts), so the first request in
|
|
||||||
// a run pays a cold start for the lot of it — ~3.4s even on an idle machine. The
|
|
||||||
// 5s default leaves no headroom for that, and the whole-monorepo run puts 21
|
|
||||||
// projects on the CPU at once, which pushed these tests into flaky timeouts.
|
|
||||||
testTimeout: 30_000,
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
cloudflareTest({
|
cloudflareTest({
|
||||||
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
|
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
|
||||||
|
|||||||
@@ -50,20 +50,13 @@
|
|||||||
"crons": ["*/5 * * * *"]
|
"crons": ["*/5 * * * *"]
|
||||||
},
|
},
|
||||||
"logpush": false,
|
"logpush": false,
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
|
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
|
||||||
// the mounted `auth` app needs to verify Oculus logins. "local" store_id replaced
|
// with RECFLARE_SECRETS_STORE at deploy.
|
||||||
// with RECFLARE_SECRETS_STORE at deploy. Both must exist in the store or the deploy
|
|
||||||
// fails — see DEPLOYING.md.
|
|
||||||
"secrets_store_secrets": [
|
"secrets_store_secrets": [
|
||||||
{
|
{
|
||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
},
|
|
||||||
{
|
|
||||||
"binding": "META_APP_SECRET",
|
|
||||||
"store_id": "local",
|
|
||||||
"secret_name": "META_APP_SECRET"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
@@ -76,11 +69,6 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
"NAME": "mono", // logging tag; split workers derive this per-app
|
"NAME": "mono", // logging tag; split workers derive this per-app
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
// Base domain the discovery document is built from; replaced with RECFLARE_DOMAIN by
|
|
||||||
// both `just dev` and `just deploy-mono`. It must name the host that actually reaches
|
|
||||||
// this worker, which serves every service it mounts from a path on that ONE host — so
|
|
||||||
// deployed, it belongs on the APEX of that domain (see src/context.ts).
|
|
||||||
"DOMAIN": "rec.example.com"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,581 +0,0 @@
|
|||||||
/**
|
|
||||||
* The `Msg` bodies the client expects behind each {@link NotificationType}.
|
|
||||||
*
|
|
||||||
* Recovered from the client's Utf8Json generated formatters (see `recnet-patcher`,
|
|
||||||
* `il2cpp-tools/dtoshape.py`), not from a spec. Three properties of that decoder decide how
|
|
||||||
* much these matter:
|
|
||||||
*
|
|
||||||
* - **Every key is accepted in three casings** — Original, camelCase and all-lowercase. So
|
|
||||||
* `RoomId`, `roomId` and `roomid` are the same field. The spelling used below is the
|
|
||||||
* client's own canonical one.
|
|
||||||
* - **Unknown members are dropped in silence.** A typo'd key behaves exactly like an omitted
|
|
||||||
* one, which is why a wrong payload shows up as a blank UI rather than an error.
|
|
||||||
* - **A missing nested object is worse than a missing scalar.** Several handlers dereference
|
|
||||||
* one level down with no null guard, so omitting a nested field surfaces as a bare
|
|
||||||
* `NullReferenceException` in the client. Send a stub rather than nothing.
|
|
||||||
*
|
|
||||||
* Field *names* below are verified. Where a payload's type mapping could not be pinned down
|
|
||||||
* as confidently as its key list, the interface says so on the member.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** How a balance came to change. Log/telemetry only on the purchase frame — see below. */
|
|
||||||
export enum BalanceAddType {
|
|
||||||
Invalid = 0,
|
|
||||||
DirectBalanceWithMultiplier = 1,
|
|
||||||
FromGiftBox = 2,
|
|
||||||
NUXChallenge = 10,
|
|
||||||
AllNUXChallenges = 11,
|
|
||||||
DailyChallenge = 100,
|
|
||||||
AllDailyChallenges = 101,
|
|
||||||
FinishActivity = 200,
|
|
||||||
RecRoyaleMatchFinished = 250,
|
|
||||||
ChecklistCredit = 303,
|
|
||||||
WonGame = 1000,
|
|
||||||
LostGame = 1001,
|
|
||||||
WonGameRateLimited = 1002,
|
|
||||||
WonGamePartial = 1003,
|
|
||||||
LevelUp = 1100,
|
|
||||||
Registered = 1200,
|
|
||||||
CreatorReward = 1300,
|
|
||||||
CommercePurchase = 1400,
|
|
||||||
CommercePurchaseRevoked = 1401,
|
|
||||||
ManualRefund = 2000,
|
|
||||||
ManualThanks = 2010,
|
|
||||||
ManualApology = 2020,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which store a balance belongs to. Note the **wire key is `Platform`, not `BalanceType`** —
|
|
||||||
* the client's property is called `BalanceType` but carries a `[DataMember]` rename, so
|
|
||||||
* `balanceType` on the wire is dropped and the balance silently reads as `SteamPurchased`.
|
|
||||||
*
|
|
||||||
* Balances are held per `(CurrencyType, Platform)` pair, so this also selects which bucket a
|
|
||||||
* balance frame updates. `RecNetPurchased` is the one to use for a self-hosted store.
|
|
||||||
*/
|
|
||||||
export enum BalancePlatform {
|
|
||||||
NonPurchasedNotUsableInP2P = -2,
|
|
||||||
NonPurchasedDefault = -1,
|
|
||||||
SteamPurchased = 0,
|
|
||||||
OculusPurchased = 1,
|
|
||||||
PlayStationPurchased = 2,
|
|
||||||
MicrosoftPurchased = 3,
|
|
||||||
RecNetPurchased = 4,
|
|
||||||
IOSPurchased = 5,
|
|
||||||
GooglePlayPurchased = 6,
|
|
||||||
PicoPurchased = 8,
|
|
||||||
PlayStationNonPurchasedP2P = 100,
|
|
||||||
NonPlayStationNonPurchasedP2P = 101,
|
|
||||||
NonPurchasedEarnedByP2P = 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CurrencyType {
|
|
||||||
Invalid = 0,
|
|
||||||
LaserTagTickets = 1,
|
|
||||||
RecCenterTokens = 2,
|
|
||||||
LostSkullsGold = 100,
|
|
||||||
DraculaSilver = 101,
|
|
||||||
RecRoyaleSeason1 = 200,
|
|
||||||
RoomCurrency = 300,
|
|
||||||
ProgressionEvent = 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */
|
|
||||||
export enum KickReportCategory {
|
|
||||||
Moderator = -1,
|
|
||||||
Unknown = 0,
|
|
||||||
DeprecatedMicrophoneAbuse = 1,
|
|
||||||
Harassment = 2,
|
|
||||||
Cheating = 3,
|
|
||||||
DeprecatedImmatureBehavior = 4,
|
|
||||||
AFK = 5,
|
|
||||||
Misc = 6,
|
|
||||||
Underage = 7,
|
|
||||||
VoteKick = 10,
|
|
||||||
MisleadingPurchases = 11,
|
|
||||||
CoCUnderage = 100,
|
|
||||||
CoCSexual = 101,
|
|
||||||
CoCDiscrimination = 102,
|
|
||||||
CoCTrolling = 103,
|
|
||||||
CoCNameOrProfile = 104,
|
|
||||||
InappropriateClothing = 200,
|
|
||||||
IssuingInaccurateReports = 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum LogoutReason {
|
|
||||||
Unknown = 0,
|
|
||||||
UserInitiated = 1,
|
|
||||||
SessionTakeover = 2,
|
|
||||||
ForciblyLoggedOut = 3,
|
|
||||||
Banned = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The error the client renders when a `GoTo` fails. */
|
|
||||||
export enum GoToFailureError {
|
|
||||||
UnknownError = -1,
|
|
||||||
Success = 0,
|
|
||||||
NoSuchGame = 1,
|
|
||||||
PlayerNotOnline = 2,
|
|
||||||
InsufficientSpace = 3,
|
|
||||||
EventNotStarted = 4,
|
|
||||||
EventAlreadyFinished = 5,
|
|
||||||
BlockedFromRoom = 7,
|
|
||||||
JuniorNotAllowed = 11,
|
|
||||||
Banned = 12,
|
|
||||||
AlreadyInBestInstance = 13,
|
|
||||||
InsufficientRelationship = 14,
|
|
||||||
UpdateRequired = 16,
|
|
||||||
AlreadyInTargetInstance = 17,
|
|
||||||
UGCNotAllowed = 19,
|
|
||||||
NoSuchRoom = 20,
|
|
||||||
RoomIsNotActive = 22,
|
|
||||||
RoomBlockedByCreator = 23,
|
|
||||||
RoomIsPrivate = 25,
|
|
||||||
RoomInstanceIsPrivate = 26,
|
|
||||||
DeviceClassNotSupported = 30,
|
|
||||||
DeviceClassNotSupportedByRoomOwner = 31,
|
|
||||||
MovementModeNotSupportedByRoomOwner = 32,
|
|
||||||
EventIsPrivate = 35,
|
|
||||||
EventIsFull = 36,
|
|
||||||
RoomInviteExpired = 40,
|
|
||||||
NoAvailableRegion = 45,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Payloads ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** `StorefrontBalancePurchase` (62). */
|
|
||||||
export interface PurchaseBalanceModificationPayload {
|
|
||||||
BalanceAddType: BalanceAddType
|
|
||||||
/**
|
|
||||||
* The change, **for display only** — the client does not apply it. Its handler logs a
|
|
||||||
* line and then stores {@link Balance} outright, so a correct `Delta` with a stale
|
|
||||||
* `Balance` leaves the player's balance wrong.
|
|
||||||
*/
|
|
||||||
Delta: number
|
|
||||||
/** The post-transaction total. Absolute, and the only field that changes client state. */
|
|
||||||
Balance: number
|
|
||||||
Platform: BalancePlatform
|
|
||||||
CurrencyType: CurrencyType
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `StorefrontBalanceUpdate` (61) — a bare set of one bucket to an absolute value. */
|
|
||||||
export interface BalanceResponsePayload {
|
|
||||||
Balance: number
|
|
||||||
CurrencyType: CurrencyType
|
|
||||||
Platform: BalancePlatform
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One element of the `StorefrontBalanceAdd` (60) batch. */
|
|
||||||
export interface RewardBalanceModificationPayload {
|
|
||||||
BalanceAddType: BalanceAddType
|
|
||||||
BaseAward: number
|
|
||||||
BonusAward: number
|
|
||||||
RateLimit: number
|
|
||||||
CurrentCount: number
|
|
||||||
Total: number
|
|
||||||
Platform: BalancePlatform
|
|
||||||
BalanceInGiftBox: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ConsumableMappingAdded` (70) / `ConsumableMappingRemoved` (71). */
|
|
||||||
export interface ConsumableMappingPayload {
|
|
||||||
Id: number
|
|
||||||
ConsumableItemDesc: string
|
|
||||||
Count: number
|
|
||||||
InitialCount: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
ActiveDurationMinutes: number | null
|
|
||||||
IsActive: boolean
|
|
||||||
IsTransferable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ModerationKick` (22) and `ModerationUnkick`. Room bans go out as this with `IsBan`. */
|
|
||||||
export interface ModerationKickPayload {
|
|
||||||
ReportCategory: KickReportCategory
|
|
||||||
/** Seconds. */
|
|
||||||
Duration: number
|
|
||||||
GameSessionId: number
|
|
||||||
IsHostKick: boolean
|
|
||||||
Message: string
|
|
||||||
PlayerIdReporter: number | null
|
|
||||||
IsBan: boolean
|
|
||||||
IsVoiceModAutoban: boolean
|
|
||||||
IsWarning: boolean
|
|
||||||
VoteKickReason: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
TimeoutStartedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ModerationKickAttemptFailed` (23) — a vote-kick that didn't carry. */
|
|
||||||
export interface ModerationKickFailedPayload {
|
|
||||||
ReportCategory: KickReportCategory
|
|
||||||
YesVotes: number
|
|
||||||
NoVotes: number
|
|
||||||
PlayerIdReported: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ServerMaintenance` (25). */
|
|
||||||
export interface ServerMaintenancePayload {
|
|
||||||
StartsInMinutes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `Logout` (6). */
|
|
||||||
export interface LogoutPayload {
|
|
||||||
Reason: LogoutReason
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `MessageDeleted` (3) — the id of the message to drop. */
|
|
||||||
export interface MessageDeletedPayload {
|
|
||||||
Id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `MessageReceived` (2). */
|
|
||||||
export interface MessageReceivedPayload {
|
|
||||||
Id: number
|
|
||||||
FromPlayerId: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
SentTime: string
|
|
||||||
Type: number
|
|
||||||
Data: string
|
|
||||||
RoomId: number | null
|
|
||||||
PlayerEventId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `RelationshipChanged` (1). */
|
|
||||||
export interface RelationshipChangedPayload {
|
|
||||||
/** Note the spelling — capital `ID`, unlike every other id key on the wire. */
|
|
||||||
PlayerID: number
|
|
||||||
RelationshipType: number
|
|
||||||
Muted: boolean
|
|
||||||
Ignored: boolean
|
|
||||||
Favorited: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PlayerEventDeleted` (82) / `PlayerEventResponseDeleted` (84). */
|
|
||||||
export interface PlayerEventIdPayload {
|
|
||||||
PlayerEventId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PlayerEventResponseChanged` (83). */
|
|
||||||
export interface PlayerEventResponsePayload {
|
|
||||||
PlayerEvent: Record<string, unknown>
|
|
||||||
PlayerEventResponse: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PlayerEventCreated` (80) / `PlayerEventUpdated` (81). */
|
|
||||||
export interface PlayerEventPayload {
|
|
||||||
Tags: unknown[]
|
|
||||||
PlayerEventId: number
|
|
||||||
CreatorPlayerId: number
|
|
||||||
RoomId: number
|
|
||||||
SubRoomId: number | null
|
|
||||||
ClubId: number | null
|
|
||||||
Name: string
|
|
||||||
Description: string
|
|
||||||
ImageName: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
StartTime: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
EndTime: string
|
|
||||||
AttendeeCount: number
|
|
||||||
Accessibility: number
|
|
||||||
IsMultiInstance: boolean
|
|
||||||
SupportMultiInstanceRoomChat: boolean
|
|
||||||
DefaultBroadcastPermissions: number
|
|
||||||
CanRequestBroadcastPermissions: number
|
|
||||||
BroadcastingRoomInstanceId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PlayerProgressionLevelUpdate`. `XP` is progress into the level, not a lifetime total. */
|
|
||||||
export interface PlayerProgressionLevelPayload {
|
|
||||||
PlayerId: number
|
|
||||||
Level: number
|
|
||||||
XP: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ProgressionEventsRecordUpdate`. */
|
|
||||||
export interface ProgressionEventRecordPayload {
|
|
||||||
AccountId: number
|
|
||||||
Xp: number
|
|
||||||
GameMinutesToday: number
|
|
||||||
RewardsCollected: number
|
|
||||||
BonusRewardsCollected: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
XpBoostLastPurchasedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `SubscriptionUpdateProfile` (`"AccountUpdate"`) — the public projection of an account.
|
|
||||||
* The `Obscured*` CodeStage wrappers on the client side serialise as their plain underlying
|
|
||||||
* value, so nothing special is needed on the wire.
|
|
||||||
*/
|
|
||||||
export interface AccountUpdatePayload {
|
|
||||||
AccountId: number
|
|
||||||
UserName: string
|
|
||||||
DisplayName: string
|
|
||||||
DisplayEmoji: string
|
|
||||||
ProfileImage: string
|
|
||||||
BannerImage: string
|
|
||||||
TreatAsJunior: boolean
|
|
||||||
HasBirthday: boolean
|
|
||||||
PersonalPronouns: number
|
|
||||||
IdentityFlags: number
|
|
||||||
/** Lowercase-camel on the client's canonical spelling, unlike its neighbours. */
|
|
||||||
createdAt: string
|
|
||||||
IsJunior: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `SubscriptionUpdateSelfProfile` (`"SelfAccountUpdate"`) — the owner-only projection: the
|
|
||||||
* six private fields **first**, then every field of {@link AccountUpdatePayload}. That order
|
|
||||||
* is not cosmetic; the client's decoder emits a derived DTO's own members before its base's.
|
|
||||||
*/
|
|
||||||
export interface SelfAccountUpdatePayload extends AccountUpdatePayload {
|
|
||||||
Email: string
|
|
||||||
Phone: string
|
|
||||||
/** ISO-8601. Its absence is what caused the under-13 junior crash. */
|
|
||||||
Birthday: string | null
|
|
||||||
JuniorState: number
|
|
||||||
ParentAccountId: number | null
|
|
||||||
AvailableUsernameChanges: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ChatMessageReceived` and `PlayerLeftChat` — same shape. */
|
|
||||||
export interface ChatMessagePayload {
|
|
||||||
ChatMessageId: number
|
|
||||||
ChatThreadId: number
|
|
||||||
SenderPlayerId: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
TimeSent: string
|
|
||||||
Contents: string
|
|
||||||
ModerationState: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ClubMembershipUpdate`. */
|
|
||||||
export interface ClubMembershipPayload {
|
|
||||||
ClubId: number
|
|
||||||
MembershipType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `CreatorClubSubscriptionUpdate`. */
|
|
||||||
export interface CreatorClubSubscriptionPayload {
|
|
||||||
CreatorAccountId: number
|
|
||||||
ClubId: number
|
|
||||||
MembershipType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `RoomCurrencyCreated` / `RoomCurrencyModified`. */
|
|
||||||
export interface RoomCurrencyPayload {
|
|
||||||
CurrencyId: string
|
|
||||||
RoomId: number | null
|
|
||||||
Name: string
|
|
||||||
Description: string
|
|
||||||
CurrencyType: CurrencyType
|
|
||||||
Limit: number
|
|
||||||
ImageName: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
ModifiedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `RoomCurrencyDeleted`. */
|
|
||||||
export interface RoomCurrencyDeletedPayload {
|
|
||||||
CurrencyId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `LocalRoomKeyCreated` (120). */
|
|
||||||
export interface LocalRoomKeyPayload {
|
|
||||||
RoomKeyId: number
|
|
||||||
ReplicationId: string
|
|
||||||
RoomId: number
|
|
||||||
Name: string
|
|
||||||
Description: string
|
|
||||||
Price: number
|
|
||||||
PurchaseCurrencyId: string | null
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
ImageName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `LocalRoomKeyDeleted` (121). */
|
|
||||||
export interface LocalRoomKeyDeletedPayload {
|
|
||||||
RoomKeyId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `AnnouncementUpdate`. */
|
|
||||||
export interface AnnouncementPayload {
|
|
||||||
AnnouncementId: number
|
|
||||||
AnnouncementType: number
|
|
||||||
Title: string
|
|
||||||
Body: string
|
|
||||||
ImageName: string
|
|
||||||
LinkType: number
|
|
||||||
LinkName: string
|
|
||||||
LinkButtonLabel: string
|
|
||||||
LinkUri: string
|
|
||||||
Platform: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `AnnouncementDelete`. */
|
|
||||||
export interface AnnouncementDeletePayload {
|
|
||||||
AnnouncementId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `CommunityBoardAnnouncementUpdate` (96) — the board's single current announcement. */
|
|
||||||
export interface CommunityBoardAnnouncementPayload {
|
|
||||||
Message: string
|
|
||||||
MoreInfoUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ReputationUpdate`. */
|
|
||||||
export interface ReputationPayload {
|
|
||||||
AccountId: number
|
|
||||||
IsCheerful: boolean
|
|
||||||
SelectedCheer: number | null
|
|
||||||
CheerCredit: number
|
|
||||||
CheerGeneral: number
|
|
||||||
CheerHelpful: number
|
|
||||||
CheerCreative: number
|
|
||||||
CheerGreatHost: number
|
|
||||||
CheerSportsman: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PhotonAccessToken`. */
|
|
||||||
export interface PhotonAccessTokenPayload {
|
|
||||||
RoomInstanceId: number
|
|
||||||
PhotonAccessToken: string
|
|
||||||
Permissions: unknown[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved`. */
|
|
||||||
export interface KeepsakeInstancePayload {
|
|
||||||
KeepsakeInstanceId: string
|
|
||||||
KeepsakeCategoryConfigId: number
|
|
||||||
PlacedByAccountId: number
|
|
||||||
RoomId: number
|
|
||||||
SubRoomId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `PlayerCustomAvatarItemModerated`. */
|
|
||||||
export interface CustomAvatarItemPayload {
|
|
||||||
CustomAvatarItemId: string
|
|
||||||
CreatorAccountId: number
|
|
||||||
Name: string
|
|
||||||
Description: string
|
|
||||||
Price: number
|
|
||||||
Accessibility: number
|
|
||||||
IsFeatured: boolean
|
|
||||||
BaseAvatarItemId: number | null
|
|
||||||
BaseAvatarItemColor: string
|
|
||||||
DesignFilename: string
|
|
||||||
ThumbnailImageFilename: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
ModifiedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GoToFailure`. */
|
|
||||||
export interface GoToFailurePayload {
|
|
||||||
Error: GoToFailureError
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `AppVersionUpdate` — the live replacement for the dead `ModerationUpdateRequired` (21). */
|
|
||||||
export interface AppVersionUpdatePayload {
|
|
||||||
ActivePlatforms: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `IncentivizedReferralUpdate`. */
|
|
||||||
export interface IncentivizedReferralPayload {
|
|
||||||
InviteeAccountId: number
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
/** ISO-8601. */
|
|
||||||
VerifiedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `InfluencerSupportedUpdate`. */
|
|
||||||
export interface InfluencerSupportedPayload {
|
|
||||||
SupportedInfluencerId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `StringAutoLocalizationJob`. */
|
|
||||||
export interface StringAutoLocalizationJobPayload {
|
|
||||||
Scope: string
|
|
||||||
Status: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `SubscriptionUpdateGameSession` (`"RoomInstanceUpdate"`). */
|
|
||||||
export interface RoomInstanceUpdatePayload {
|
|
||||||
RoomInstanceId: number
|
|
||||||
RoomId: number
|
|
||||||
SubRoomId: number
|
|
||||||
Location: string
|
|
||||||
EventId: number
|
|
||||||
ClubId: number
|
|
||||||
RoomCode: string
|
|
||||||
/** Canonical spelling is lower-camel here, unlike its neighbours. */
|
|
||||||
photonRegionId: string
|
|
||||||
PhotonRoomId: string
|
|
||||||
Name: string
|
|
||||||
MaxCapacity: number
|
|
||||||
IsFull: boolean
|
|
||||||
IsPrivate: boolean
|
|
||||||
IsInProgress: boolean
|
|
||||||
EncryptVoiceChat: boolean
|
|
||||||
RoomInstanceType: number
|
|
||||||
MatchmakingPolicy: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GiftPackageReceived` (30), `GiftPackageReceivedImmediate` (31) and
|
|
||||||
* `GiftPackageRewardSelectionReceived` (32) all carry this. Note `BalanceType` here is NOT
|
|
||||||
* the renamed-to-`Platform` field seen on the balance payloads — it is its own member and
|
|
||||||
* keeps its name; `Platform` is a separate key on the same object.
|
|
||||||
*/
|
|
||||||
export interface GiftPackagePayload {
|
|
||||||
Id: number | null
|
|
||||||
FromPlayerId: number | null
|
|
||||||
ConsumableItemDesc: string
|
|
||||||
AvatarItemType: number | null
|
|
||||||
AvatarItemDesc: string
|
|
||||||
EquipmentPrefabName: string
|
|
||||||
EquipmentModificationGuid: string
|
|
||||||
CurrencyType: CurrencyType
|
|
||||||
Currency: number
|
|
||||||
Xp: number
|
|
||||||
GiftContext: number
|
|
||||||
GiftRarity: number
|
|
||||||
Message: string
|
|
||||||
Platform: number
|
|
||||||
PlatformsToSpawnOn: unknown
|
|
||||||
BalanceType: BalancePlatform | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `gift.manualconsumed`. */
|
|
||||||
export interface GiftManualConsumedPayload {
|
|
||||||
GiftPackageId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `RewardSelectionReceived` — distinct from the gift-package frames above. */
|
|
||||||
export interface RewardSelectionPayload {
|
|
||||||
RewardSelectionId: number
|
|
||||||
RewardType: number
|
|
||||||
Message: string
|
|
||||||
GiftContext: number
|
|
||||||
GiftDrop1: unknown
|
|
||||||
GiftDrop2: unknown
|
|
||||||
GiftDrop3: unknown
|
|
||||||
Subscriber_GiftDrop3: unknown
|
|
||||||
/** ISO-8601. */
|
|
||||||
CreatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Channels whose handler takes no argument at all — the client refetches rather than reading
|
|
||||||
* the frame. Send `{}`; anything else is ignored.
|
|
||||||
*/
|
|
||||||
export type NoPayload = Record<string, never>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user