Compare commits

...

114 Commits

Author SHA1 Message Date
Devin Zuczek f871736839 [api] player photo tagging setting
GET/PUT /api/players/v1/playerPhotoTaggingSetting, ported from the reference's
PlayerDB.Get/SetPlayerPhotoTaggingSetting. Backed by the shared player-settings
KV bag (owned by the `playersettings` worker) under a `PlayerPhotoTaggingSetting`
key rather than its own table.

The setting is served as the enum ORDINAL (0 Anyone / 1 Friends / 2 NoOne) — the
reference registers no JsonStringEnumConverter, so the client decodes a number.
Unset reads back 0; the PUT answers a bare true, or false when the body carries
no recognizable setting, as the reference's bool does.

The write merges into the player's settings map and seeds the `playersettings`
defaults when there is none yet, so writing this key can't cost a player the
seeding that worker's first read would have done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:34:54 -04:00
Devin Zuczek ba0aec92a4 [lists] add worker 2026-08-15 23:09:56 -04:00
Devin Zuczek 11b037a2f1 more stubs 2026-08-15 13:54:34 -04:00
Devin Zuczek 8ea0caa1e5 more stubs 2026-08-15 13:14:59 -04:00
Devin Zuczek dda18b0d2c stubs 2026-08-15 13:14:59 -04:00
Devin Zuczek 7430116339 correct one this time 2026-08-15 13:14:59 -04:00
Devin Zuczek 3edc315205 20250718.0 2026-08-15 13:14:59 -04:00
Devin Zuczek ec52985ef2 [2025] unstable 2026-08-15 13:14:59 -04:00
Devin Zuczek 551585179a [auth][api] accept the 20250424.01 client
Version check now answers "current" for a set of builds rather than one:
SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is
unchanged and still what the server reports for itself (presence, rn.ver).

Adds GET /api/versioncheck/islandedversions, always [] — we never island a
build off into its own matchmaking pool.

The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a
deviceId/platformAuth/time form body where the 2023 build GETs it, so that
route now takes both methods. The body is accepted and ignored for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:14:59 -04:00
devin 66c09806f9 [www] beta room uploads (#34)
* WIP room uploads

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

* require turnstile

* homepage refresh

* implemented rooms visited endpoint for friends

* update default profile pic

* add a meta download button

* add subroom permissions

* enable signup
2026-08-03 15:20:10 -04:00
Devin Zuczek a46f6db9d7 meta platform login 2026-08-02 01:57:01 -04:00
Devin Zuczek b3f1d04823 add invention blob hash which does not seem to help 2026-08-01 12:15:56 -04:00
Devin Zuczek 55cb769de9 add github workflow to test 2026-07-31 14:03:35 -04:00
211 changed files with 78310 additions and 6362 deletions
@@ -0,0 +1,249 @@
---
name: daily-objectives
description: Guide for parsing/writing daily objectives in /api/config/v2
---
# Daily objectives — config shape and the full type enum
Reference for authoring `dailyObjectives` in `GET /api/config/v2`. Extracted from the 20230414
client (`GameAssembly.dll` mtime 2026-07-23). See `SHAPES.md` for the method used.
> **This table survives game upgrades.** Enum *member names and values* are not obfuscated — only
> type and method names re-roll per build. So the ids below stay valid across client versions unless
> Rec Room adds or removes members. The obfuscated names in this file (`PNLFAAAPEID`,
> `LCPOOJEAMJA`, …) are the only part that will go stale.
## Where it lives
`GET api/config/v2` (service `API`) → `JAGPNOHGHBG.DownloadConfigSettings`, deserialized as a bare
`LCPOOJEAMJA` via `SendWithRequiredResponseAsync` — response required, no envelope.
Top-level keys, in declaration order (all accept three casings):
| Wire name | Type |
| --- | --- |
| `levelProgressionMaps` | array of objects |
| **`dailyObjectives`** | **jagged array** — `FCAOHDFPEAP[][]` |
| `serverMaintenance` | object |
| `autoMicMutingConfig` | object |econ
| `storefrontConfig` | object |
| `roomKeyConfig` | object |
| `roomCurrencyConfig` | object |
| `shareBaseUrl` | string |
A `Dictionary<int,int>` declared first on the type carries `[IgnoreDataMember]` — client-only, never
on the wire.
## `dailyObjectives` shape
Array of arrays. Each leaf element (`FCAOHDFPEAP`, formatter `BMBOPLBKALL`) has exactly two members:
| Wire name | Type |
| --- | --- |
| `type` | int — a value from the table below |
| `score` | int — the target / threshold |
```json
{
"dailyObjectives": [
[ { "type": 1, "score": 1 },
{ "type": 6, "score": 5 },
{ "type": 31, "score": 3 } ],
[ { "type": 2, "score": 1 },
{ "type": 65, "score": 2 },
{ "type": 300, "score": 1 } ]
]
}
```
**Unverified:** what the outer dimension indexes. The `updateobjective` DTO carries both `index` and
`group`, which lines up with `dailyObjectives[group][index]`, and `DailyObjective1/2/3` existing as
distinct types suggests three slots per set — but neither is confirmed against the consumer. Serve a
distinctive jagged array and watch the `group`/`index` pairs your endpoint receives.
## Numbering scheme
The ids are blocked, which tells you where new entries belong:
| Range | Meaning | Count |
| --- | --- | --- |
| `-1` – `15` | meta / rollup / social | 16 |
| `20` – `26` | onboarding (OOBE, NUX) | 5 |
| `30` – `75` | general engagement | 45 |
| `100`+ | per-activity, one block each | 67 |
Activity blocks follow a `Games` / `Wins` / `<activity-specific>` pattern. Quest (`1000`) additionally
sub-blocks by scenario in steps of 10.
**Careful with `10`–`15`.** `DailyObjective1/2/3`, `AllDailyObjectives`, `CompleteAnyDaily` and
`CompleteAnyWeekly` read as *rollup* types the reward system uses to track "you finished daily #1",
not as objective definitions themselves. Using them as leaf `type` values in `dailyObjectives` is
probably not what you want. (Inference from naming — not traced.)
## Full enum — `PNLFAAAPEID`, 133 values
| id | name |
| --- | --- |
| -1 | Default |
| 1 | FirstSessionOfDay |
| 2 | AddAFriend |
| 3 | PartyUp |
| 4 | AllOtherChallenges |
| 5 | LevelUp |
| 6 | CheerAPlayer |
| 7 | PointedAtPlayer |
| 8 | CheerARoom |
| 9 | SubscribeToPlayer |
| 10 | DailyObjective1 |
| 11 | DailyObjective2 |
| 12 | DailyObjective3 |
| 13 | AllDailyObjectives |
| 14 | CompleteAnyDaily |
| 15 | CompleteAnyWeekly |
| 20 | OOBE_GoToLockerRoom |
| 21 | OOBE_GoToActivity |
| 22 | OOBE_FinishActivity |
| 25 | NUX_PunchcardObjective |
| 26 | NUX_AllPunchcardObjectives |
| 30 | GoToRecCenter |
| 31 | FinishActivity |
| 32 | VisitACustomRoom |
| 33 | CreateACustomRoom |
| 35 | ScoreBasketInRecCenter |
| 36 | UploadPhotoToRecNet |
| 37 | UpdatePlayerBio |
| 38 | SaveOutfitSlot |
| 39 | PurchaseClothingItem |
| 40 | PurchaseNonClothingItem |
| 41 | DrinkWater |
| 42 | ColorOnWhiteboard |
| 43 | SetBasketballSkin |
| 44 | ThrowBasketball |
| 45 | PlaceInventionInDorm |
| 46 | ChangeDormRoomSkin |
| 47 | ToggleOwnedClothes |
| 48 | EquipHat |
| 49 | LoadOutfit |
| 50 | SaveNewOutfitSlot |
| 51 | SpawnCamera |
| 52 | TakeSelfie |
| 53 | PrintSelfie |
| 54 | TakePictureOfPlayer |
| 55 | PrintPictureOfPlayer |
| 56 | PublishSelfieWithPlayer |
| 57 | SpawnFoodWithOtherPlayers |
| 58 | EmoteInRecCenter |
| 59 | SendRoomChatInRecCenter |
| 60 | UseFrendotron |
| 61 | GoToDormRoom |
| 62 | VisitSpecificRoom |
| 63 | VisitPublicRRO |
| 64 | VisitPublicRoomBySource |
| 65 | FavoriteARoom |
| 66 | TakePhotoWithFilter |
| 67 | OpenYourPlayerProfile |
| 68 | OpenOnlineStatusModal |
| 69 | ChangeProfilePicture |
| 70 | ChangePlayerDisplayName |
| 71 | ChangePlayerDescriptionText |
| 72 | OpenPlayerPronounsModal |
| 73 | OpenOtherPlayersProfile |
| 74 | VisitPlayersPortfolio |
| 75 | FavoriteAFriend |
| 100 | CharadesGames |
| 101 | CharadesWinsPerformer |
| 102 | CharadesWinsGuesser |
| 200 | DiscGolfWins |
| 201 | DiscGolfGames |
| 202 | DiscGolfHolesUnderPar |
| 300 | DodgeballWins |
| 301 | DodgeballGames |
| 302 | DodgeballHits |
| 400 | PaddleballGames |
| 401 | PaddleballWins |
| 402 | PaddleballScores |
| 500 | PaintballAnyModeGames |
| 501 | PaintballAnyModeWins |
| 502 | PaintballAnyModeHits |
| 600 | PaintballCTFWins |
| 601 | PaintballCTFGames |
| 602 | PaintballCTFHits |
| 603 | PaintballFlagCaptures |
| 700 | PaintballTeamBattleWins |
| 701 | PaintballTeamBattleGames |
| 702 | PaintballTeamBattleHits |
| 710 | PaintballFreeForAllWins |
| 711 | PaintballFreeForAllGames |
| 712 | PaintballFreeForAllHits |
| 800 | SoccerWins |
| 801 | SoccerGames |
| 802 | SoccerGoals |
| 900 | BowlingGames |
| 901 | BowlingWins |
| 902 | BowlingStrike |
| 1000 | QuestGames |
| 1001 | QuestWins |
| 1002 | QuestPlayerRevives |
| 1003 | QuestEnemyKills |
| 1010 | QuestGames_Goblin1 |
| 1011 | QuestWins_Goblin1 |
| 1012 | QuestPlayerRevives_Goblin1 |
| 1013 | QuestEnemyKills_Goblin1 |
| 1020 | QuestGames_Goblin2 |
| 1021 | QuestWins_Goblin2 |
| 1022 | QuestPlayerRevives_Goblin2 |
| 1023 | QuestEnemyKills_Goblin2 |
| 1030 | QuestGames_Scifi1 |
| 1031 | QuestWins_Scifi1 |
| 1032 | QuestPlayerRevives_Scifi1 |
| 1033 | QuestEnemyKills_Scifi1 |
| 1040 | QuestGames_Pirate1 |
| 1041 | QuestWins_Pirate1 |
| 1042 | QuestPlayerRevives_Pirate1 |
| 1043 | QuestEnemyKills_Pirate1 |
| 1050 | QuestGames_Dracula1 |
| 1051 | QuestWins_Dracula1 |
| 1052 | QuestPlayerRevives_Dracula1 |
| 1053 | QuestEnemyKills_Dracula1 |
| 2000 | ArenaGames |
| 2001 | ArenaWins |
| 2002 | ArenaPlayerRevives |
| 2003 | ArenaHeroTags |
| 2004 | ArenaBotTags |
| 3000 | RecRoyaleGames |
| 3001 | RecRoyaleWins |
| 3002 | RecRoyaleTags |
| 4000 | StuntRunnerGames |
| 4001 | StuntRunnerWins |
| 5000 | RecRallyGames |
| 5001 | RecRallyWins |
## Machine-readable
```json
{"Default":-1,"FirstSessionOfDay":1,"AddAFriend":2,"PartyUp":3,"AllOtherChallenges":4,"LevelUp":5,"CheerAPlayer":6,"PointedAtPlayer":7,"CheerARoom":8,"SubscribeToPlayer":9,"DailyObjective1":10,"DailyObjective2":11,"DailyObjective3":12,"AllDailyObjectives":13,"CompleteAnyDaily":14,"CompleteAnyWeekly":15,"OOBE_GoToLockerRoom":20,"OOBE_GoToActivity":21,"OOBE_FinishActivity":22,"NUX_PunchcardObjective":25,"NUX_AllPunchcardObjectives":26,"GoToRecCenter":30,"FinishActivity":31,"VisitACustomRoom":32,"CreateACustomRoom":33,"ScoreBasketInRecCenter":35,"UploadPhotoToRecNet":36,"UpdatePlayerBio":37,"SaveOutfitSlot":38,"PurchaseClothingItem":39,"PurchaseNonClothingItem":40,"DrinkWater":41,"ColorOnWhiteboard":42,"SetBasketballSkin":43,"ThrowBasketball":44,"PlaceInventionInDorm":45,"ChangeDormRoomSkin":46,"ToggleOwnedClothes":47,"EquipHat":48,"LoadOutfit":49,"SaveNewOutfitSlot":50,"SpawnCamera":51,"TakeSelfie":52,"PrintSelfie":53,"TakePictureOfPlayer":54,"PrintPictureOfPlayer":55,"PublishSelfieWithPlayer":56,"SpawnFoodWithOtherPlayers":57,"EmoteInRecCenter":58,"SendRoomChatInRecCenter":59,"UseFrendotron":60,"GoToDormRoom":61,"VisitSpecificRoom":62,"VisitPublicRRO":63,"VisitPublicRoomBySource":64,"FavoriteARoom":65,"TakePhotoWithFilter":66,"OpenYourPlayerProfile":67,"OpenOnlineStatusModal":68,"ChangeProfilePicture":69,"ChangePlayerDisplayName":70,"ChangePlayerDescriptionText":71,"OpenPlayerPronounsModal":72,"OpenOtherPlayersProfile":73,"VisitPlayersPortfolio":74,"FavoriteAFriend":75,"CharadesGames":100,"CharadesWinsPerformer":101,"CharadesWinsGuesser":102,"DiscGolfWins":200,"DiscGolfGames":201,"DiscGolfHolesUnderPar":202,"DodgeballWins":300,"DodgeballGames":301,"DodgeballHits":302,"PaddleballGames":400,"PaddleballWins":401,"PaddleballScores":402,"PaintballAnyModeGames":500,"PaintballAnyModeWins":501,"PaintballAnyModeHits":502,"PaintballCTFWins":600,"PaintballCTFGames":601,"PaintballCTFHits":602,"PaintballFlagCaptures":603,"PaintballTeamBattleWins":700,"PaintballTeamBattleGames":701,"PaintballTeamBattleHits":702,"PaintballFreeForAllWins":710,"PaintballFreeForAllGames":711,"PaintballFreeForAllHits":712,"SoccerWins":800,"SoccerGames":801,"SoccerGoals":802,"BowlingGames":900,"BowlingWins":901,"BowlingStrike":902,"QuestGames":1000,"QuestWins":1001,"QuestPlayerRevives":1002,"QuestEnemyKills":1003,"QuestGames_Goblin1":1010,"QuestWins_Goblin1":1011,"QuestPlayerRevives_Goblin1":1012,"QuestEnemyKills_Goblin1":1013,"QuestGames_Goblin2":1020,"QuestWins_Goblin2":1021,"QuestPlayerRevives_Goblin2":1022,"QuestEnemyKills_Goblin2":1023,"QuestGames_Scifi1":1030,"QuestWins_Scifi1":1031,"QuestPlayerRevives_Scifi1":1032,"QuestEnemyKills_Scifi1":1033,"QuestGames_Pirate1":1040,"QuestWins_Pirate1":1041,"QuestPlayerRevives_Pirate1":1042,"QuestEnemyKills_Pirate1":1043,"QuestGames_Dracula1":1050,"QuestWins_Dracula1":1051,"QuestPlayerRevives_Dracula1":1052,"QuestEnemyKills_Dracula1":1053,"ArenaGames":2000,"ArenaWins":2001,"ArenaPlayerRevives":2002,"ArenaHeroTags":2003,"ArenaBotTags":2004,"RecRoyaleGames":3000,"RecRoyaleWins":3001,"RecRoyaleTags":3002,"StuntRunnerGames":4000,"StuntRunnerWins":4001,"RecRallyGames":5000,"RecRallyWins":5001}
```
## Related endpoints
| Endpoint | Purpose |
| --- | --- |
| `GET api/config/v2` | serves `dailyObjectives` (this file) |
| `GET api/objectives/v1/myprogress` | player's current progress |
| `POST api/objectives/v1/updateobjective` | one objective update — `{index, group, progress, visualProgress, isCompleted, hasClaimedReward}` → `{group, isCompleted, clearedAt}` |
| `POST api/objectives/v1/completegroup` | group completion |
| `POST api/objectives/v1/cleargroup` | group reset |
The objectives endpoints are on the **Econ** service (`econ.*`); config is on **API** (`api.*`).
## How this was extracted
```sh
# in the il2cpp scratchpad, with Il2CppDumper output in ./out/
grep -n "enum PNLFAAAPEID" out/dump.cs # find the block
# then parse `public const PNLFAAAPEID <name> = <value>;` lines until the closing brace
```
The `dailyObjectives` wire name came from the Utf8Json formatter, not the property name — see
`SHAPES.md` §1. Formatter `.ctor` RVAs for this build: `LCPOOJEAMJA` → `0x3512E10`,
`FCAOHDFPEAP` → `0x34EE5F0`.
@@ -0,0 +1,183 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — node types, scene-id predicates, and the shared-scene traps
---
# The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a challenge in
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
**The server never evaluates these rules.** The client reads the tree, watches its own
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
So the tree is a _specification handed to the client_, and a malformed one fails silently —
the challenge just never completes. Nothing server-side will tell you.
Everything here was read off one captured live rotation, not a spec. Meanings marked
_(inferred)_ are read from how values line up with the strings the client renders; the rest
are pinned by the data.
## `Config` is an escaped JSON string
Not a nested object. In the file it looks like:
```json
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[...]}"
```
Author the tree as an object and stringify it into the field — don't hand-escape:
```sh
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
```
To read one back:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
```
## Node types
Each node carries a numeric type in `ct`. Two composite kinds appear:
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
result (AND).
- **Counter** (`ct: 1`) — `ctc` holds the child node to count, `t` is the target count.
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
the client echoes it back, so keep emitting it.
## Predicate leaves
Leaves carry `vs`, a list of accepted values matched as OR.
| `ct` | Shape | Meaning |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
## The two idioms
Every challenge in the captured rotation is one of these.
```jsonc
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 9, "vs": [true], "v": "won" },
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
]}
// "Complete 5 Charades games" — count matching sessions to a target
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
]}
]}
```
The quest challenges have **no `t`** (one qualifying session is the whole goal) and the
counted ones have **no `won` predicate** (finishing counts, winning is irrelevant). A "one
map only" challenge is the counted shape with a single-entry scene list.
## Scene ids, not room ids
`ct: 7` matches `UnitySceneId`, so one guid can name several rooms — a screens room, its VR
twin, and the standalone base room all share a scene. The captured "Complete 10 games in
^Paintball" lists six guids, which are the subrooms of _both_ `Paintball` and `PaintballVR`,
each of which is also a standalone base room (`River`, `Clearcut`, …). One list covers every
way in.
Resolve a guid against `SubRooms[].UnitySceneId` in `apps/rooms/static/ImportRooms.json`
(same data as `apps/rooms/migrations/0002_import_rooms.sql`). Run from the repo root:
```sh
cat > /tmp/scene.ts <<'EOF'
// path is resolved against the cwd, so run this from the repo root
const rooms = await Bun.file('apps/rooms/static/ImportRooms.json').json()
const want = new Set(process.argv.slice(2))
const byScene = new Map<string, string[]>()
for (const r of rooms as any[])
for (const s of r.SubRooms ?? [])
byScene.set(s.UnitySceneId, [...(byScene.get(s.UnitySceneId) ?? []), `${r.Name}/${s.Name}`])
for (const [id, names] of byScene) if (!want.size || want.has(id)) console.log(id, names.join(', '))
EOF
bun run /tmp/scene.ts 380d18b5-de9c-49f3-80f7-f4a95c1de161
# → 380d18b5-… Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home
```
With no arguments it dumps every scene, which is how you go the other way — from a room name
to the guid to put in `vs`.
### Shared scenes to watch for
These guids resolve to more than one room, so a challenge naming one also completes in the
others. Most are a deliberate screens/VR/base-room trio, but two are genuine surprises:
**`Soccer/Home` and `Stadium/Home` are the same scene**, so a soccer challenge also completes
in the Stadium, and `Dodgeball` shares its scene with the plain `Gym`.
| Scene id | Rooms |
| ----------- | ------------------------------------------------------------------ |
| `6d5eea4b…` | Soccer/Home, **Stadium/Home** |
| `3d474b26…` | Dodgeball/Home, **Gym/Home**, DodgeballVR/Home |
| `ae929543…` | Bowling/Home, BowlingAlley/Home |
| `f6f7256c…` | DiscGolfLake/Home, Lake/Home |
| `d9378c9f…` | DiscGolfPropulsion/Home, PropulsionTestRange/Home |
| `239e676c…` | LaserTag/Hangar, Hangar/Home |
| `9d6456ce…` | LaserTag/CyberJunkCity, LaserTagCyberJunk/Home, CyberJunkCity/Home |
| `e122fe98…` | Paintball/River, PaintballVR/River, River/Home |
| `a785267d…` | Paintball/Homestead, PaintballVR/Homestead, Homestead/Home |
| `ff4c6427…` | Paintball/Quarry, PaintballVR/Quarry, Quarry/Home |
| `380d18b5…` | Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home |
| `58763055…` | Paintball/Spillway, PaintballVR/Spillway, Spillway/Home |
| `65ddbb48…` | Paintball/Drive-in, PaintballVR/Drive-in, DriveIn/Home |
Regenerate this list with the script above and no arguments.
## Progress fields (`cc`, `c`) — client-side only
On `updateProgress` the client posts the same tree back with its own progress written into
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`**
(`"c":true`) marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't add `cc`/`c` to an authored tree, and don't try to read progress
out of one.
## Authoring a new challenge
1. Pick the idiom: one-shot (`ct: 0` root, add the `won` predicate if winning is required)
or counted (`ct: 1` root with `t`).
2. Resolve the scenes with the script above, and check the shared-scene table — decide
whether the extra rooms it lets in are acceptable.
3. Build the tree as an object, stringify it twice into `Config`.
4. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
and write the real goal in `Description` — `Name` is an internal slug that is not
authoritative (captured id `63` is named `Complete3SpillwayGames` but its `Config` and
description are Clearcut).
5. Leave `Complete: false`; `getCurrent` stamps it per caller.
6. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
and a new map id is what resets stored completions.
7. Keep `ServerTime` inside `StartAt`…`EndAt`, or the client renders the rotation as expired.
Sanity check the file parses and every tree parses:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
```
Then `bun turbo -F econ test` — `src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it.
## Related
- `apps/econ/README.md` — the rest of the weekly-challenge file (top level, `Gift`, progress)
- `.agents/daily-objectives/SKILL.md` — the other objective system, on `GET api/config/v2`
+54 -3
View File
@@ -1,9 +1,22 @@
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
RECFLARE_DOMAIN=rec.example.com
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
# worker's directory name. Defaults to the directory name when unset.
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
# Optional per-service subdomain overrides, as a compact JSON object keyed by the
# service's default subdomain (which, for a service backed by a worker, is that
# worker's directory name). Unlisted services keep their default.
#
# One entry moves both sides: it decides which host `just deploy` puts the worker on
# AND which host the `ns` discovery document advertises to the client, so the two can't
# drift apart. Redeploy `ns` (`just deploy -F ns`) after changing this.
#
# {"playersettings":"settings"} the playersettings worker moves to settings.<domain>
# {"moderation":"api"} Moderation has no worker of its own, so this is a pure
# client-side redirect: it points the client's Moderation
# calls at the api worker, which is where the
# /api/PlayerReporting/… routes actually live
#
# Keep it compact — no spaces. Services are listed in SERVICES.md.
# RECFLARE_SUBDOMAINS='{"moderation":"api"}'
# Id of the shared `recflare` D1 database (create it manually with
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
@@ -52,6 +65,20 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a
# banned one, and refuses a signup from either. Without that, an evader is back in the
# game with a new account in under a minute.
# ...`platform` matches a Steam/Meta identity the player PROVED — sharp, no false
# positives worth the name.
# ...`ip` matches the signup/last-login address — coarse. A household, dorm, campus or
# mobile carrier shares one address, so this arm bans the banned player's housemates
# along with them, and locks them out of signing up at all. Set BAN_EVASION_MATCH=platform
# to keep the sharp arm only, or off to make a ban apply to just the banned account.
# A ban ALWAYS applies to the account it was handed to, whatever this is set to.
# RECFLARE_BAN_EVASION_MATCH=ip,platform
# How many rooms one account may create (`rooms`) and how many clubs (`clubs`).
# Enforced on creation only — lowering either never touches what players already have,
# it just stops new ones. Set either to 0 to turn that cap off.
@@ -60,7 +87,31 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
# room named MyHub instead, whether the client asked for it by id or by name, and whether
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
# substitute's default one, and a target that doesn't exist leaves the original room in
# place. Following a friend or joining a specific instance is unaffected — those join a
# live instance, which is already in whichever room it was created in.
# RECFLARE_ROOM_REDIRECTS=2=MyHub
# RecCenterTokens a new player is granted, the first time their balance is read (`econ`).
# 0 means players start broke. Applies only to players who haven't been granted yet —
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
#
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
# --scopes workers --remote
#
# Setting them both is what opens web signup; with either missing it stays closed. See
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
# caps above apply regardless.
+43
View File
@@ -0,0 +1,43 @@
name: Test
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Regression tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Install just
uses: extractions/setup-just@v3
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: just install
- name: Test
run: just test
+51 -2
View File
@@ -70,6 +70,11 @@ inconsistency here without checking the client first.
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
image names — a bare string array fails the client's parser ("expected '{'"). The list
is packed: removing an image shifts the rest up, never leaving a blank slot.
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
client's parser wants one — but the client renders only the FIRST entry and only ever
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
sits unreachable behind the old one and setting a load screen looks like it did
nothing. Keep the array shape for eventual multi-screen support.
- Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope.
@@ -94,14 +99,58 @@ inconsistency here without checking the client first.
publish: no publish step exists in the client for them. Saves live in the
`subroom_save` table with globally-unique ids (a bare id has to resolve —
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
serves. Three shapes of one save; keep them straight.
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
co-owners as such (a co-owner passes only by standing there). They list unpublished
staged saves, so they aren't public; but a visitor resolves which version an instance is
running from this list, so creator-only locks them out of loading the room. The grant
expires with the presence row.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
latter lives on the SUBROOM). The room's public description is `PUT /rooms/:id/description`'s
alone; copying the save comment onto `room.Description` (as this once did) silently
replaces the room's description every time someone saves.
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
the owner whether to load the latest or the published version and resolves it from the
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
this server-side: it would put two people in one instance on different versions.
- A balance lives in a `(CurrencyType, Platform)` BUCKET and the client shows the SUM of the
buckets, so `Platform` is a balance's identity, not a label. This server uses exactly one
bucket per currency — `ALL_PLATFORMS`, -2 `NonPurchasedNotUsableInP2P` — and every surface
must name it: the balance DTO (`econ`: `GET /api/storefronts/v4/balance/:type`), the
`BalanceType` the storefront bodies echo, and the `Platform` on every `StorefrontBalance*`
socket frame. Two traps, which produced two "balance doubling" bugs that both looked like
the frames being additive when they are not:
- Each frame SETS the bucket it names to an absolute value — `Balance` is the RESULTING
TOTAL, never the change (`StorefrontBalancePurchase`'s `Delta`/`BalanceAddType` are
display-only; the client logs them and stores `Balance` outright). Send a change and the
balance becomes that change. Being absolute, a frame is idempotent: re-sending one, or
racing a `GET /balance`, cannot drift the total, so the player reading the HTTP response
for the same change gets a frame too.
- The bucket key on the wire is `Platform`. The client's property is named `BalanceType`
but carries a `[DataMember]` rename, and its decoder drops unknown members silently, so
a frame saying `BalanceType` lands in `Platform` 0 (`SteamPurchased`) and adds a phantom
balance to the real one — 10,000 tokens + a 250 reward read 20,250. Sending a real-but-
different platform does the same: `Platform: RecNet` on a buy showed 34,100 to a player
who spent 900 of 17,500, then 33,200 once the body's -900 reached the true bucket.
The payload shapes are recovered from the client's own decoder in
`apps/notify/src/notification-payloads.ts` — build frames against those interfaces (econ
does) so a renamed key fails the build instead of silently vanishing on the wire.
- Every matchmake response (`match`: `/matchmake/*`, refusals and the ban middleware's
included) must echo the request's `CorrelationId` back as `correlationId` — the client
tags each attempt with a GUID and fails with "Unable to connect to game session" if it
can't match the response to the attempt. A request that names none gets the all-zero
`Guid.Empty` rather than null: the client's field is a non-nullable Guid. The code is
served under TWO names, `result` (what the client reads) and `errorCode` (what this
server has always sent); they are the same number and must never disagree, which is why
everything answers through `matchmakeResult` rather than building the envelope by hand.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+93 -8
View File
@@ -43,10 +43,12 @@ services but would require small code changes.
## Prerequisites
- node (modern)
- pnpm
- bun
- jq/awk/sed
**You must have all these requirements or RecFlare deployment will fail!**
- node 24 (https://nodejs.org)
- pnpm (install with `npm install -g pnpm`)
- bun (https://bun.sh)
- jq/awk/sed (on Windows try `winget jq` etc.)
- A Cloudflare account with a zone (domain) you control, for deploying.
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
@@ -65,6 +67,8 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
Create a new .env file from the template:
@@ -75,13 +79,20 @@ cp .env.example .env
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
(Optional) - per-app subdomain overrides come from
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
if you wanted to merge two services together.
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
puts that worker on and which host the `ns` discovery document advertises to the client,
so the two can't drift apart.
This is also how you merge two services together: `'{"moderation":"api"}'` points the
client's Moderation calls at the `api` worker (which is where the `/api/PlayerReporting/…`
routes already live) without deploying anything on `moderation.<domain>`. Redeploy `ns`
after changing it — `just deploy -F ns`.
**Create the storage resources:**
The workers bind Cloudflare storage primitive. Create them once against your
The workers bind Cloudflare storage primitives. Create them once against your
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
nothing in version control needs editing. Authenticate wrangler first
@@ -104,6 +115,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
```
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
and only to authenticate itself to Meta when validating a headset login's nonce —
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
it. Create it too:
```bash
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
> 500 ("Meta platform verification is not configured") and nothing else is affected.
> Steam and password sign-ins are unaffected either way. Put the real value in later
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
@@ -169,6 +198,7 @@ edit the value, then re-deploy the worker that reads them.
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
Then deploy just the worker that reads it:
@@ -199,6 +229,61 @@ single address, so raise it (or set it to `0`) if real players report being lock
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
### Signing up on the website (Turnstile)
Players get an account by launching the game, which needs no setup. The website can create
one too — that path has no platform identity behind it, so it runs behind a
[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until
you configure one**. Two steps, both one-time:
1. Create the widget: Cloudflare dashboard → **Turnstile** → **Add widget**, mode
**Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against
real keys). It gives you a **site key** and a **secret key**.
2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch
that opens signup, and store values survive deploys:
```bash
wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
--scopes workers --remote
wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
--scopes workers --remote
```
Then `just deploy -F www`. The site key is public — the browser needs it to render the
widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is
configured in one place. The secret key never leaves the worker: `/api/signup` verifies the
token against Turnstile server-side before it calls `auth`.
Signup opens only when **both** resolve. With either missing, `/api/config` reports signup
closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs
you the signup form, never an unprotected one. That is also how you turn signup back off:
`wrangler secrets-store secret delete <store-id> --name TURNSTILE_SECRET_KEY --remote`,
then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one
until fresh isolates start). For local dev, seed the same two names into the local store
from `apps/www` — Turnstile's documented always-passes test keypair
(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a
widget:
```bash
cd apps/www
printf '1x00000000000000000000AA' |
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
printf '1x0000000000000000000000000000000AA' |
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
```
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
the only cap that can see a web signup.
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
`auth` would then record one shared `signupIp` for every web account and cap the whole
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
(the binding refuses to resolve otherwise), and web accounts created before this change
carry that shared address as their permanent `signupIp` — harmless, but they are not
counted against any real network.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+2
View File
@@ -2,6 +2,8 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
+10 -4
View File
@@ -6,6 +6,12 @@ Each is reached at `https://<subdomain>.<your-domain>`. Services with a worker i
`apps/` are implemented here; the rest are advertised in the endpoints document
but not yet backed by a Worker. Not all services are fully implemented.
The subdomains below are the defaults. Any of them can be redirected from `.env` via
`RECFLARE_SUBDOMAINS`, keyed by the subdomain in this table — which both moves where the
worker deploys and what `ns` advertises. Pointing a service with no worker at one that has
one merges them, e.g. `'{"moderation":"api"}'` sends the client's Moderation calls to the
`api` worker, where the `/api/PlayerReporting/…` routes already live. See `DEPLOYING.md`.
A small `ns` worker itself serves this discovery document at the
apex/`ns` host and isn't listed within it. Each implemented worker has its own
`README.md` under `apps/<name>/` documenting its routes.
@@ -13,7 +19,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ---------------- | ------------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | — | Not yet implemented |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth token issuance (`/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
@@ -25,16 +31,16 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | — | Not yet implemented |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | — | Not yet implemented |
| Link | `link` | — | Not yet implemented |
| Lists | `lists` | — | Not yet implemented |
| Lists | `lists` | `lists` | Stub — deploys and answers, no list endpoints yet |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | — | Not yet implemented |
| Moderation | `moderation` | — | No worker; point it at `api` to serve `/api/PlayerReporting/…` |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
+77 -29
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -11,9 +11,18 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import {
logger,
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
AccountDto,
BioRequest,
@@ -72,6 +81,11 @@ const DEFAULT_USERNAME_CHANGES = 1
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
* and `value` is an empty string.
*
* The envelope-at-200 is the reference's (`RecNet`) convention — a refusal is a
* successful call that answers "no", and the player-facing sentence rides in `error`.
* `POST /account/create` does the same. This was briefly a 400 so a caller could branch
* on the status; it isn't, because that's not what the real service does.
*/
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
return c.json({ success: error === '', error, value })
@@ -94,6 +108,10 @@ function toAccountDto(account: Account) {
username: account.username,
displayName: account.displayName,
profileImage: account.profileImage,
// Nothing writes these yet, and rows stored before they existed have neither
// key — always emit them as "" rather than letting them go missing.
bannerImage: account.bannerImage ?? '',
displayEmoji: account.displayEmoji ?? '',
isJunior: account.isJunior,
platforms: account.platforms,
personalPronouns: account.personalPronouns,
@@ -105,14 +123,20 @@ function toAccountDto(account: Account) {
/**
* Project a stored account into the private self DTO (the /account/me shape) —
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
* OMITTED when null (emitting `null` makes the client's enum parser throw);
* `email`/`birthday` are kept as null (not enums, so null is fine).
* OMITTED when null (emitting `null` makes the client's enum parser throw).
*
* An unset `email` is `""`, never null — same as `bio`. Two reasons: the client reads
* it as a string, and this DTO also rides the `SelfAccountUpdate` hub frame, where the
* hub DROPS null values from `Msg` — so a null email doesn't arrive as null, it
* vanishes from the frame entirely.
*/
function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
email: account.email ?? '',
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
// everyone can be old.
birthday: '1904-01-01T00:00:00.000Z',
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
@@ -131,9 +155,13 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const publicDto = toAccountDto(account)
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
await hub.broadcast('AccountUpdate', publicDto)
await hub.notifyPlayer(
account.accountId,
NotificationType.SubscriptionUpdateSelfProfile,
toSelfAccountDto(account)
)
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
} catch (err) {
logger.error('failed to push account update notifications', {
accountId: account.accountId,
@@ -159,6 +187,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -409,18 +445,20 @@ const app = new Hono<App>()
summary: 'Set display name',
description: 'Persisted and broadcast via an AccountUpdate notification.',
security: AUTHED,
requestBody: form(DisplayNameRequest, 'The new display name'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty display name (empty body)' },
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// An EMPTY 400, which is what this route already answered for an empty name: it
// acks with a bare SuccessResponse and has never sent the client a body on
// failure, so enforcing the schema doesn't change what a refusal looks like.
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400)
const { displayName } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
@@ -436,23 +474,33 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Rejects a name taken by another account and requires a remaining change; on',
'success the name is persisted and the counter decremented. Always HTTP 200 —',
'failures carry a message in `error` (see the UsernameResult envelope).',
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
].join(' '),
security: AUTHED,
requestBody: form(UsernameRequest, 'The desired username'),
responses: {
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
// — the part that matters — can never spend one of the account's rationed changes.
// The message is relayed rather than zod's issue array: `nameRejection` writes the
// sentence the player reads, and nothing can render an array of issues.
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
// Standard Schema's flat issue list rather than a zod error object.
validator('form', UsernameRequest, (r, c: Context<App>) =>
r.success
? undefined
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const username = (await formField(c, 'username')).trim()
if (username === '') return usernameResult(c, 'You must enter a username.')
const { username } = c.req.valid('form')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
@@ -484,18 +532,17 @@ const app = new Hono<App>()
summary: 'Set email',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
security: AUTHED,
requestBody: form(EmailRequest, 'The new email'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Email without an “@” (empty body)' },
400: { description: 'Not a syntactically valid address (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const email = (await formField(c, 'email')).trim()
if (!email.includes('@')) return c.body(null, 400)
const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
@@ -509,18 +556,17 @@ const app = new Hono<App>()
summary: 'Set phone number',
description: 'Persisted on the account row. Not broadcast.',
security: AUTHED,
requestBody: form(PhoneRequest, 'The new phone number'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty phone (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const phone = (await formField(c, 'phone')).trim()
if (phone === '') return c.body(null, 400)
const { phone } = c.req.valid('form')
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
}
@@ -595,18 +641,20 @@ const app = new Hono<App>()
describeRoute({
tags: ['Profile'],
summary: 'Set bio',
description: 'Free text; empty is allowed. Persisted and broadcast.',
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
security: AUTHED,
requestBody: form(BioRequest, 'The new bio'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Bio over 255 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// Refused rather than truncated: silently storing half a sentence reads as data loss.
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio')
const { bio } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
+75 -18
View File
@@ -1,20 +1,35 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
MAX_USERNAME_LENGTH,
nameRejection,
} from '@repo/domain'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the accounts worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
* missing or malformed input falls through to a graceful path (or a synthesized default
* account) rather than a hard error. A schema that rejected what the client actually
* sends would break the game, not protect it.
*
* As with the auth worker, this is deliberate. The Rec Room client is the only real
* consumer and the handlers are intentionally lenient — form fields are read as
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
* to a graceful path (or a synthesized default account) rather than a hard error.
* These schemas record what the client is observed to send and what we send back; to
* enforce one, do it per-route and land a test with it.
* The EXCEPTION is the profile mutations a player types into a box — displayName,
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
* version of this note prescribed. Wiring one up means the schema both validates the
* request and generates the spec, so a limit can't be changed in one and not the other —
* which is precisely how the documented email limit came to disagree with the real one.
*
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
* validator registers the body itself, and declaring it twice would emit it twice.
*/
/** Emit a zod schema as an `application/json` response body. */
@@ -52,6 +67,8 @@ export const AccountDto = z.object({
username: z.string(),
displayName: z.string(),
profileImage: z.string().describe('Avatar object key'),
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
@@ -62,12 +79,16 @@ export const AccountDto = z.object({
/**
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
* unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are
* kept as nullable since they aren't enums.
* unset (emitting `null` makes the client's enum parser throw).
*/
export const SelfAccountDto = AccountDto.extend({
email: z.string().nullable(),
birthday: z.null().describe('Always null — birthday is not stored'),
email: z
.string()
.describe(
'"" when unset — never null: the client reads it as a string, and the hub frame this ' +
'DTO also rides drops null values outright'
),
birthday: z.iso.datetime().describe('A fixed placeholder — birthdays are not stored'),
availableUsernameChanges: z.int().describe('Remaining username changes'),
})
@@ -122,21 +143,54 @@ export const CreateAccountRequest = z.object({
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({
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
})
export const UsernameRequest = z.object({
username: z.string().describe('Trimmed; must be unique and changes must remain'),
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
email: z.string().describe('Must contain "@"; otherwise 400'),
email: z
.string()
.trim()
.refine(isValidEmail, 'That email address looks wrong.')
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
})
export const PhoneRequest = z.object({
phone: z.string().describe('Trimmed; empty is rejected (400)'),
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
// rule above would reject outright by eating the leading `+`.
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
})
export const IdentityFlagsRequest = z.object({
@@ -147,7 +201,10 @@ export const PronounsRequest = z.object({
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
export const BioRequest = z.object({
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
@@ -161,6 +161,13 @@ describe('auth-gated endpoints', () => {
personalPronouns: 0,
identityFlags: 0,
availableUsernameChanges: 1,
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
// Nothing sets these yet, but the key has to be present — the client reads
// both off the account DTO.
bannerImage: '',
displayEmoji: '',
})
// juniorState + parentAccountId must be omitted when null, not emitted as
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
@@ -459,4 +466,179 @@ describe('auth-gated endpoints', () => {
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')
})
+82
View File
@@ -0,0 +1,82 @@
# ai
AI worker served on the `ai` subdomain (`ai.recflare.net`). The client checks here before
offering any of its AI features.
- `GET /` — service status `{ "service": "ai", "status": "ok" }`. No auth.
- `GET /gameai/user/access?roomId=<id>` — `[Authorize]`. Whether the caller may use Game AI
in a room. Always refused:
```json
{
"success": false,
"error_id": "AI.RoomDoesNotSupportGameAI",
"error": "This room does not support Rec Room Game AI"
}
```
No model runs behind this worker, so every room gets that answer. Two things about it
are deliberate: **it is a 200, not a 4xx** (the client branches on `success` in the body;
an error status would surface as a failed request rather than the "not available here"
state this is), and **`roomId` is ignored** while the token is still validated first, as
the reference server does — so an unauthenticated caller gets a 401 rather than the
refusal.
- `GET /roomieai/user/access` — `[Authorize]`. Roomie AI's energy budget, granted in full:
```json
{
"success": true,
"error_id": null,
"error": null,
"value": {
"MaxEnergyFromSubscriptions": 2147483647,
"EnergyLeft": 2147483647,
"NextSubscriptionEnergyRechargeAt": null,
"OutputAudioEnabled": true
}
}
```
Granted rather than refused because Roomie runs on the CLIENT and only asks this service
how much energy it may spend — so for a server that meters nothing, "as much as you can
count" is the honest answer. That number is `int.MaxValue`: the client's field is a
signed 32-bit int, and anything larger overflows on the way in and reads as negative,
i.e. no energy at all. Nothing depletes, so nothing recharges — hence the null
`NextSubscriptionEnergyRechargeAt`.
Note the envelope: `{ success, error_id, error, value }`, not the flat body the Game AI
check answers with. The two shapes are different on purpose; don't unify them.
The worker exists so the client gets a definite answer on the host its endpoints document
already names (`AI` → `ai`, see `apps/ns`), instead of a failed request to a host with
nothing behind it.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit alongside
each handler, with the schemas in `src/openapi.ts`. It's also aggregated into the docs page
www serves at `/docs`. A test asserts every route appears in it.
**The spec is descriptive, not enforced** — same rationale as the other workers: a
reverse-engineered protocol, lenient handlers, no runtime validation.
## Development
### Run in dev mode
```sh
pnpm dev
```
### Run tests
```sh
pnpm test
```
### Deploy
```sh
pnpm turbo deploy
```
+12
View File
@@ -0,0 +1,12 @@
// oxlint-disable @typescript-eslint/consistent-type-imports
type LocalEnv = import('./src/context').Env
type MainModule = typeof import('./src/ai.app')
// Add Env to Cloudflare namespace so that we can access it via
// import { env } from 'cloudflare:workers'
declare namespace Cloudflare {
interface Env extends LocalEnv {}
interface GlobalProps {
mainModule: MainModule
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ai",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build:wrangler": "run-wrangler-build",
"check:lint": "run-oxlint",
"check:types": "run-tsc",
"check:workers-types": "run-wrangler-types --check",
"deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"test": "run-vitest"
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
}
}
+191
View File
@@ -0,0 +1,191 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AUTHED,
GameAiAccessDenied,
HealthResponse,
intQuery,
json,
RoomieAiAccess,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* AI Worker. Serves the access checks the client makes before offering its AI features.
* Nothing here runs a model, and the two features are answered differently for that
* reason: Game AI is a server-side feature this server cannot provide, so it is refused;
* Roomie is the client's own assistant and only asks this service what its energy budget
* is, so it is granted an unlimited one.
*/
/**
* `int.MaxValue` — the client's energy fields are signed 32-bit ints, so this is the
* largest budget it can hold. Anything larger (an int64 max, say) overflows on the way in
* and lands as a negative number, i.e. no energy at all.
*/
const INT32_MAX = 2_147_483_647
/**
* Resolve the account id from a Bearer token (the route is auth-gated).
* Returns `null` when the header is missing, the token is invalid, or the `sub` claim
* isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the ai worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'ai', status: 'ok' })
)
// Whether the caller may use Game AI in a room. Always refused: no Game AI backend
// exists here, so the honest answer for every room is that it doesn't support it.
.get(
'/gameai/user/access',
describeRoute({
tags: ['Game AI'],
summary: 'May the caller use Game AI here?',
description: [
'Asked before the client offers any Game AI feature in a room. This server hosts no',
'Game AI, so it always refuses — with a 200 carrying `success: false`, NOT an HTTP',
'error: the client branches on the body, and an error status would read as a failed',
'request rather than the “not available here” state this is. `AI.RoomDoesNotSupportGameAI`',
'is the reason the client renders.',
'',
'`roomId` is accepted and ignored — the answer is the same for every room, and the',
'refusal is per-room by nature, so the client asks again for the next one. The token',
'is still validated first, as the reference does.',
].join(' '),
security: AUTHED,
parameters: [intQuery('roomId', 'The room the client is asking about. Ignored.')],
responses: {
200: json(GameAiAccessDenied, 'Always a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: false,
error_id: 'AI.RoomDoesNotSupportGameAI',
error: 'This room does not support Rec Room Game AI',
})
}
)
// Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client
// and only asks this service how much energy it has, so the honest answer for a server
// that meters nothing is "as much as you can count".
.get(
'/roomieai/user/access',
describeRoute({
tags: ['Roomie AI'],
summary: 'The caller’s Roomie AI energy budget',
description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never',
'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no',
'spend to recharge from.',
'',
'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI',
'check answers with. The two are different shapes on purpose — don’t unify them.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomieAiAccess, 'The energy budget — always granted, always full'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
error_id: null,
error: null,
value: {
MaxEnergyFromSubscriptions: INT32_MAX,
EnergyLeft: INT32_MAX,
NextSubscriptionEnergyRechargeAt: null,
OutputAudioEnabled: true,
},
})
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare ai',
version: '1.0.0',
description: [
'The Game AI service for recflare, a private-server reimplementation of the Rec Room',
'backend. The client checks here before offering its AI features in a room.',
'',
'No model runs behind this worker, so the access check refuses every room — as a 200',
'carrying `success: false`, which is the shape the client branches on. That is the',
'whole surface today; the worker exists so the client gets a definite answer on the',
'host its endpoints document names, instead of a failed request.',
].join('\n'),
},
servers: [{ url: 'https://ai.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+19
View File
@@ -0,0 +1,19 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value with
* `await env.JWT_SECRET.get()`; every worker binds the same store, so tokens signed by
* `auth` verify here.
*/
JWT_SECRET: SecretsStoreSecret
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
+75
View File
@@ -0,0 +1,75 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the ai 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) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An optional integer query parameter. */
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('ai'),
status: z.literal('ok'),
})
/**
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
* the maximum a signed 32-bit int holds (see INT32_MAX).
*/
export const RoomieAiAccess = z.object({
success: z.literal(true),
error_id: z.null(),
error: z.null(),
value: z.object({
MaxEnergyFromSubscriptions: z
.int()
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
NextSubscriptionEnergyRechargeAt: z
.string()
.nullable()
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
}),
})
/**
* The refusal every Game AI read answers with. It is a 200 carrying `success: false`, not
* an HTTP error — the client branches on the body, and an error status would surface as a
* failed request rather than the "not available here" state it is meant to show.
*/
export const GameAiAccessDenied = z.object({
success: z.literal(false),
error_id: z.string().describe('Machine-readable reason, e.g. `AI.RoomDoesNotSupportGameAI`'),
error: z.string().describe('The message shown to the player'),
})
+163
View File
@@ -0,0 +1,163 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import '../../ai.app'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
const REFUSAL = {
success: false,
error_id: 'AI.RoomDoesNotSupportGameAI',
error: 'This room does not support Rec Room Game AI',
}
describe('ai endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'ai', status: 'ok' })
})
})
describe('GET /gameai/user/access', () => {
// A refusal, not an HTTP error: the client branches on the body, so a 4xx here would
// read as a failed request rather than "Game AI isn't available in this room".
it('refuses with a 200 body', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access?roomId=1234`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(REFUSAL)
})
// `roomId` is optional in the reference signature and ignored here, so both forms and
// any room answer identically.
it.each(['', '?roomId=1', '?roomId=18446744073709551615'])(
'answers the same for %s',
async (query) => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access${query}`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(REFUSAL)
}
)
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access?roomId=1234`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
it('401s with a garbage token', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
expect(res.status).toBe(401)
})
})
describe('GET /roomieai/user/access', () => {
// Granted, unlike the Game AI check: Roomie runs on the client and only asks for its
// energy budget, which nothing here meters.
it('grants an int32-max energy budget', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
error_id: null,
error: null,
value: {
// int.MaxValue — the client's field is a signed 32-bit int, so a larger number
// overflows on the way in and reads as negative, i.e. no energy at all.
MaxEnergyFromSubscriptions: 2147483647,
EnergyLeft: 2147483647,
NextSubscriptionEnergyRechargeAt: null,
OutputAudioEnabled: true,
},
})
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
it('401s with a garbage token', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
expect(res.status).toBe(401)
})
})
describe('GET /openapi.json', () => {
it('documents every route, with no dangling $refs', 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; everything else is described. Adding a route without
// a describeRoute() block fails here rather than 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 /gameai/user/access',
'GET /roomieai/user/access',
])
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@repo/typescript-config/workers.json"
}
+15
View File
@@ -0,0 +1,15 @@
import { cloudflareTest } from '@cloudflare/vitest-pool-workers'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
},
},
}),
],
})
+14640
View File
@@ -0,0 +1,14640 @@
/* eslint-disable */
// Runtime types generated with workerd@1.20260625.1 2026-06-16 nodejs_compat
// Begin runtime types
/*! *****************************************************************************
Copyright (c) Cloudflare. All rights reserved.
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/* eslint-disable */
// noinspection JSUnusedGlobalSymbols
declare var onmessage: never;
/**
* The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
*/
declare class DOMException extends Error {
constructor(message?: string, name?: string);
/**
* The **`message`** read-only property of the a message or description associated with the given error name.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)
*/
readonly message: string;
/**
* The **`name`** read-only property of the one of the strings associated with an error name.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)
*/
readonly name: string;
/**
* The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match.
* @deprecated
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
*/
readonly code: number;
static readonly INDEX_SIZE_ERR: number;
static readonly DOMSTRING_SIZE_ERR: number;
static readonly HIERARCHY_REQUEST_ERR: number;
static readonly WRONG_DOCUMENT_ERR: number;
static readonly INVALID_CHARACTER_ERR: number;
static readonly NO_DATA_ALLOWED_ERR: number;
static readonly NO_MODIFICATION_ALLOWED_ERR: number;
static readonly NOT_FOUND_ERR: number;
static readonly NOT_SUPPORTED_ERR: number;
static readonly INUSE_ATTRIBUTE_ERR: number;
static readonly INVALID_STATE_ERR: number;
static readonly SYNTAX_ERR: number;
static readonly INVALID_MODIFICATION_ERR: number;
static readonly NAMESPACE_ERR: number;
static readonly INVALID_ACCESS_ERR: number;
static readonly VALIDATION_ERR: number;
static readonly TYPE_MISMATCH_ERR: number;
static readonly SECURITY_ERR: number;
static readonly NETWORK_ERR: number;
static readonly ABORT_ERR: number;
static readonly URL_MISMATCH_ERR: number;
static readonly QUOTA_EXCEEDED_ERR: number;
static readonly TIMEOUT_ERR: number;
static readonly INVALID_NODE_TYPE_ERR: number;
static readonly DATA_CLONE_ERR: number;
get stack(): any;
set stack(value: any);
}
type WorkerGlobalScopeEventMap = {
fetch: FetchEvent;
scheduled: ScheduledEvent;
queue: QueueEvent;
unhandledrejection: PromiseRejectionEvent;
rejectionhandled: PromiseRejectionEvent;
};
declare abstract class WorkerGlobalScope extends EventTarget<WorkerGlobalScopeEventMap> {
EventTarget: typeof EventTarget;
}
/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). *
* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console)
*/
interface Console {
"assert"(condition?: boolean, ...data: any[]): void;
/**
* The **`console.clear()`** static method clears the console if possible.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static)
*/
clear(): void;
/**
* The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)
*/
count(label?: string): void;
/**
* The **`console.countReset()`** static method resets counter used with console/count_static.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)
*/
countReset(label?: string): void;
/**
* The **`console.debug()`** static method outputs a message to the console at the 'debug' log level.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)
*/
debug(...data: any[]): void;
/**
* The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)
*/
dir(item?: any, options?: any): void;
/**
* The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)
*/
dirxml(...data: any[]): void;
/**
* The **`console.error()`** static method outputs a message to the console at the 'error' log level.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
*/
error(...data: any[]): void;
/**
* The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)
*/
group(...data: any[]): void;
/**
* The **`console.groupCollapsed()`** static method creates a new inline group in the console.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)
*/
groupCollapsed(...data: any[]): void;
/**
* The **`console.groupEnd()`** static method exits the current inline group in the console.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)
*/
groupEnd(): void;
/**
* The **`console.info()`** static method outputs a message to the console at the 'info' log level.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)
*/
info(...data: any[]): void;
/**
* The **`console.log()`** static method outputs a message to the console.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
*/
log(...data: any[]): void;
/**
* The **`console.table()`** static method displays tabular data as a table.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static)
*/
table(tabularData?: any, properties?: string[]): void;
/**
* The **`console.time()`** static method starts a timer you can use to track how long an operation takes.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)
*/
time(label?: string): void;
/**
* The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)
*/
timeEnd(label?: string): void;
/**
* The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)
*/
timeLog(label?: string, ...data: any[]): void;
timeStamp(label?: string): void;
/**
* The **`console.trace()`** static method outputs a stack trace to the console.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static)
*/
trace(...data: any[]): void;
/**
* The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)
*/
warn(...data: any[]): void;
}
declare const console: Console;
type BufferSource = ArrayBufferView | ArrayBuffer;
type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
declare namespace WebAssembly {
class CompileError extends Error {
constructor(message?: string);
}
class RuntimeError extends Error {
constructor(message?: string);
}
type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
interface GlobalDescriptor {
value: ValueType;
mutable?: boolean;
}
class Global {
constructor(descriptor: GlobalDescriptor, value?: any);
value: any;
valueOf(): any;
}
type ImportValue = ExportValue | number;
type ModuleImports = Record<string, ImportValue>;
type Imports = Record<string, ModuleImports>;
type ExportValue = Function | Global | Memory | Table;
type Exports = Record<string, ExportValue>;
class Instance {
constructor(module: Module, imports?: Imports);
readonly exports: Exports;
}
interface MemoryDescriptor {
initial: number;
maximum?: number;
shared?: boolean;
}
class Memory {
constructor(descriptor: MemoryDescriptor);
readonly buffer: ArrayBuffer;
grow(delta: number): number;
}
type ImportExportKind = "function" | "global" | "memory" | "table";
interface ModuleExportDescriptor {
kind: ImportExportKind;
name: string;
}
interface ModuleImportDescriptor {
kind: ImportExportKind;
module: string;
name: string;
}
abstract class Module {
static customSections(module: Module, sectionName: string): ArrayBuffer[];
static exports(module: Module): ModuleExportDescriptor[];
static imports(module: Module): ModuleImportDescriptor[];
}
type TableKind = "anyfunc" | "externref";
interface TableDescriptor {
element: TableKind;
initial: number;
maximum?: number;
}
class Table {
constructor(descriptor: TableDescriptor, value?: any);
readonly length: number;
get(index: number): any;
grow(delta: number, value?: any): number;
set(index: number, value?: any): void;
}
function instantiate(module: Module, imports?: Imports): Promise<Instance>;
function validate(bytes: BufferSource): boolean;
}
/**
* The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker.
* Available only in secure contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
*/
interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
DOMException: typeof DOMException;
WorkerGlobalScope: typeof WorkerGlobalScope;
btoa(data: string): string;
atob(data: string): string;
setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
setTimeout<Args extends any[]>(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
clearTimeout(timeoutId: number | null): void;
setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
setInterval<Args extends any[]>(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
clearInterval(timeoutId: number | null): void;
queueMicrotask(task: Function): void;
structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
reportError(error: any): void;
fetch(input: RequestInfo | URL, init?: RequestInit<RequestInitCfProperties>): Promise<Response>;
self: ServiceWorkerGlobalScope;
crypto: Crypto;
caches: CacheStorage;
scheduler: Scheduler;
performance: Performance;
Cloudflare: Cloudflare;
readonly origin: string;
Event: typeof Event;
ExtendableEvent: typeof ExtendableEvent;
CustomEvent: typeof CustomEvent;
PromiseRejectionEvent: typeof PromiseRejectionEvent;
FetchEvent: typeof FetchEvent;
TailEvent: typeof TailEvent;
TraceEvent: typeof TailEvent;
ScheduledEvent: typeof ScheduledEvent;
MessageEvent: typeof MessageEvent;
CloseEvent: typeof CloseEvent;
ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
ReadableStream: typeof ReadableStream;
WritableStream: typeof WritableStream;
WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
TransformStream: typeof TransformStream;
ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
CountQueuingStrategy: typeof CountQueuingStrategy;
ErrorEvent: typeof ErrorEvent;
MessageChannel: typeof MessageChannel;
MessagePort: typeof MessagePort;
EventSource: typeof EventSource;
ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
ReadableByteStreamController: typeof ReadableByteStreamController;
WritableStreamDefaultController: typeof WritableStreamDefaultController;
TransformStreamDefaultController: typeof TransformStreamDefaultController;
CompressionStream: typeof CompressionStream;
DecompressionStream: typeof DecompressionStream;
TextEncoderStream: typeof TextEncoderStream;
TextDecoderStream: typeof TextDecoderStream;
Headers: typeof Headers;
Body: typeof Body;
Request: typeof Request;
Response: typeof Response;
WebSocket: typeof WebSocket;
WebSocketPair: typeof WebSocketPair;
WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
AbortController: typeof AbortController;
AbortSignal: typeof AbortSignal;
TextDecoder: typeof TextDecoder;
TextEncoder: typeof TextEncoder;
navigator: Navigator;
Navigator: typeof Navigator;
URL: typeof URL;
URLSearchParams: typeof URLSearchParams;
URLPattern: typeof URLPattern;
Blob: typeof Blob;
File: typeof File;
FormData: typeof FormData;
Crypto: typeof Crypto;
SubtleCrypto: typeof SubtleCrypto;
CryptoKey: typeof CryptoKey;
CacheStorage: typeof CacheStorage;
Cache: typeof Cache;
FixedLengthStream: typeof FixedLengthStream;
IdentityTransformStream: typeof IdentityTransformStream;
HTMLRewriter: typeof HTMLRewriter;
}
declare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
declare function removeEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/
declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
declare function btoa(data: string): string;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
declare function atob(data: string): string;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
declare function setTimeout<Args extends any[]>(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
declare function clearTimeout(timeoutId: number | null): void;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
declare function setInterval<Args extends any[]>(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
declare function clearInterval(timeoutId: number | null): void;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
declare function queueMicrotask(task: Function): void;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
declare function structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
declare function reportError(error: any): void;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
declare function fetch(input: RequestInfo | URL, init?: RequestInit<RequestInitCfProperties>): Promise<Response>;
declare const self: ServiceWorkerGlobalScope;
/**
* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
* The Workers runtime implements the full surface of this API, but with some differences in
* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
* compared to those implemented in most browsers.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
*/
declare const crypto: Crypto;
/**
* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
*/
declare const caches: CacheStorage;
declare const scheduler: Scheduler;
/**
* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
* as well as timing of subrequests and other operations.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
*/
declare const performance: Performance;
declare const Cloudflare: Cloudflare;
declare const origin: string;
declare const navigator: Navigator;
interface TestController {
}
interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void;
passThroughOnException(): void;
readonly exports: Cloudflare.Exports;
readonly props: Props;
cache?: CacheContext;
readonly access?: CloudflareAccessContext;
tracing: Tracing;
}
type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>;
type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
type ExportedHandlerTailHandler<Env = unknown, Props = unknown> = (events: TraceItem[], env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
type ExportedHandlerTraceHandler<Env = unknown, Props = unknown> = (traces: TraceItem[], env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
type ExportedHandlerTailStreamHandler<Env = unknown, Props = unknown> = (event: TailStream.TailEvent<TailStream.Onset>, env: Env, ctx: ExecutionContext<Props>) => TailStream.TailEventHandlerType | Promise<TailStream.TailEventHandlerType>;
type ExportedHandlerScheduledHandler<Env = unknown, Props = unknown> = (controller: ScheduledController, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
type ExportedHandlerQueueHandler<Env = unknown, Message = unknown, Props = unknown> = (batch: MessageBatch<Message>, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
type ExportedHandlerTestHandler<Env = unknown, Props = unknown> = (controller: TestController, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
interface ExportedHandler<Env = unknown, QueueHandlerMessage = unknown, CfHostMetadata = unknown, Props = unknown> {
fetch?: ExportedHandlerFetchHandler<Env, CfHostMetadata, Props>;
connect?: ExportedHandlerConnectHandler<Env, Props>;
tail?: ExportedHandlerTailHandler<Env, Props>;
trace?: ExportedHandlerTraceHandler<Env, Props>;
tailStream?: ExportedHandlerTailStreamHandler<Env, Props>;
scheduled?: ExportedHandlerScheduledHandler<Env, Props>;
test?: ExportedHandlerTestHandler<Env, Props>;
email?: EmailExportedHandler<Env, Props>;
queue?: ExportedHandlerQueueHandler<Env, QueueHandlerMessage, Props>;
}
interface StructuredSerializeOptions {
transfer?: any[];
}
declare abstract class Navigator {
sendBeacon(url: string, body?: BodyInit): boolean;
readonly userAgent: string;
readonly hardwareConcurrency: number;
readonly platform: string;
readonly language: string;
readonly languages: string[];
}
interface AlarmInvocationInfo {
readonly isRetry: boolean;
readonly retryCount: number;
readonly scheduledTime: number;
}
interface Cloudflare {
readonly compatibilityFlags: Record<string, boolean>;
}
interface CachePurgeError {
code: number;
message: string;
}
interface CachePurgeResult {
success: boolean;
errors: CachePurgeError[];
}
interface CachePurgeOptions {
tags?: string[];
pathPrefixes?: string[];
purgeEverything?: boolean;
}
interface CacheContext {
purge(options: CachePurgeOptions): Promise<CachePurgeResult>;
}
interface CloudflareAccessContext {
readonly aud: string;
getIdentity(): Promise<CloudflareAccessIdentity | undefined>;
}
declare abstract class ColoLocalActorNamespace {
get(actorId: string): Fetcher;
}
interface DurableObject {
fetch(request: Request): Response | Promise<Response>;
connect?(socket: Socket): void | Promise<void>;
alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise<void>;
webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void>;
webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise<void>;
webSocketError?(ws: WebSocket, error: unknown): void | Promise<void>;
}
type DurableObjectStub<T extends Rpc.DurableObjectBranded | undefined = undefined> = Fetcher<T, "alarm" | "connect" | "webSocketMessage" | "webSocketClose" | "webSocketError"> & {
readonly id: DurableObjectId;
readonly name?: string;
};
interface DurableObjectId {
toString(): string;
equals(other: DurableObjectId): boolean;
readonly name?: string;
readonly jurisdiction?: string;
}
declare abstract class DurableObjectNamespace<T extends Rpc.DurableObjectBranded | undefined = undefined> {
newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
idFromName(name: string): DurableObjectId;
idFromString(id: string): DurableObjectId;
get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>;
getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>;
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>;
}
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
interface DurableObjectNamespaceNewUniqueIdOptions {
jurisdiction?: DurableObjectJurisdiction;
}
type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me";
type DurableObjectRoutingMode = "primary-only";
interface DurableObjectNamespaceGetDurableObjectOptions {
locationHint?: DurableObjectLocationHint;
routingMode?: DurableObjectRoutingMode;
}
interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
}
interface DurableObjectState<Props = unknown> {
waitUntil(promise: Promise<any>): void;
readonly exports: Cloudflare.Exports;
readonly props: Props;
readonly id: DurableObjectId;
readonly storage: DurableObjectStorage;
container?: Container;
facets: DurableObjectFacets;
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
getWebSockets(tag?: string): WebSocket[];
setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
getHibernatableWebSocketEventTimeout(): number | null;
getTags(ws: WebSocket): string[];
abort(reason?: string): void;
}
interface DurableObjectTransaction {
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
get<T = unknown>(keys: string[], options?: DurableObjectGetOptions): Promise<Map<string, T>>;
list<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>;
put<T>(key: string, value: T, options?: DurableObjectPutOptions): Promise<void>;
put<T>(entries: Record<string, T>, options?: DurableObjectPutOptions): Promise<void>;
delete(key: string, options?: DurableObjectPutOptions): Promise<boolean>;
delete(keys: string[], options?: DurableObjectPutOptions): Promise<number>;
rollback(): void;
getAlarm(options?: DurableObjectGetAlarmOptions): Promise<number | null>;
setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise<void>;
deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise<void>;
}
interface DurableObjectStorage {
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
get<T = unknown>(keys: string[], options?: DurableObjectGetOptions): Promise<Map<string, T>>;
list<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>;
put<T>(key: string, value: T, options?: DurableObjectPutOptions): Promise<void>;
put<T>(entries: Record<string, T>, options?: DurableObjectPutOptions): Promise<void>;
delete(key: string, options?: DurableObjectPutOptions): Promise<boolean>;
delete(keys: string[], options?: DurableObjectPutOptions): Promise<number>;
deleteAll(options?: DurableObjectPutOptions): Promise<void>;
transaction<T>(closure: (txn: DurableObjectTransaction) => Promise<T>): Promise<T>;
getAlarm(options?: DurableObjectGetAlarmOptions): Promise<number | null>;
setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise<void>;
deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise<void>;
sync(): Promise<void>;
sql: SqlStorage;
kv: SyncKvStorage;
transactionSync<T>(closure: () => T): T;
getCurrentBookmark(): Promise<string>;
getBookmarkForTime(timestamp: number | Date): Promise<string>;
onNextSessionRestoreBookmark(bookmark: string): Promise<string>;
}
interface DurableObjectListOptions {
start?: string;
startAfter?: string;
end?: string;
prefix?: string;
reverse?: boolean;
limit?: number;
allowConcurrency?: boolean;
noCache?: boolean;
}
interface DurableObjectGetOptions {
allowConcurrency?: boolean;
noCache?: boolean;
}
interface DurableObjectGetAlarmOptions {
allowConcurrency?: boolean;
}
interface DurableObjectPutOptions {
allowConcurrency?: boolean;
allowUnconfirmed?: boolean;
noCache?: boolean;
}
interface DurableObjectSetAlarmOptions {
allowConcurrency?: boolean;
allowUnconfirmed?: boolean;
}
declare class WebSocketRequestResponsePair {
constructor(request: string, response: string);
get request(): string;
get response(): string;
}
interface DurableObjectFacets {
get<T extends Rpc.DurableObjectBranded | undefined = undefined>(name: string, getStartupOptions: () => FacetStartupOptions<T> | Promise<FacetStartupOptions<T>>): Fetcher<T>;
abort(name: string, reason: any): void;
delete(name: string): void;
clone(src: string, dst: string): void;
}
interface FacetStartupOptions<T extends Rpc.DurableObjectBranded | undefined = undefined> {
id?: DurableObjectId | string;
class: DurableObjectClass<T>;
}
interface AnalyticsEngineDataset {
writeDataPoint(event?: AnalyticsEngineDataPoint): void;
}
interface AnalyticsEngineDataPoint {
indexes?: ((ArrayBuffer | string) | null)[];
doubles?: number[];
blobs?: ((ArrayBuffer | string) | null)[];
}
/**
* The **`Event`** interface represents an event which takes place on an `EventTarget`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event)
*/
declare class Event {
constructor(type: string, init?: EventInit);
/**
* The **`type`** read-only property of the Event interface returns a string containing the event's type.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type)
*/
get type(): string;
/**
* The **`eventPhase`** read-only property of the being evaluated.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase)
*/
get eventPhase(): number;
/**
* The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed)
*/
get composed(): boolean;
/**
* The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles)
*/
get bubbles(): boolean;
/**
* The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable)
*/
get cancelable(): boolean;
/**
* The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented)
*/
get defaultPrevented(): boolean;
/**
* The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not.
* @deprecated
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue)
*/
get returnValue(): boolean;
/**
* The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget)
*/
get currentTarget(): EventTarget | undefined;
/**
* The read-only **`target`** property of the dispatched.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)
*/
get target(): EventTarget | undefined;
/**
* The deprecated **`Event.srcElement`** is an alias for the Event.target property.
* @deprecated
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
*/
get srcElement(): EventTarget | undefined;
/**
* The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp)
*/
get timeStamp(): number;
/**
* The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted)
*/
get isTrusted(): boolean;
/**
* The **`cancelBubble`** property of the Event interface is deprecated.
* @deprecated
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/
get cancelBubble(): boolean;
/**
* The **`cancelBubble`** property of the Event interface is deprecated.
* @deprecated
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/
set cancelBubble(value: boolean);
/**
* The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation)
*/
stopImmediatePropagation(): void;
/**
* The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault)
*/
preventDefault(): void;
/**
* The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation)
*/
stopPropagation(): void;
/**
* The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath)
*/
composedPath(): EventTarget[];
static readonly NONE: number;
static readonly CAPTURING_PHASE: number;
static readonly AT_TARGET: number;
static readonly BUBBLING_PHASE: number;
}
interface EventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
}
type EventListener<EventType extends Event = Event> = (event: EventType) => void;
interface EventListenerObject<EventType extends Event = Event> {
handleEvent(event: EventType): void;
}
type EventListenerOrEventListenerObject<EventType extends Event = Event> = EventListener<EventType> | EventListenerObject<EventType>;
/**
* The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget)
*/
declare class EventTarget<EventMap extends Record<string, Event> = Record<string, Event>> {
constructor();
/**
* The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)
*/
addEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
/**
* The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)
*/
removeEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/
dispatchEvent(event: EventMap[keyof EventMap]): boolean;
}
interface EventTargetEventListenerOptions {
capture?: boolean;
}
interface EventTargetAddEventListenerOptions {
capture?: boolean;
passive?: boolean;
once?: boolean;
signal?: AbortSignal;
}
interface EventTargetHandlerObject {
handleEvent: (event: Event) => any | undefined;
}
/**
* The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
*/
declare class AbortController {
constructor();
/**
* The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)
*/
get signal(): AbortSignal;
/**
* The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)
*/
abort(reason?: any): void;
}
/**
* The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal)
*/
declare abstract class AbortSignal extends EventTarget {
/**
* The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static)
*/
static abort(reason?: any): AbortSignal;
/**
* The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static)
*/
static timeout(delay: number): AbortSignal;
/**
* The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static)
*/
static any(signals: AbortSignal[]): AbortSignal;
/**
* The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)
*/
get aborted(): boolean;
/**
* The **`reason`** read-only property returns a JavaScript value that indicates the abort reason.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason)
*/
get reason(): any;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
get onabort(): any | null;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
set onabort(value: any | null);
/**
* The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted)
*/
throwIfAborted(): void;
}
interface Scheduler {
wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>;
}
interface SchedulerWaitOptions {
signal?: AbortSignal;
}
/**
* The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent)
*/
declare abstract class ExtendableEvent extends Event {
/**
* The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)
*/
waitUntil(promise: Promise<any>): void;
}
/**
* The **`CustomEvent`** interface represents events initialized by an application for any purpose.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent)
*/
declare class CustomEvent<T = any> extends Event {
constructor(type: string, init?: CustomEventCustomEventInit);
/**
* The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail)
*/
get detail(): T;
}
interface CustomEventCustomEventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
detail?: any;
}
/**
* The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob)
*/
declare class Blob {
constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions);
/**
* The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size)
*/
get size(): number;
/**
* The **`type`** read-only property of the Blob interface returns the MIME type of the file.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type)
*/
get type(): string;
/**
* The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice)
*/
slice(start?: number, end?: number, type?: string): Blob;
/**
* The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer)
*/
arrayBuffer(): Promise<ArrayBuffer>;
/**
* The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes)
*/
bytes(): Promise<Uint8Array>;
/**
* The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text)
*/
text(): Promise<string>;
/**
* The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream)
*/
stream(): ReadableStream;
}
interface BlobOptions {
type?: string;
}
/**
* The **`File`** interface provides information about files and allows JavaScript in a web page to access their content.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File)
*/
declare class File extends Blob {
constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions);
/**
* The **`name`** read-only property of the File interface returns the name of the file represented by a File object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name)
*/
get name(): string;
/**
* The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified)
*/
get lastModified(): number;
}
interface FileOptions {
type?: string;
lastModified?: number;
}
/**
* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
*/
declare abstract class CacheStorage {
/**
* The **`open()`** method of the the Cache object matching the `cacheName`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open)
*/
open(cacheName: string): Promise<Cache>;
readonly default: Cache;
}
/**
* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
*/
declare abstract class Cache {
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */
delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise<boolean>;
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */
match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise<Response | undefined>;
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */
put(request: RequestInfo | URL, response: Response): Promise<void>;
}
interface CacheQueryOptions {
ignoreMethod?: boolean;
}
/**
* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
* The Workers runtime implements the full surface of this API, but with some differences in
* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
* compared to those implemented in most browsers.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
*/
declare abstract class Crypto {
/**
* The **`Crypto.subtle`** read-only property returns a cryptographic operations.
* Available only in secure contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
*/
get subtle(): SubtleCrypto;
/**
* The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues)
*/
getRandomValues<T extends Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | BigInt64Array | BigUint64Array>(buffer: T): T;
/**
* The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
* Available only in secure contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID)
*/
randomUUID(): string;
DigestStream: typeof DigestStream;
}
/**
* The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions.
* Available only in secure contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto)
*/
declare abstract class SubtleCrypto {
/**
* The **`encrypt()`** method of the SubtleCrypto interface encrypts data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt)
*/
encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/**
* The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt)
*/
decrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, cipherText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/**
* The **`sign()`** method of the SubtleCrypto interface generates a digital signature.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign)
*/
sign(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/**
* The **`verify()`** method of the SubtleCrypto interface verifies a digital signature.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify)
*/
verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean>;
/**
* The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest)
*/
digest(algorithm: string | SubtleCryptoHashAlgorithm, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/**
* The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey)
*/
generateKey(algorithm: string | SubtleCryptoGenerateKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey | CryptoKeyPair>;
/**
* The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey)
*/
deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>;
/**
* The **`deriveBits()`** method of the key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits)
*/
deriveBits(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, length?: number | null): Promise<ArrayBuffer>;
/**
* The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey)
*/
importKey(format: string, keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, algorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>;
/**
* The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey)
*/
exportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>;
/**
* The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey)
*/
wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise<ArrayBuffer>;
/**
* The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey)
*/
unwrapKey(format: string, wrappedKey: ArrayBuffer | ArrayBufferView, unwrappingKey: CryptoKey, unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>;
timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean;
}
/**
* The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey.
* Available only in secure contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey)
*/
declare abstract class CryptoKey {
/**
* The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type)
*/
readonly type: string;
/**
* The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable)
*/
readonly extractable: boolean;
/**
* The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm)
*/
readonly algorithm: CryptoKeyKeyAlgorithm | CryptoKeyAesKeyAlgorithm | CryptoKeyHmacKeyAlgorithm | CryptoKeyRsaKeyAlgorithm | CryptoKeyEllipticKeyAlgorithm | CryptoKeyArbitraryKeyAlgorithm;
/**
* The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages)
*/
readonly usages: string[];
}
interface CryptoKeyPair {
publicKey: CryptoKey;
privateKey: CryptoKey;
}
interface JsonWebKey {
kty: string;
use?: string;
key_ops?: string[];
alg?: string;
ext?: boolean;
crv?: string;
x?: string;
y?: string;
d?: string;
n?: string;
e?: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
oth?: RsaOtherPrimesInfo[];
k?: string;
}
interface RsaOtherPrimesInfo {
r?: string;
d?: string;
t?: string;
}
interface SubtleCryptoDeriveKeyAlgorithm {
name: string;
salt?: (ArrayBuffer | ArrayBufferView);
iterations?: number;
hash?: (string | SubtleCryptoHashAlgorithm);
$public?: CryptoKey;
info?: (ArrayBuffer | ArrayBufferView);
}
interface SubtleCryptoEncryptAlgorithm {
name: string;
iv?: (ArrayBuffer | ArrayBufferView);
additionalData?: (ArrayBuffer | ArrayBufferView);
tagLength?: number;
counter?: (ArrayBuffer | ArrayBufferView);
length?: number;
label?: (ArrayBuffer | ArrayBufferView);
}
interface SubtleCryptoGenerateKeyAlgorithm {
name: string;
hash?: (string | SubtleCryptoHashAlgorithm);
modulusLength?: number;
publicExponent?: (ArrayBuffer | ArrayBufferView);
length?: number;
namedCurve?: string;
}
interface SubtleCryptoHashAlgorithm {
name: string;
}
interface SubtleCryptoImportKeyAlgorithm {
name: string;
hash?: (string | SubtleCryptoHashAlgorithm);
length?: number;
namedCurve?: string;
compressed?: boolean;
}
interface SubtleCryptoSignAlgorithm {
name: string;
hash?: (string | SubtleCryptoHashAlgorithm);
dataLength?: number;
saltLength?: number;
}
interface CryptoKeyKeyAlgorithm {
name: string;
}
interface CryptoKeyAesKeyAlgorithm {
name: string;
length: number;
}
interface CryptoKeyHmacKeyAlgorithm {
name: string;
hash: CryptoKeyKeyAlgorithm;
length: number;
}
interface CryptoKeyRsaKeyAlgorithm {
name: string;
modulusLength: number;
publicExponent: ArrayBuffer | ArrayBufferView;
hash?: CryptoKeyKeyAlgorithm;
}
interface CryptoKeyEllipticKeyAlgorithm {
name: string;
namedCurve: string;
}
interface CryptoKeyArbitraryKeyAlgorithm {
name: string;
hash?: CryptoKeyKeyAlgorithm;
namedCurve?: string;
length?: number;
}
declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
constructor(algorithm: string | SubtleCryptoHashAlgorithm);
readonly digest: Promise<ArrayBuffer>;
get bytesWritten(): number | bigint;
}
/**
* The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)
*/
declare class TextDecoder {
constructor(label?: string, options?: TextDecoderConstructorOptions);
/**
* The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
*/
decode(input?: (ArrayBuffer | ArrayBufferView), options?: TextDecoderDecodeOptions): string;
get encoding(): string;
get fatal(): boolean;
get ignoreBOM(): boolean;
}
/**
* The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)
*/
declare class TextEncoder {
constructor();
/**
* The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)
*/
encode(input?: string): Uint8Array;
/**
* The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto)
*/
encodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult;
get encoding(): string;
}
interface TextDecoderConstructorOptions {
fatal: boolean;
ignoreBOM: boolean;
}
interface TextDecoderDecodeOptions {
stream: boolean;
}
interface TextEncoderEncodeIntoResult {
read: number;
written: number;
}
/**
* The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent)
*/
declare class ErrorEvent extends Event {
constructor(type: string, init?: ErrorEventErrorEventInit);
/**
* The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename)
*/
get filename(): string;
/**
* The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message)
*/
get message(): string;
/**
* The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno)
*/
get lineno(): number;
/**
* The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno)
*/
get colno(): number;
/**
* The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error)
*/
get error(): any;
}
interface ErrorEventErrorEventInit {
message?: string;
filename?: string;
lineno?: number;
colno?: number;
error?: any;
}
/**
* The **`MessageEvent`** interface represents a message received by a target object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
*/
declare class MessageEvent extends Event {
constructor(type: string, initializer: MessageEventInit);
/**
* The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data)
*/
readonly data: any;
/**
* The **`origin`** read-only property of the origin of the message emitter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin)
*/
readonly origin: string | null;
/**
* The **`lastEventId`** read-only property of the unique ID for the event.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId)
*/
readonly lastEventId: string;
/**
* The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source)
*/
readonly source: MessagePort | null;
/**
* The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports)
*/
readonly ports: MessagePort[];
}
interface MessageEventInit {
data: ArrayBuffer | string;
}
/**
* The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent)
*/
declare abstract class PromiseRejectionEvent extends Event {
/**
* The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise)
*/
readonly promise: Promise<any>;
/**
* The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject().
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason)
*/
readonly reason: any;
}
/**
* The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData)
*/
declare class FormData {
constructor();
/**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/
append(name: string, value: string | Blob): void;
/**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/
append(name: string, value: string): void;
/**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/
append(name: string, value: Blob, filename?: string): void;
/**
* The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete)
*/
delete(name: string): void;
/**
* The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get)
*/
get(name: string): (File | string) | null;
/**
* The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll)
*/
getAll(name: string): (File | string)[];
/**
* The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has)
*/
has(name: string): boolean;
/**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/
set(name: string, value: string | Blob): void;
/**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/
set(name: string, value: string): void;
/**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/
set(name: string, value: Blob, filename?: string): void;
/* Returns an array of key, value pairs for every entry in the list. */
entries(): IterableIterator<[
key: string,
value: File | string
]>;
/* Returns a list of keys in the list. */
keys(): IterableIterator<string>;
/* Returns a list of values in the list. */
values(): IterableIterator<(File | string)>;
forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
[Symbol.iterator](): IterableIterator<[
key: string,
value: File | string
]>;
}
interface ContentOptions {
html?: boolean;
}
declare class HTMLRewriter {
constructor();
on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter;
onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter;
transform(response: Response): Response;
}
interface HTMLRewriterElementContentHandlers {
element?(element: Element): void | Promise<void>;
comments?(comment: Comment): void | Promise<void>;
text?(element: Text): void | Promise<void>;
}
interface HTMLRewriterDocumentContentHandlers {
doctype?(doctype: Doctype): void | Promise<void>;
comments?(comment: Comment): void | Promise<void>;
text?(text: Text): void | Promise<void>;
end?(end: DocumentEnd): void | Promise<void>;
}
interface Doctype {
readonly name: string | null;
readonly publicId: string | null;
readonly systemId: string | null;
}
interface Element {
tagName: string;
readonly attributes: IterableIterator<string[]>;
readonly removed: boolean;
readonly namespaceURI: string;
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
setAttribute(name: string, value: string): Element;
removeAttribute(name: string): Element;
before(content: string | ReadableStream | Response, options?: ContentOptions): Element;
after(content: string | ReadableStream | Response, options?: ContentOptions): Element;
prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element;
append(content: string | ReadableStream | Response, options?: ContentOptions): Element;
replace(content: string | ReadableStream | Response, options?: ContentOptions): Element;
remove(): Element;
removeAndKeepContent(): Element;
setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element;
onEndTag(handler: (tag: EndTag) => void | Promise<void>): void;
}
interface EndTag {
name: string;
before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag;
after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag;
remove(): EndTag;
}
interface Comment {
text: string;
readonly removed: boolean;
before(content: string, options?: ContentOptions): Comment;
after(content: string, options?: ContentOptions): Comment;
replace(content: string, options?: ContentOptions): Comment;
remove(): Comment;
}
interface Text {
readonly text: string;
readonly lastInTextNode: boolean;
readonly removed: boolean;
before(content: string | ReadableStream | Response, options?: ContentOptions): Text;
after(content: string | ReadableStream | Response, options?: ContentOptions): Text;
replace(content: string | ReadableStream | Response, options?: ContentOptions): Text;
remove(): Text;
}
interface DocumentEnd {
append(content: string, options?: ContentOptions): DocumentEnd;
}
/**
* This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent)
*/
declare abstract class FetchEvent extends ExtendableEvent {
/**
* The **`request`** read-only property of the the event handler.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request)
*/
readonly request: Request;
/**
* The **`respondWith()`** method of allows you to provide a promise for a Response yourself.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith)
*/
respondWith(promise: Response | Promise<Response>): void;
passThroughOnException(): void;
}
type HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>;
/**
* The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers)
*/
declare class Headers {
constructor(init?: HeadersInit);
/**
* The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get)
*/
get(name: string): string | null;
getAll(name: string): string[];
/**
* The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie)
*/
getSetCookie(): string[];
/**
* The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has)
*/
has(name: string): boolean;
/**
* The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set)
*/
set(name: string, value: string): void;
/**
* The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append)
*/
append(name: string, value: string): void;
/**
* The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete)
*/
delete(name: string): void;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void;
/* Returns an iterator allowing to go through all key/value pairs contained in this object. */
entries(): IterableIterator<[
key: string,
value: string
]>;
/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
keys(): IterableIterator<string>;
/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
values(): IterableIterator<string>;
[Symbol.iterator](): IterableIterator<[
key: string,
value: string
]>;
}
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
declare abstract class Body {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
get body(): ReadableStream | null;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */
get bodyUsed(): boolean;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */
arrayBuffer(): Promise<ArrayBuffer>;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */
bytes(): Promise<Uint8Array>;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */
text(): Promise<string>;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */
json<T>(): Promise<T>;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */
formData(): Promise<FormData>;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */
blob(): Promise<Blob>;
}
/**
* The **`Response`** interface of the Fetch API represents the response to a request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)
*/
declare var Response: {
prototype: Response;
new (body?: BodyInit | null, init?: ResponseInit): Response;
error(): Response;
redirect(url: string, status?: number): Response;
json(any: any, maybeInit?: (ResponseInit | Response)): Response;
};
/**
* The **`Response`** interface of the Fetch API represents the response to a request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)
*/
interface Response extends Body {
/**
* The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone)
*/
clone(): Response;
/**
* The **`status`** read-only property of the Response interface contains the HTTP status codes of the response.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status)
*/
status: number;
/**
* The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText)
*/
statusText: string;
/**
* The **`headers`** read-only property of the with the response.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers)
*/
headers: Headers;
/**
* The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok)
*/
ok: boolean;
/**
* The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected)
*/
redirected: boolean;
/**
* The **`url`** read-only property of the Response interface contains the URL of the response.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url)
*/
url: string;
webSocket: WebSocket | null;
cf: any | undefined;
/**
* The **`type`** read-only property of the Response interface contains the type of the response.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type)
*/
type: "default" | "error";
}
interface ResponseInit {
status?: number;
statusText?: string;
headers?: HeadersInit;
cf?: any;
webSocket?: (WebSocket | null);
encodeBody?: "automatic" | "manual";
}
type RequestInfo<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> = Request<CfHostMetadata, Cf> | string;
/**
* The **`Request`** interface of the Fetch API represents a resource request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)
*/
declare var Request: {
prototype: Request;
new <CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>(input: RequestInfo<CfProperties> | URL, init?: RequestInit<Cf>): Request<CfHostMetadata, Cf>;
};
/**
* The **`Request`** interface of the Fetch API represents a resource request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)
*/
interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> extends Body {
/**
* The **`clone()`** method of the Request interface creates a copy of the current `Request` object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone)
*/
clone(): Request<CfHostMetadata, Cf>;
/**
* The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)
*/
method: string;
/**
* The **`url`** read-only property of the Request interface contains the URL of the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url)
*/
url: string;
/**
* The **`headers`** read-only property of the with the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)
*/
headers: Headers;
/**
* The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect)
*/
redirect: string;
fetcher: Fetcher | null;
/**
* The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal)
*/
signal: AbortSignal;
cf?: Cf;
/**
* The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity)
*/
integrity: string;
/**
* The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive)
*/
keepalive: boolean;
/**
* The **`cache`** read-only property of the Request interface contains the cache mode of the request.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache)
*/
cache?: "no-store" | "no-cache";
}
interface RequestInit<Cf = CfProperties> {
/* A string to set request's method. */
method?: string;
/* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */
headers?: HeadersInit;
/* A BodyInit object or null to set request's body. */
body?: BodyInit | null;
/* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */
redirect?: string;
fetcher?: (Fetcher | null);
cf?: Cf;
/* A string indicating how the request will interact with the browser's cache to set request's cache. */
cache?: "no-store" | "no-cache";
/* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */
integrity?: string;
/* An AbortSignal to set request's signal. */
signal?: (AbortSignal | null);
encodeResponseBody?: "automatic" | "manual";
}
type Service<T extends (new (...args: any[]) => Rpc.WorkerEntrypointBranded) | Rpc.WorkerEntrypointBranded | ExportedHandler<any, any, any> | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? Fetcher<InstanceType<T>> : T extends Rpc.WorkerEntrypointBranded ? Fetcher<T> : T extends Exclude<Rpc.EntrypointBranded, Rpc.WorkerEntrypointBranded> ? never : Fetcher<undefined>;
type Fetcher<T extends Rpc.EntrypointBranded | undefined = undefined, Reserved extends string = never> = (T extends Rpc.EntrypointBranded ? Rpc.Provider<T, Reserved | "fetch" | "connect"> : unknown) & {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
connect(address: SocketAddress | string, options?: SocketOptions): Socket;
};
interface KVNamespaceListKey<Metadata, Key extends string = string> {
name: Key;
expiration?: number;
metadata?: Metadata;
}
type KVNamespaceListResult<Metadata, Key extends string = string> = {
list_complete: false;
keys: KVNamespaceListKey<Metadata, Key>[];
cursor: string;
cacheStatus: string | null;
} | {
list_complete: true;
keys: KVNamespaceListKey<Metadata, Key>[];
cacheStatus: string | null;
};
interface KVNamespace<Key extends string = string> {
get(key: Key, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<string | null>;
get(key: Key, type: "text"): Promise<string | null>;
get<ExpectedValue = unknown>(key: Key, type: "json"): Promise<ExpectedValue | null>;
get(key: Key, type: "arrayBuffer"): Promise<ArrayBuffer | null>;
get(key: Key, type: "stream"): Promise<ReadableStream | null>;
get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise<string | null>;
get<ExpectedValue = unknown>(key: Key, options?: KVNamespaceGetOptions<"json">): Promise<ExpectedValue | null>;
get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise<ArrayBuffer | null>;
get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise<ReadableStream | null>;
get(key: Array<Key>, type: "text"): Promise<Map<string, string | null>>;
get<ExpectedValue = unknown>(key: Array<Key>, type: "json"): Promise<Map<string, ExpectedValue | null>>;
get(key: Array<Key>, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<Map<string, string | null>>;
get(key: Array<Key>, options?: KVNamespaceGetOptions<"text">): Promise<Map<string, string | null>>;
get<ExpectedValue = unknown>(key: Array<Key>, options?: KVNamespaceGetOptions<"json">): Promise<Map<string, ExpectedValue | null>>;
list<Metadata = unknown>(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<Metadata, Key>>;
put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise<void>;
getWithMetadata<Metadata = unknown>(key: Key, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, type: "text"): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>;
getWithMetadata<ExpectedValue = unknown, Metadata = unknown>(key: Key, type: "json"): Promise<KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, type: "arrayBuffer"): Promise<KVNamespaceGetWithMetadataResult<ArrayBuffer, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, type: "stream"): Promise<KVNamespaceGetWithMetadataResult<ReadableStream, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, options: KVNamespaceGetOptions<"text">): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>;
getWithMetadata<ExpectedValue = unknown, Metadata = unknown>(key: Key, options: KVNamespaceGetOptions<"json">): Promise<KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, options: KVNamespaceGetOptions<"arrayBuffer">): Promise<KVNamespaceGetWithMetadataResult<ArrayBuffer, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Key, options: KVNamespaceGetOptions<"stream">): Promise<KVNamespaceGetWithMetadataResult<ReadableStream, Metadata>>;
getWithMetadata<Metadata = unknown>(key: Array<Key>, type: "text"): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>;
getWithMetadata<ExpectedValue = unknown, Metadata = unknown>(key: Array<Key>, type: "json"): Promise<Map<string, KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>>;
getWithMetadata<Metadata = unknown>(key: Array<Key>, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>;
getWithMetadata<Metadata = unknown>(key: Array<Key>, options?: KVNamespaceGetOptions<"text">): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>;
getWithMetadata<ExpectedValue = unknown, Metadata = unknown>(key: Array<Key>, options?: KVNamespaceGetOptions<"json">): Promise<Map<string, KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>>;
delete(key: Key): Promise<void>;
}
interface KVNamespaceListOptions {
limit?: number;
prefix?: (string | null);
cursor?: (string | null);
}
interface KVNamespaceGetOptions<Type> {
type: Type;
cacheTtl?: number;
}
interface KVNamespacePutOptions {
expiration?: number;
expirationTtl?: number;
metadata?: (any | null);
}
interface KVNamespaceGetWithMetadataResult<Value, Metadata> {
value: Value | null;
metadata: Metadata | null;
cacheStatus: string | null;
}
type QueueContentType = "text" | "bytes" | "json" | "v8";
interface Queue<Body = unknown> {
metrics(): Promise<QueueMetrics>;
send(message: Body, options?: QueueSendOptions): Promise<QueueSendResponse>;
sendBatch(messages: Iterable<MessageSendRequest<Body>>, options?: QueueSendBatchOptions): Promise<QueueSendBatchResponse>;
}
interface QueueSendMetrics {
backlogCount: number;
backlogBytes: number;
oldestMessageTimestamp?: Date;
}
interface QueueSendMetadata {
metrics: QueueSendMetrics;
}
interface QueueSendResponse {
metadata: QueueSendMetadata;
}
interface QueueSendBatchMetrics {
backlogCount: number;
backlogBytes: number;
oldestMessageTimestamp?: Date;
}
interface QueueSendBatchMetadata {
metrics: QueueSendBatchMetrics;
}
interface QueueSendBatchResponse {
metadata: QueueSendBatchMetadata;
}
interface QueueSendOptions {
contentType?: QueueContentType;
delaySeconds?: number;
}
interface QueueSendBatchOptions {
delaySeconds?: number;
}
interface MessageSendRequest<Body = unknown> {
body: Body;
contentType?: QueueContentType;
delaySeconds?: number;
}
interface QueueMetrics {
backlogCount: number;
backlogBytes: number;
oldestMessageTimestamp?: Date;
}
interface MessageBatchMetrics {
backlogCount: number;
backlogBytes: number;
oldestMessageTimestamp?: Date;
}
interface MessageBatchMetadata {
metrics: MessageBatchMetrics;
}
interface QueueRetryOptions {
delaySeconds?: number;
}
interface Message<Body = unknown> {
readonly id: string;
readonly timestamp: Date;
readonly body: Body;
readonly attempts: number;
retry(options?: QueueRetryOptions): void;
ack(): void;
}
interface QueueEvent<Body = unknown> extends ExtendableEvent {
readonly messages: readonly Message<Body>[];
readonly queue: string;
readonly metadata: MessageBatchMetadata;
retryAll(options?: QueueRetryOptions): void;
ackAll(): void;
}
interface MessageBatch<Body = unknown> {
readonly messages: readonly Message<Body>[];
readonly queue: string;
readonly metadata: MessageBatchMetadata;
retryAll(options?: QueueRetryOptions): void;
ackAll(): void;
}
interface R2Error extends Error {
readonly name: string;
readonly code: number;
readonly message: string;
readonly action: string;
readonly stack: any;
}
interface R2ListOptions {
limit?: number;
prefix?: string;
cursor?: string;
delimiter?: string;
startAfter?: string;
include?: ("httpMetadata" | "customMetadata")[];
}
interface R2Bucket {
head(key: string): Promise<R2Object | null>;
get(key: string, options: R2GetOptions & {
onlyIf: R2Conditional | Headers;
}): Promise<R2ObjectBody | R2Object | null>;
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>;
put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions & {
onlyIf: R2Conditional | Headers;
}): Promise<R2Object | null>;
put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise<R2Object>;
createMultipartUpload(key: string, options?: R2MultipartOptions): Promise<R2MultipartUpload>;
resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
delete(keys: string | string[]): Promise<void>;
list(options?: R2ListOptions): Promise<R2Objects>;
}
interface R2MultipartUpload {
readonly key: string;
readonly uploadId: string;
uploadPart(partNumber: number, value: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob, options?: R2UploadPartOptions): Promise<R2UploadedPart>;
abort(): Promise<void>;
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
}
interface R2UploadedPart {
partNumber: number;
etag: string;
}
declare abstract class R2Object {
readonly key: string;
readonly version: string;
readonly size: number;
readonly etag: string;
readonly httpEtag: string;
readonly checksums: R2Checksums;
readonly uploaded: Date;
readonly httpMetadata?: R2HTTPMetadata;
readonly customMetadata?: Record<string, string>;
readonly range?: R2Range;
readonly storageClass: string;
readonly ssecKeyMd5?: string;
writeHttpMetadata(headers: Headers): void;
}
interface R2ObjectBody extends R2Object {
get body(): ReadableStream;
get bodyUsed(): boolean;
arrayBuffer(): Promise<ArrayBuffer>;
bytes(): Promise<Uint8Array>;
text(): Promise<string>;
json<T>(): Promise<T>;
blob(): Promise<Blob>;
}
type R2Range = {
offset: number;
length?: number;
} | {
offset?: number;
length: number;
} | {
suffix: number;
};
interface R2Conditional {
etagMatches?: string;
etagDoesNotMatch?: string;
uploadedBefore?: Date;
uploadedAfter?: Date;
secondsGranularity?: boolean;
}
interface R2GetOptions {
onlyIf?: (R2Conditional | Headers);
range?: (R2Range | Headers);
ssecKey?: (ArrayBuffer | string);
}
interface R2PutOptions {
onlyIf?: (R2Conditional | Headers);
httpMetadata?: (R2HTTPMetadata | Headers);
customMetadata?: Record<string, string>;
md5?: ((ArrayBuffer | ArrayBufferView) | string);
sha1?: ((ArrayBuffer | ArrayBufferView) | string);
sha256?: ((ArrayBuffer | ArrayBufferView) | string);
sha384?: ((ArrayBuffer | ArrayBufferView) | string);
sha512?: ((ArrayBuffer | ArrayBufferView) | string);
storageClass?: string;
ssecKey?: (ArrayBuffer | string);
}
interface R2MultipartOptions {
httpMetadata?: (R2HTTPMetadata | Headers);
customMetadata?: Record<string, string>;
storageClass?: string;
ssecKey?: (ArrayBuffer | string);
}
interface R2Checksums {
readonly md5?: ArrayBuffer;
readonly sha1?: ArrayBuffer;
readonly sha256?: ArrayBuffer;
readonly sha384?: ArrayBuffer;
readonly sha512?: ArrayBuffer;
toJSON(): R2StringChecksums;
}
interface R2StringChecksums {
md5?: string;
sha1?: string;
sha256?: string;
sha384?: string;
sha512?: string;
}
interface R2HTTPMetadata {
contentType?: string;
contentLanguage?: string;
contentDisposition?: string;
contentEncoding?: string;
cacheControl?: string;
cacheExpiry?: Date;
}
type R2Objects = {
objects: R2Object[];
delimitedPrefixes: string[];
} & ({
truncated: true;
cursor: string;
} | {
truncated: false;
});
interface R2UploadPartOptions {
ssecKey?: (ArrayBuffer | string);
}
declare abstract class ScheduledEvent extends ExtendableEvent {
readonly scheduledTime: number;
readonly cron: string;
noRetry(): void;
}
interface ScheduledController {
readonly scheduledTime: number;
readonly cron: string;
noRetry(): void;
}
interface QueuingStrategy<T = any> {
highWaterMark?: (number | bigint);
size?: (chunk: T) => number | bigint;
}
interface UnderlyingSink<W = any> {
type?: string;
start?: (controller: WritableStreamDefaultController) => void | Promise<void>;
write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise<void>;
abort?: (reason: any) => void | Promise<void>;
close?: () => void | Promise<void>;
}
interface UnderlyingByteSource {
type: "bytes";
autoAllocateChunkSize?: number;
start?: (controller: ReadableByteStreamController) => void | Promise<void>;
pull?: (controller: ReadableByteStreamController) => void | Promise<void>;
cancel?: (reason: any) => void | Promise<void>;
}
interface UnderlyingSource<R = any> {
type?: "" | undefined;
start?: (controller: ReadableStreamDefaultController<R>) => void | Promise<void>;
pull?: (controller: ReadableStreamDefaultController<R>) => void | Promise<void>;
cancel?: (reason: any) => void | Promise<void>;
expectedLength?: (number | bigint);
}
interface Transformer<I = any, O = any> {
readableType?: string;
writableType?: string;
start?: (controller: TransformStreamDefaultController<O>) => void | Promise<void>;
transform?: (chunk: I, controller: TransformStreamDefaultController<O>) => void | Promise<void>;
flush?: (controller: TransformStreamDefaultController<O>) => void | Promise<void>;
cancel?: (reason: any) => void | Promise<void>;
expectedLength?: number;
}
interface StreamPipeOptions {
preventAbort?: boolean;
preventCancel?: boolean;
/**
* Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.
*
* Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader.
*
* Errors and closures of the source and destination streams propagate as follows:
*
* An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination.
*
* An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source.
*
* When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error.
*
* If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source.
*
* The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set.
*/
preventClose?: boolean;
signal?: AbortSignal;
}
type ReadableStreamReadResult<R = any> = {
done: false;
value: R;
} | {
done: true;
value?: undefined;
};
/**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/
interface ReadableStream<R = any> {
/**
* The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked)
*/
get locked(): boolean;
/**
* The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel)
*/
cancel(reason?: any): Promise<void>;
/**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/
getReader(): ReadableStreamDefaultReader<R>;
/**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/
getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader;
/**
* The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough)
*/
pipeThrough<T>(transform: ReadableWritablePair<T, R>, options?: StreamPipeOptions): ReadableStream<T>;
/**
* The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo)
*/
pipeTo(destination: WritableStream<R>, options?: StreamPipeOptions): Promise<void>;
/**
* The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee)
*/
tee(): [
ReadableStream<R>,
ReadableStream<R>
];
values(options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>;
[Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>;
}
/**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/
declare const ReadableStream: {
prototype: ReadableStream;
new (underlyingSource: UnderlyingByteSource, strategy?: QueuingStrategy<Uint8Array>): ReadableStream<Uint8Array>;
new <R = any>(underlyingSource?: UnderlyingSource<R>, strategy?: QueuingStrategy<R>): ReadableStream<R>;
};
/**
* The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader)
*/
declare class ReadableStreamDefaultReader<R = any> {
constructor(stream: ReadableStream);
get closed(): Promise<void>;
cancel(reason?: any): Promise<void>;
/**
* The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read)
*/
read(): Promise<ReadableStreamReadResult<R>>;
/**
* The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock)
*/
releaseLock(): void;
}
/**
* The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader)
*/
declare class ReadableStreamBYOBReader {
constructor(stream: ReadableStream);
get closed(): Promise<void>;
cancel(reason?: any): Promise<void>;
/**
* The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read)
*/
read<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>;
/**
* The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock)
*/
releaseLock(): void;
readAtLeast<T extends ArrayBufferView>(minElements: number, view: T): Promise<ReadableStreamReadResult<T>>;
}
interface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions {
min?: number;
}
interface ReadableStreamGetReaderOptions {
/**
* Creates a ReadableStreamBYOBReader and locks the stream to the new reader.
*
* This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle "bring your own buffer" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation.
*/
mode: "byob";
}
/**
* The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest)
*/
declare abstract class ReadableStreamBYOBRequest {
/**
* The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view)
*/
get view(): Uint8Array | null;
/**
* The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond)
*/
respond(bytesWritten: number): void;
/**
* The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView)
*/
respondWithNewView(view: ArrayBuffer | ArrayBufferView): void;
get atLeast(): number | null;
}
/**
* The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController)
*/
declare abstract class ReadableStreamDefaultController<R = any> {
/**
* The **`desiredSize`** read-only property of the required to fill the stream's internal queue.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize)
*/
get desiredSize(): number | null;
/**
* The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close)
*/
close(): void;
/**
* The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue)
*/
enqueue(chunk?: R): void;
/**
* The **`error()`** method of the with the associated stream to error.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error)
*/
error(reason: any): void;
}
/**
* The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController)
*/
declare abstract class ReadableByteStreamController {
/**
* The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest)
*/
get byobRequest(): ReadableStreamBYOBRequest | null;
/**
* The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize)
*/
get desiredSize(): number | null;
/**
* The **`close()`** method of the ReadableByteStreamController interface closes the associated stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close)
*/
close(): void;
/**
* The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues).
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue)
*/
enqueue(chunk: ArrayBuffer | ArrayBufferView): void;
/**
* The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error)
*/
error(reason: any): void;
}
/**
* The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController)
*/
declare abstract class WritableStreamDefaultController {
/**
* The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal)
*/
get signal(): AbortSignal;
/**
* The **`error()`** method of the with the associated stream to error.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error)
*/
error(reason?: any): void;
}
/**
* The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController)
*/
declare abstract class TransformStreamDefaultController<O = any> {
/**
* The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize)
*/
get desiredSize(): number | null;
/**
* The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue)
*/
enqueue(chunk?: O): void;
/**
* The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error)
*/
error(reason: any): void;
/**
* The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate)
*/
terminate(): void;
}
interface ReadableWritablePair<R = any, W = any> {
readable: ReadableStream<R>;
/**
* Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use.
*
* Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader.
*/
writable: WritableStream<W>;
}
/**
* The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream)
*/
declare class WritableStream<W = any> {
constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy);
/**
* The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked)
*/
get locked(): boolean;
/**
* The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort)
*/
abort(reason?: any): Promise<void>;
/**
* The **`close()`** method of the WritableStream interface closes the associated stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close)
*/
close(): Promise<void>;
/**
* The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter)
*/
getWriter(): WritableStreamDefaultWriter<W>;
}
/**
* The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter)
*/
declare class WritableStreamDefaultWriter<W = any> {
constructor(stream: WritableStream);
/**
* The **`closed`** read-only property of the the stream errors or the writer's lock is released.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed)
*/
get closed(): Promise<void>;
/**
* The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready)
*/
get ready(): Promise<void>;
/**
* The **`desiredSize`** read-only property of the to fill the stream's internal queue.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize)
*/
get desiredSize(): number | null;
/**
* The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort)
*/
abort(reason?: any): Promise<void>;
/**
* The **`close()`** method of the stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close)
*/
close(): Promise<void>;
/**
* The **`write()`** method of the operation.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write)
*/
write(chunk?: W): Promise<void>;
/**
* The **`releaseLock()`** method of the corresponding stream.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock)
*/
releaseLock(): void;
}
/**
* The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream)
*/
declare class TransformStream<I = any, O = any> {
constructor(transformer?: Transformer<I, O>, writableStrategy?: QueuingStrategy<I>, readableStrategy?: QueuingStrategy<O>);
/**
* The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable)
*/
get readable(): ReadableStream<O>;
/**
* The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable)
*/
get writable(): WritableStream<I>;
}
declare class FixedLengthStream extends IdentityTransformStream {
constructor(expectedLength: number | bigint, queuingStrategy?: IdentityTransformStreamQueuingStrategy);
}
declare class IdentityTransformStream extends TransformStream<ArrayBuffer | ArrayBufferView, Uint8Array> {
constructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy);
}
interface IdentityTransformStreamQueuingStrategy {
highWaterMark?: (number | bigint);
}
interface ReadableStreamValuesOptions {
preventCancel?: boolean;
}
/**
* The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream)
*/
declare class CompressionStream extends TransformStream<ArrayBuffer | ArrayBufferView, Uint8Array> {
constructor(format: "gzip" | "deflate" | "deflate-raw");
}
/**
* The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream)
*/
declare class DecompressionStream extends TransformStream<ArrayBuffer | ArrayBufferView, Uint8Array> {
constructor(format: "gzip" | "deflate" | "deflate-raw");
}
/**
* The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream)
*/
declare class TextEncoderStream extends TransformStream<string, Uint8Array> {
constructor();
get encoding(): string;
}
/**
* The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream)
*/
declare class TextDecoderStream extends TransformStream<ArrayBuffer | ArrayBufferView, string> {
constructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit);
get encoding(): string;
get fatal(): boolean;
get ignoreBOM(): boolean;
}
interface TextDecoderStreamTextDecoderStreamInit {
fatal?: boolean;
ignoreBOM?: boolean;
}
/**
* The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy)
*/
declare class ByteLengthQueuingStrategy implements QueuingStrategy<ArrayBufferView> {
constructor(init: QueuingStrategyInit);
/**
* The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark)
*/
get highWaterMark(): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */
get size(): (chunk?: any) => number;
}
/**
* The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy)
*/
declare class CountQueuingStrategy implements QueuingStrategy {
constructor(init: QueuingStrategyInit);
/**
* The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark)
*/
get highWaterMark(): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */
get size(): (chunk?: any) => number;
}
interface QueuingStrategyInit {
/**
* Creates a new ByteLengthQueuingStrategy with the provided high water mark.
*
* Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw.
*/
highWaterMark: number;
}
interface TracePreviewInfo {
id: string;
slug: string;
name: string;
}
interface ScriptVersion {
id?: string;
tag?: string;
message?: string;
}
declare abstract class TailEvent extends ExtendableEvent {
readonly events: TraceItem[];
readonly traces: TraceItem[];
}
interface TraceItem {
readonly event: (TraceItemFetchEventInfo | TraceItemJsRpcEventInfo | TraceItemConnectEventInfo | TraceItemScheduledEventInfo | TraceItemAlarmEventInfo | TraceItemQueueEventInfo | TraceItemEmailEventInfo | TraceItemTailEventInfo | TraceItemCustomEventInfo | TraceItemHibernatableWebSocketEventInfo) | null;
readonly eventTimestamp: number | null;
readonly logs: TraceLog[];
readonly exceptions: TraceException[];
readonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[];
readonly scriptName: string | null;
readonly entrypoint?: string;
readonly scriptVersion?: ScriptVersion;
readonly dispatchNamespace?: string;
readonly scriptTags?: string[];
readonly tailAttributes?: Record<string, boolean | number | string>;
readonly preview?: TracePreviewInfo;
readonly durableObjectId?: string;
readonly outcome: string;
readonly executionModel: string;
readonly truncated: boolean;
readonly cpuTime: number;
readonly wallTime: number;
}
interface TraceItemAlarmEventInfo {
readonly scheduledTime: Date;
}
interface TraceItemConnectEventInfo {
}
interface TraceItemCustomEventInfo {
}
interface TraceItemScheduledEventInfo {
readonly scheduledTime: number;
readonly cron: string;
}
interface TraceItemQueueEventInfo {
readonly queue: string;
readonly batchSize: number;
}
interface TraceItemEmailEventInfo {
readonly mailFrom: string;
readonly rcptTo: string;
readonly rawSize: number;
}
interface TraceItemTailEventInfo {
readonly consumedEvents: TraceItemTailEventInfoTailItem[];
}
interface TraceItemTailEventInfoTailItem {
readonly scriptName: string | null;
}
interface TraceItemFetchEventInfo {
readonly response?: TraceItemFetchEventInfoResponse;
readonly request: TraceItemFetchEventInfoRequest;
}
interface TraceItemFetchEventInfoRequest {
readonly cf?: any;
readonly headers: Record<string, string>;
readonly method: string;
readonly url: string;
getUnredacted(): TraceItemFetchEventInfoRequest;
}
interface TraceItemFetchEventInfoResponse {
readonly status: number;
}
interface TraceItemJsRpcEventInfo {
readonly rpcMethod: string;
}
interface TraceItemHibernatableWebSocketEventInfo {
readonly getWebSocketEvent: TraceItemHibernatableWebSocketEventInfoMessage | TraceItemHibernatableWebSocketEventInfoClose | TraceItemHibernatableWebSocketEventInfoError;
}
interface TraceItemHibernatableWebSocketEventInfoMessage {
readonly webSocketEventType: string;
}
interface TraceItemHibernatableWebSocketEventInfoClose {
readonly webSocketEventType: string;
readonly code: number;
readonly wasClean: boolean;
}
interface TraceItemHibernatableWebSocketEventInfoError {
readonly webSocketEventType: string;
}
interface TraceLog {
readonly timestamp: number;
readonly level: string;
readonly message: any;
}
interface TraceException {
readonly timestamp: number;
readonly message: string;
readonly name: string;
readonly stack?: string;
}
interface TraceDiagnosticChannelEvent {
readonly timestamp: number;
readonly channel: string;
readonly message: any;
}
interface TraceMetrics {
readonly cpuTime: number;
readonly wallTime: number;
}
interface UnsafeTraceMetrics {
fromTrace(item: TraceItem): TraceMetrics;
}
/**
* The **`URL`** interface is used to parse, construct, normalize, and encode URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)
*/
declare class URL {
constructor(url: string | URL, base?: string | URL);
/**
* The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin)
*/
get origin(): string;
/**
* The **`href`** property of the URL interface is a string containing the whole URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href)
*/
get href(): string;
/**
* The **`href`** property of the URL interface is a string containing the whole URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href)
*/
set href(value: string);
/**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/
get protocol(): string;
/**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/
set protocol(value: string);
/**
* The **`username`** property of the URL interface is a string containing the username component of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/
get username(): string;
/**
* The **`username`** property of the URL interface is a string containing the username component of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/
set username(value: string);
/**
* The **`password`** property of the URL interface is a string containing the password component of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/
get password(): string;
/**
* The **`password`** property of the URL interface is a string containing the password component of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/
set password(value: string);
/**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/
get host(): string;
/**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/
set host(value: string);
/**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/
get hostname(): string;
/**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/
set hostname(value: string);
/**
* The **`port`** property of the URL interface is a string containing the port number of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/
get port(): string;
/**
* The **`port`** property of the URL interface is a string containing the port number of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/
set port(value: string);
/**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/
get pathname(): string;
/**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/
set pathname(value: string);
/**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/
get search(): string;
/**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/
set search(value: string);
/**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/
get hash(): string;
/**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/
set hash(value: string);
/**
* The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams)
*/
get searchParams(): URLSearchParams;
/**
* The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON)
*/
toJSON(): string;
/*function toString() { [native code] }*/
toString(): string;
/**
* The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static)
*/
static canParse(url: string, base?: string): boolean;
/**
* The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static)
*/
static parse(url: string, base?: string): URL | null;
/**
* The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static)
*/
static createObjectURL(object: File | Blob): string;
/**
* The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static)
*/
static revokeObjectURL(object_url: string): void;
}
/**
* The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams)
*/
declare class URLSearchParams {
constructor(init?: (Iterable<Iterable<string>> | Record<string, string> | string));
/**
* The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size)
*/
get size(): number;
/**
* The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append)
*/
append(name: string, value: string): void;
/**
* The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete)
*/
delete(name: string, value?: string): void;
/**
* The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get)
*/
get(name: string): string | null;
/**
* The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll)
*/
getAll(name: string): string[];
/**
* The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has)
*/
has(name: string, value?: string): boolean;
/**
* The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set)
*/
set(name: string, value: string): void;
/**
* The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort)
*/
sort(): void;
/* Returns an array of key, value pairs for every entry in the search params. */
entries(): IterableIterator<[
key: string,
value: string
]>;
/* Returns a list of keys in the search params. */
keys(): IterableIterator<string>;
/* Returns a list of values in the search params. */
values(): IterableIterator<string>;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void;
/*function toString() { [native code] }*/
toString(): string;
[Symbol.iterator](): IterableIterator<[
key: string,
value: string
]>;
}
declare class URLPattern {
constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions);
get protocol(): string;
get username(): string;
get password(): string;
get hostname(): string;
get port(): string;
get pathname(): string;
get search(): string;
get hash(): string;
get hasRegExpGroups(): boolean;
test(input?: (string | URLPatternInit), baseURL?: string): boolean;
exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null;
}
interface URLPatternInit {
protocol?: string;
username?: string;
password?: string;
hostname?: string;
port?: string;
pathname?: string;
search?: string;
hash?: string;
baseURL?: string;
}
interface URLPatternComponentResult {
input: string;
groups: Record<string, string>;
}
interface URLPatternResult {
inputs: (string | URLPatternInit)[];
protocol: URLPatternComponentResult;
username: URLPatternComponentResult;
password: URLPatternComponentResult;
hostname: URLPatternComponentResult;
port: URLPatternComponentResult;
pathname: URLPatternComponentResult;
search: URLPatternComponentResult;
hash: URLPatternComponentResult;
}
interface URLPatternOptions {
ignoreCase?: boolean;
}
/**
* A `CloseEvent` is sent to clients using WebSockets when the connection is closed.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent)
*/
declare class CloseEvent extends Event {
constructor(type: string, initializer?: CloseEventInit);
/**
* The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code)
*/
readonly code: number;
/**
* The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason)
*/
readonly reason: string;
/**
* The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean)
*/
readonly wasClean: boolean;
}
interface CloseEventInit {
code?: number;
reason?: string;
wasClean?: boolean;
}
type WebSocketEventMap = {
close: CloseEvent;
message: MessageEvent;
open: Event;
error: ErrorEvent;
};
/**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/
declare var WebSocket: {
prototype: WebSocket;
new (url: string, protocols?: (string[] | string)): WebSocket;
readonly READY_STATE_CONNECTING: number;
readonly CONNECTING: number;
readonly READY_STATE_OPEN: number;
readonly OPEN: number;
readonly READY_STATE_CLOSING: number;
readonly CLOSING: number;
readonly READY_STATE_CLOSED: number;
readonly CLOSED: number;
};
/**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/
interface WebSocket extends EventTarget<WebSocketEventMap> {
accept(options?: WebSocketAcceptOptions): void;
/**
* The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send)
*/
send(message: (ArrayBuffer | ArrayBufferView) | string): void;
/**
* The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close)
*/
close(code?: number, reason?: string): void;
serializeAttachment(attachment: any): void;
deserializeAttachment(): any | null;
/**
* The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState)
*/
readyState: number;
/**
* The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url)
*/
url: string | null;
/**
* The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol)
*/
protocol: string | null;
/**
* The **`WebSocket.extensions`** read-only property returns the extensions selected by the server.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions)
*/
extensions: string | null;
/**
* The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType)
*/
binaryType: "blob" | "arraybuffer";
}
interface WebSocketAcceptOptions {
/**
* When set to `true`, receiving a server-initiated WebSocket Close frame will not
* automatically send a reciprocal Close frame, leaving the connection in a half-open
* state. This is useful for proxying scenarios where you need to coordinate closing
* both sides independently. Defaults to `false` when the
* `no_web_socket_half_open_by_default` compatibility flag is enabled.
*/
allowHalfOpen?: boolean;
}
declare const WebSocketPair: {
new (): {
0: WebSocket;
1: WebSocket;
};
};
interface SqlStorage {
exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: any[]): SqlStorageCursor<T>;
get databaseSize(): number;
Cursor: typeof SqlStorageCursor;
Statement: typeof SqlStorageStatement;
}
declare abstract class SqlStorageStatement {
}
type SqlStorageValue = ArrayBuffer | string | number | null;
declare abstract class SqlStorageCursor<T extends Record<string, SqlStorageValue>> {
next(): {
done?: false;
value: T;
} | {
done: true;
value?: never;
};
toArray(): T[];
one(): T;
raw<U extends SqlStorageValue[]>(): IterableIterator<U>;
columnNames: string[];
get rowsRead(): number;
get rowsWritten(): number;
[Symbol.iterator](): IterableIterator<T>;
}
interface Socket {
get readable(): ReadableStream;
get writable(): WritableStream;
get closed(): Promise<void>;
get opened(): Promise<SocketInfo>;
get upgraded(): boolean;
get secureTransport(): "on" | "off" | "starttls";
close(): Promise<void>;
startTls(options?: TlsOptions): Socket;
}
interface SocketOptions {
secureTransport?: string;
allowHalfOpen: boolean;
highWaterMark?: (number | bigint);
}
interface SocketAddress {
hostname: string;
port: number;
}
interface TlsOptions {
expectedServerHostname?: string;
}
interface SocketInfo {
remoteAddress?: string;
localAddress?: string;
}
/**
* The **`EventSource`** interface is web content's interface to server-sent events.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource)
*/
declare class EventSource extends EventTarget {
constructor(url: string, init?: EventSourceEventSourceInit);
/**
* The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close)
*/
close(): void;
/**
* The **`url`** read-only property of the URL of the source.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url)
*/
get url(): string;
/**
* The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials)
*/
get withCredentials(): boolean;
/**
* The **`readyState`** read-only property of the connection.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState)
*/
get readyState(): number;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */
get onopen(): any | null;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */
set onopen(value: any | null);
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */
get onmessage(): any | null;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */
set onmessage(value: any | null);
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */
get onerror(): any | null;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */
set onerror(value: any | null);
static readonly CONNECTING: number;
static readonly OPEN: number;
static readonly CLOSED: number;
static from(stream: ReadableStream): EventSource;
}
interface EventSourceEventSourceInit {
withCredentials?: boolean;
fetcher?: Fetcher;
}
interface ExecOutput {
readonly stdout: ArrayBuffer;
readonly stderr: ArrayBuffer;
readonly exitCode: number;
}
interface ContainerExecOptions {
cwd?: string;
env?: Record<string, string>;
user?: string;
stdin?: ReadableStream | "pipe";
stdout?: "pipe" | "ignore";
stderr?: "pipe" | "ignore" | "combined";
}
interface ExecProcess {
readonly stdin: WritableStream | null;
readonly stdout: ReadableStream | null;
readonly stderr: ReadableStream | null;
readonly pid: number;
readonly exitCode: Promise<number>;
output(): Promise<ExecOutput>;
kill(signal?: number): void;
}
interface Container {
get running(): boolean;
start(options?: ContainerStartupOptions): void;
monitor(): Promise<void>;
destroy(error?: any): Promise<void>;
signal(signo: number): void;
getTcpPort(port: number): Fetcher;
setInactivityTimeout(durationMs: number | bigint): Promise<void>;
interceptOutboundHttp(addr: string, binding: Fetcher): Promise<void>;
interceptAllOutboundHttp(binding: Fetcher): Promise<void>;
snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise<ContainerDirectorySnapshot>;
snapshotContainer(options: ContainerSnapshotOptions): Promise<ContainerSnapshot>;
interceptOutboundHttps(addr: string, binding: Fetcher): Promise<void>;
exec(cmd: string[], options?: ContainerExecOptions): Promise<ExecProcess>;
}
interface ContainerDirectorySnapshot {
id: string;
size: number;
dir: string;
name?: string;
}
interface ContainerDirectorySnapshotOptions {
dir: string;
name?: string;
}
interface ContainerDirectorySnapshotRestoreParams {
snapshot: ContainerDirectorySnapshot;
mountPoint?: string;
}
interface ContainerSnapshot {
id: string;
size: number;
name?: string;
}
interface ContainerSnapshotOptions {
name?: string;
}
interface ContainerStartupOptions {
entrypoint?: string[];
enableInternet: boolean;
env?: Record<string, string>;
labels?: Record<string, string>;
directorySnapshots?: ContainerDirectorySnapshotRestoreParams[];
containerSnapshot?: ContainerSnapshot;
}
/**
* The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort)
*/
declare abstract class MessagePort extends EventTarget {
/**
* The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage)
*/
postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void;
/**
* The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close)
*/
close(): void;
/**
* The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start)
*/
start(): void;
get onmessage(): any | null;
set onmessage(value: any | null);
}
/**
* The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel)
*/
declare class MessageChannel {
constructor();
/**
* The **`port1`** read-only property of the the port attached to the context that originated the channel.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1)
*/
readonly port1: MessagePort;
/**
* The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2)
*/
readonly port2: MessagePort;
}
interface MessagePortPostMessageOptions {
transfer?: any[];
}
type LoopbackForExport<T extends (new (...args: any[]) => Rpc.EntrypointBranded) | ExportedHandler<any, any, any> | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? LoopbackServiceStub<InstanceType<T>> : T extends new (...args: any[]) => Rpc.DurableObjectBranded ? LoopbackDurableObjectClass<InstanceType<T>> : T extends ExportedHandler<any, any, any> ? LoopbackServiceStub<undefined> : undefined;
type LoopbackServiceStub<T extends Rpc.WorkerEntrypointBranded | undefined = undefined> = Fetcher<T> & (T extends CloudflareWorkersModule.WorkerEntrypoint<any, infer Props> ? (opts: {
props?: Props;
}) => Fetcher<T> : (opts: {
props?: any;
}) => Fetcher<T>);
type LoopbackDurableObjectClass<T extends Rpc.DurableObjectBranded | undefined = undefined> = DurableObjectClass<T> & (T extends CloudflareWorkersModule.DurableObject<any, infer Props> ? (opts: {
props?: Props;
}) => DurableObjectClass<T> : (opts: {
props?: any;
}) => DurableObjectClass<T>);
interface LoopbackDurableObjectNamespace extends DurableObjectNamespace {
}
interface LoopbackColoLocalActorNamespace extends ColoLocalActorNamespace {
}
interface SyncKvStorage {
get<T = unknown>(key: string): T | undefined;
list<T = unknown>(options?: SyncKvListOptions): Iterable<[
string,
T
]>;
put<T>(key: string, value: T): void;
delete(key: string): boolean;
}
interface SyncKvListOptions {
start?: string;
startAfter?: string;
end?: string;
prefix?: string;
reverse?: boolean;
limit?: number;
}
interface WorkerStub {
getEntrypoint<T extends Rpc.WorkerEntrypointBranded | undefined>(name?: string, options?: WorkerStubEntrypointOptions): Fetcher<T>;
getDurableObjectClass<T extends Rpc.DurableObjectBranded | undefined>(name?: string, options?: WorkerStubEntrypointOptions): DurableObjectClass<T>;
}
interface WorkerStubEntrypointOptions {
props?: any;
limits?: workerdResourceLimits;
}
interface WorkerLoader {
get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise<WorkerLoaderWorkerCode>): WorkerStub;
load(code: WorkerLoaderWorkerCode): WorkerStub;
}
interface WorkerLoaderModule {
js?: string;
cjs?: string;
text?: string;
data?: ArrayBuffer;
json?: any;
py?: string;
wasm?: ArrayBuffer;
}
interface WorkerLoaderWorkerCode {
compatibilityDate: string;
compatibilityFlags?: string[];
allowExperimental?: boolean;
limits?: workerdResourceLimits;
mainModule: string;
modules: Record<string, WorkerLoaderModule | string>;
env?: any;
globalOutbound?: (Fetcher | null);
tails?: Fetcher[];
streamingTails?: Fetcher[];
}
interface workerdResourceLimits {
cpuMs?: number;
subRequests?: number;
}
/**
* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
* as well as timing of subrequests and other operations.
*
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
*/
declare abstract class Performance {
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */
get timeOrigin(): number;
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
now(): number;
/**
* The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON)
*/
toJSON(): object;
}
interface Tracing {
enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
Span: typeof Span;
}
declare abstract class Span {
get isTraced(): boolean;
setAttribute(key: string, value?: (boolean | number | string)): void;
end(): void;
}
/**
* Represents the identity of a user authenticated via Cloudflare Access.
* This matches the result of calling /cdn-cgi/access/get-identity.
*
* The exact structure of the returned object depends on the identity provider
* configuration for the Access application. The fields below represent commonly
* available properties, but additional provider-specific fields may be present.
*/
interface CloudflareAccessIdentity extends Record<string, unknown> {
/** The user's email address, if available from the identity provider. */
email?: string;
/** The user's display name. */
name?: string;
/** The user's unique identifier. */
user_uuid?: string;
/** The Cloudflare account ID. */
account_id?: string;
/** Login timestamp (Unix epoch seconds). */
iat?: number;
/** The user's IP address at authentication time. */
ip?: string;
/** Authentication methods used (e.g., "pwd"). */
amr?: string[];
/** Identity provider information. */
idp?: {
id: string;
type: string;
};
/** Geographic information about where the user authenticated. */
geo?: {
country: string;
};
/** Group memberships from the identity provider. */
groups?: Array<{
id: string;
name: string;
email?: string;
}>;
/** Device posture check results, keyed by check ID. */
devicePosture?: Record<string, unknown>;
/** True if the user connected via Cloudflare WARP. */
is_warp?: boolean;
/** True if the user is authenticated via Cloudflare Gateway. */
is_gateway?: boolean;
}
// ============================================================================
// Agent Memory
//
// Public type surface for user Workers binding to an Agent Memory namespace.
// ============================================================================
/** Memory type — every memory is classified into exactly one. */
type AgentMemoryMemoryType = "fact" | "event" | "instruction" | "task";
/** Search intensity for recall. */
type AgentMemoryThinkingLevel = "low" | "medium" | "high";
/** Response verbosity for recall. */
type AgentMemoryResponseLength = "short" | "medium" | "long";
/** A conversation message passed to ingest(). */
interface AgentMemoryMessage {
role: "system" | "user" | "assistant";
content: string;
/** Optional message timestamp. */
timestamp?: Date;
}
/** Raw memory content passed to remember(). */
interface AgentMemoryIncomingMemory {
/** Raw memory content. The service classifies and summarizes automatically. */
content: string;
/** Optional session identifier to associate with this memory. */
sessionId?: string | null | undefined;
}
/** A stored memory returned from remember(), get(), and delete(). */
interface AgentMemoryMemory {
/** Memory ID. */
id: string;
/** Memory type. */
type: AgentMemoryMemoryType;
/** Text summary. */
summary: string;
/** Memory text. */
content: string;
/** Session that created this memory. */
sessionId: string | null;
/** Memory creation time. */
createdAt: Date;
/** Memory last-update time. */
updatedAt: Date;
}
/** Single entry in a list() response. Same shape as Memory minus full content. */
type AgentMemoryMemoryListEntry = Omit<AgentMemoryMemory, "content">;
/** A scored memory candidate in a recall result. */
interface AgentMemoryScoredCandidate {
/** Candidate ID. */
id: string;
/** Text summary. */
summary: string;
/** Session that created this candidate, when known. */
sessionId: string | null;
/** Relevance score (higher is better). Comparable only within a single query. */
score: number;
}
/** Options for the ingest() method. */
interface AgentMemoryIngestOptions {
/** Session identifier to associate with memories created during ingestion. */
sessionId?: string | null | undefined;
}
/** Options for the getSummary() method. */
interface AgentMemoryGetSummaryOptions {
/** Session identifier to retrieve session summary for. */
sessionId?: string | null | undefined;
}
/** Response from the getSummary() method. */
interface AgentMemoryGetSummaryResponse {
/** Markdown summary. */
summary: string;
}
/**
* Options for the recall() method.
*
* `referenceDate` accepts a Date object, an ISO-8601 date string
* (YYYY-MM-DD), or a full ISO-8601 datetime string. When provided, this
* date is used as "today" for resolving relative time references
* ("how many days ago", "last week") instead of the server's wall-clock time.
*/
interface AgentMemoryRecallOptions {
/** Recall intensity: "low" (default), "medium", or "high". */
thinkingLevel?: AgentMemoryThinkingLevel;
/** Response verbosity: "short", "medium" (default), or "long". */
responseLength?: AgentMemoryResponseLength;
/** Temporal anchor for date arithmetic. */
referenceDate?: Date | string;
}
/** Response from the recall() method. */
interface AgentMemoryRecallResult {
/** Number of memories retrieved. */
count: number;
/** LLM-generated answer synthesizing the matching memories. */
answer: string;
/** Matching memories ranked by relevance. */
candidates: AgentMemoryScoredCandidate[];
}
/**
* Options for the list() method.
*
* `cursor` is the opaque continuation token returned by the previous page;
* pass it back unchanged to fetch the next page. `sessionId` and `type`
* are exact-match filters; combining them is allowed.
*/
interface AgentMemoryListMemoriesOptions {
/** Maximum number of memories to return. Default 20, max 500. */
limit?: number;
/** Opaque cursor from a previous page. */
cursor?: string;
/** Exact-match session filter. */
sessionId?: string;
/** Exact-match memory-type filter. */
type?: AgentMemoryMemoryType;
}
/** Response from the list() method. */
interface AgentMemoryListMemoriesResult {
memories: AgentMemoryMemoryListEntry[];
/** Continuation cursor; absent when this page exhausted the result set. */
cursor?: string;
}
/**
* A single Agent Memory profile, scoped to a profile name.
*
* Returned by {@link AgentMemoryNamespace.getProfile}.
*/
declare abstract class AgentMemoryProfile {
/**
* Retrieve a memory by ID.
*
* @param memoryId - ULID of the memory to retrieve.
* @throws if the memory does not exist.
*/
get(memoryId: string): Promise<AgentMemoryMemory>;
/**
* Delete a memory by ID.
*
* Removes the memory and any source messages linked by the memory's
* source message IDs.
*
* @param memoryId - ULID of the memory to delete.
* @throws if the memory does not exist.
*/
delete(memoryId: string): Promise<AgentMemoryMemory>;
/**
* Store a memory in this profile. The content is automatically classified,
* summarized, and indexed.
*
* @param memory - Raw memory content to persist.
*/
remember(memory: AgentMemoryIncomingMemory): Promise<AgentMemoryMemory>;
/**
* Extract memories from a conversation.
*
* @param messages - Conversation messages to extract memories from.
* @param options - Optional ingest options.
*/
ingest(messages: Iterable<AgentMemoryMessage>, options?: AgentMemoryIngestOptions): Promise<void>;
/**
* Get a profile summary.
*
* @param options - Optional getSummary options.
*/
getSummary(options?: AgentMemoryGetSummaryOptions): Promise<AgentMemoryGetSummaryResponse>;
/**
* Recall memories in this profile.
*
* @param query - Recall query matched against memory content and keywords.
* @param options - Optional recall parameters.
* @returns Matching memories with relevance scores and a synthesized answer.
*/
recall(query: string, options?: AgentMemoryRecallOptions): Promise<AgentMemoryRecallResult>;
/**
* List active memories in this profile.
*
* Returns a paginated, filterable view of stored memories. Superseded
* versions are excluded. Use the returned `cursor` (when present) to
* fetch the next page.
*
* @param options - Optional pagination and filter options.
*/
list(options?: AgentMemoryListMemoriesOptions): Promise<AgentMemoryListMemoriesResult>;
/**
* Soft-delete every memory and message in this profile that is tagged
* with `sessionId`.
*
* Idempotent: deleting a sessionId that has no rows is a no-op.
*
* @param sessionId - Session to delete.
*/
deleteSession(sessionId: string): Promise<void>;
}
/**
* Namespace-level Agent Memory binding.
*
* Used as the type of an `env.MEMORY`-style binding backed by the Agent
* Memory product.
*
* @example
* ```ts
* export default {
* async fetch(_request: Request, env: Env): Promise<Response> {
* const profile = await env.MEMORY.getProfile("wrangler-e2e");
* const summary = await profile.getSummary();
* return Response.json(summary);
* },
* };
* ```
*/
declare abstract class AgentMemoryNamespace {
/**
* Get a memory profile by name. Profiles are isolated by namespace and
* addressed by a compound key (namespaceId:profileName).
*
* @param profileName - Profile name (validated against naming rules).
* @returns RPC target for interacting with the profile.
*/
getProfile(profileName: string): Promise<AgentMemoryProfile>;
/**
* Soft-delete a profile and schedule deferred purge. Marks all
* memories and messages as deleted.
*
* @param profileName - Name of the profile to delete.
*/
deleteProfile(profileName: string): Promise<void>;
}
// ============ AI Search Error Interfaces ============
interface AiSearchInternalError extends Error {
}
interface AiSearchNotFoundError extends Error {
}
// ============ AI Search Common Types ============
/** A single message in a conversation-style search or chat request. */
type AiSearchMessage = {
role: 'system' | 'developer' | 'user' | 'assistant' | 'tool';
content: string | null;
};
/**
* Common shape for `ai_search_options` used by both single-instance and multi-instance requests.
* Contains retrieval, query rewrite, reranking, and cache sub-options.
*/
type AiSearchOptions = {
retrieval?: {
/** Which retrieval backend to use. Defaults to the instance's configured index_method. */
retrieval_type?: 'vector' | 'keyword' | 'hybrid';
/** Fusion method for combining vector + keyword results. */
fusion_method?: 'max' | 'rrf';
/** How keyword terms are combined: "and" = all terms must match, "or" = any term matches. */
keyword_match_mode?: 'and' | 'or';
/** Minimum similarity score (0-1) for a result to be included. Default 0.4. */
match_threshold?: number;
/** Maximum number of results to return (1-50). Default 10. */
max_num_results?: number;
/** Vectorize metadata filters applied to the search. */
filters?: VectorizeVectorMetadataFilter;
/** Number of surrounding chunks to include for context (0-3). Default 0. */
context_expansion?: number;
/** If true, return only item metadata without chunk text. */
metadata_only?: boolean;
/** If true (default), return empty results on retrieval failure instead of throwing. */
return_on_failure?: boolean;
/** Boost results by metadata field values. Max 3 entries. */
boost_by?: Array<{
field: string;
direction?: 'asc' | 'desc' | 'exists' | 'not_exists';
}>;
[key: string]: unknown;
};
query_rewrite?: {
enabled?: boolean;
model?: string;
rewrite_prompt?: string;
[key: string]: unknown;
};
reranking?: {
enabled?: boolean;
model?: string;
/** Match threshold (0-1, default 0.4) */
match_threshold?: number;
[key: string]: unknown;
};
cache?: {
enabled?: boolean;
cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes';
};
[key: string]: unknown;
};
// ============ AI Search Request Types ============
/**
* Request body for single-instance search.
* Exactly one of `query` or `messages` must be provided.
*/
type AiSearchSearchRequest = {
/** Simple query string. */
query: string;
messages?: never;
ai_search_options?: AiSearchOptions;
} | {
query?: never;
/** Conversation-style input. At least one user message with non-empty content is required. */
messages: AiSearchMessage[];
ai_search_options?: AiSearchOptions;
};
type AiSearchChatCompletionsRequest = {
messages: AiSearchMessage[];
model?: string;
stream?: boolean;
ai_search_options?: AiSearchOptions;
[key: string]: unknown;
};
// ============ AI Search Multi-Instance Types (Namespace-Scoped) ============
/** `ai_search_options` shape for multi-instance requests — requires `instance_ids`. */
type AiSearchMultiSearchOptions = AiSearchOptions & {
/** Instance IDs to search across (1-10). */
instance_ids: string[];
};
/**
* Request for searching across multiple instances within a namespace.
* `ai_search_options` is required and must include `instance_ids`.
* Exactly one of `query` or `messages` must be provided.
*/
type AiSearchMultiSearchRequest = {
/** Simple query string. */
query: string;
messages?: never;
ai_search_options: AiSearchMultiSearchOptions;
} | {
query?: never;
/** Conversation-style input. */
messages: AiSearchMessage[];
ai_search_options: AiSearchMultiSearchOptions;
};
/** A search result chunk tagged with the instance it originated from. */
type AiSearchMultiSearchChunk = AiSearchSearchResponse['chunks'][number] & {
instance_id: string;
};
/** Describes a per-instance error during a multi-instance operation. */
type AiSearchMultiSearchError = {
instance_id: string;
message: string;
};
/** Response from a multi-instance search, with chunks tagged by instance and optional partial-failure errors. */
type AiSearchMultiSearchResponse = {
search_query: string;
chunks: AiSearchMultiSearchChunk[];
errors?: AiSearchMultiSearchError[];
};
/** Request for chat completions across multiple instances within a namespace. `ai_search_options` is required and must include `instance_ids`. */
type AiSearchMultiChatCompletionsRequest = Omit<AiSearchChatCompletionsRequest, 'ai_search_options'> & {
ai_search_options: AiSearchMultiSearchOptions;
};
/** Response from multi-instance chat completions, with chunks tagged by instance and optional partial-failure errors. */
type AiSearchMultiChatCompletionsResponse = Omit<AiSearchChatCompletionsResponse, 'chunks'> & {
chunks: AiSearchMultiSearchChunk[];
errors?: AiSearchMultiSearchError[];
};
// ============ AI Search Response Types ============
type AiSearchSearchResponse = {
search_query: string;
chunks: Array<{
id: string;
type: string;
/** Match score (0-1) */
score: number;
text: string;
item: {
timestamp?: number;
key: string;
metadata?: Record<string, unknown>;
};
scoring_details?: {
/** Keyword match score (0-1) */
keyword_score?: number;
/** Vector similarity score (0-1) */
vector_score?: number;
/** Keyword rank position */
keyword_rank?: number;
/** Vector rank position */
vector_rank?: number;
/** Reranking model score */
reranking_score?: number;
/** Fusion method used to combine results */
fusion_method?: 'rrf' | 'max';
[key: string]: unknown;
};
}>;
};
type AiSearchChatCompletionsResponse = {
id?: string;
object?: string;
model?: string;
choices: Array<{
index?: number;
message: {
role: 'system' | 'developer' | 'user' | 'assistant' | 'tool';
content: string | null;
[key: string]: unknown;
};
[key: string]: unknown;
}>;
chunks: AiSearchSearchResponse['chunks'];
[key: string]: unknown;
};
type AiSearchStatsResponse = {
queued?: number;
running?: number;
completed?: number;
error?: number;
skipped?: number;
outdated?: number;
last_activity?: string;
/** Storage engine statistics. */
engine?: {
vectorize?: {
vectorsCount: number;
dimensions: number;
};
r2?: {
payloadSizeBytes: number;
metadataSizeBytes: number;
objectCount: number;
};
};
};
// ============ AI Search Instance Info Types ============
type AiSearchInstanceInfo = {
id: string;
type?: 'r2' | 'web-crawler' | string;
source?: string;
source_params?: unknown;
paused?: boolean;
status?: string;
namespace?: string;
created_at?: string;
modified_at?: string;
token_id?: string;
ai_gateway_id?: string;
rewrite_query?: boolean;
reranking?: boolean;
embedding_model?: string;
ai_search_model?: string;
rewrite_model?: string;
reranking_model?: string;
/** @deprecated Use index_method instead. */
hybrid_search_enabled?: boolean;
/** Controls which storage backends are active. */
index_method?: {
vector?: boolean;
keyword?: boolean;
};
/** Fusion method for combining vector and keyword results. */
fusion_method?: 'max' | 'rrf';
indexing_options?: {
keyword_tokenizer?: 'porter' | 'trigram';
} | null;
retrieval_options?: {
keyword_match_mode?: 'and' | 'or';
boost_by?: Array<{
field: string;
direction?: 'asc' | 'desc' | 'exists' | 'not_exists';
}>;
} | null;
chunk?: boolean;
chunk_size?: number;
chunk_overlap?: number;
score_threshold?: number;
max_num_results?: number;
cache?: boolean;
cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes';
custom_metadata?: Array<{
field_name: string;
data_type: 'text' | 'number' | 'boolean' | 'datetime';
}>;
/** Sync interval in seconds. */
sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400;
metadata?: Record<string, unknown>;
[key: string]: unknown;
};
/** Pagination, search, and ordering parameters for listing instances within a namespace. */
type AiSearchListInstancesParams = {
page?: number;
per_page?: number;
/** Search instances by ID. */
search?: string;
/** Field to sort by. */
order_by?: 'created_at';
/** Sort direction. */
order_by_direction?: 'asc' | 'desc';
};
type AiSearchListResponse = {
result: AiSearchInstanceInfo[];
result_info?: {
count: number;
page: number;
per_page: number;
total_count: number;
};
};
// ============ AI Search Config Types ============
type AiSearchConfig = {
/** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */
id: string;
/** Instance type. Omit to create with built-in storage. */
type?: 'r2' | 'web-crawler' | string;
/** Source URL (required for web-crawler type). */
source?: string;
source_params?: unknown;
/** Token ID (UUID format) */
token_id?: string;
ai_gateway_id?: string;
/** Enable query rewriting (default false) */
rewrite_query?: boolean;
/** Enable reranking (default false) */
reranking?: boolean;
embedding_model?: string;
ai_search_model?: string;
rewrite_model?: string;
reranking_model?: string;
/** @deprecated Use index_method instead. */
hybrid_search_enabled?: boolean;
/** Controls which storage backends are used during indexing. Defaults to vector-only. */
index_method?: {
vector?: boolean;
keyword?: boolean;
};
/** Fusion method for combining vector and keyword results. "rrf" = reciprocal rank fusion (default), "max" = maximum score. */
fusion_method?: 'max' | 'rrf';
indexing_options?: {
keyword_tokenizer?: 'porter' | 'trigram';
} | null;
retrieval_options?: {
keyword_match_mode?: 'and' | 'or';
boost_by?: Array<{
field: string;
direction?: 'asc' | 'desc' | 'exists' | 'not_exists';
}>;
} | null;
chunk?: boolean;
chunk_size?: number;
chunk_overlap?: number;
/** Minimum similarity score (0-1) for a result to be included. */
score_threshold?: number;
max_num_results?: number;
cache?: boolean;
/** Similarity threshold for cache hits. Stricter = fewer cache hits but higher relevance. */
cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes';
custom_metadata?: Array<{
field_name: string;
data_type: 'text' | 'number' | 'boolean' | 'datetime';
}>;
namespace?: string;
/** Sync interval in seconds. 3600=1h, 7200=2h, 14400=4h, 21600=6h, 43200=12h, 86400=24h. */
sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400;
metadata?: Record<string, unknown>;
[key: string]: unknown;
};
// ============ AI Search Item Types ============
type AiSearchItemInfo = {
id: string;
key: string;
status: 'completed' | 'error' | 'skipped' | 'queued' | 'running' | 'outdated';
next_action?: 'INDEX' | 'DELETE' | null;
error?: string;
checksum?: string;
namespace?: string;
chunks_count?: number | null;
file_size?: number | null;
source_id?: string | null;
last_seen_at?: string;
created_at?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
};
type AiSearchItemContentResult = {
body: ReadableStream;
contentType: string;
filename: string;
size: number;
};
type AiSearchUploadItemOptions = {
metadata?: Record<string, unknown>;
};
type AiSearchListItemsParams = {
page?: number;
per_page?: number;
/** Search items by key name. */
search?: string;
/** Sort order for results. */
sort_by?: 'status' | 'modified_at';
/** Filter items by processing status. */
status?: 'queued' | 'running' | 'completed' | 'error' | 'skipped' | 'outdated';
/** Filter items by source (e.g. "builtin" or "web-crawler:https://example.com"). */
source?: string;
/** JSON-encoded Vectorize filter for metadata filtering. */
metadata_filter?: string;
};
type AiSearchListItemsResponse = {
result: AiSearchItemInfo[];
result_info?: {
count: number;
page: number;
per_page: number;
total_count: number;
};
};
// ============ AI Search Item Logs Types ============
type AiSearchItemLogsParams = {
/** Maximum number of log entries to return (1-100, default 50). */
limit?: number;
/** Opaque cursor for pagination. Pass the `cursor` value from a previous response. */
cursor?: string;
};
type AiSearchItemLog = {
timestamp: string;
action: string;
message: string;
fileKey?: string;
chunkCount?: number;
processingTimeMs?: number;
errorType?: string;
};
/** Paginated response for item processing logs (cursor-based). */
type AiSearchItemLogsResponse = {
result: AiSearchItemLog[];
result_info: {
count: number;
per_page: number;
cursor: string | null;
truncated: boolean;
};
};
// ============ AI Search Item Chunks Types ============
type AiSearchItemChunksParams = {
/** Maximum number of chunks to return (1-100, default 20). */
limit?: number;
/** Offset into the chunks list (default 0). */
offset?: number;
};
/** A single indexed chunk belonging to an item, including its text content and byte range. */
type AiSearchItemChunk = {
id: string;
text: string;
start_byte: number;
end_byte: number;
item?: {
timestamp?: number;
key: string;
metadata?: Record<string, unknown>;
};
};
/** Paginated response for item chunks (offset-based). */
type AiSearchItemChunksResponse = {
result: AiSearchItemChunk[];
result_info: {
count: number;
total: number;
limit: number;
offset: number;
};
};
// ============ AI Search Job Types ============
type AiSearchJobInfo = {
id: string;
source: 'user' | 'schedule';
description?: string;
last_seen_at?: string;
started_at?: string;
ended_at?: string;
end_reason?: string;
};
type AiSearchJobLog = {
id: number;
message: string;
message_type: number;
created_at: number;
};
type AiSearchCreateJobParams = {
description?: string;
};
type AiSearchListJobsParams = {
page?: number;
per_page?: number;
};
type AiSearchListJobsResponse = {
result: AiSearchJobInfo[];
result_info?: {
count: number;
page: number;
per_page: number;
total_count: number;
};
};
type AiSearchJobLogsParams = {
page?: number;
per_page?: number;
};
type AiSearchJobLogsResponse = {
result: AiSearchJobLog[];
result_info?: {
count: number;
page: number;
per_page: number;
total_count: number;
};
};
// ============ AI Search Sub-Service Classes ============
/**
* Single item service for an AI Search instance.
* Provides info, download, sync, logs, and chunks operations on a specific item.
*/
declare abstract class AiSearchItem {
/** Get metadata about this item. */
info(): Promise<AiSearchItemInfo>;
/**
* Download the item's content.
* @returns Object with body stream, content type, filename, and size.
*/
download(): Promise<AiSearchItemContentResult>;
/**
* Trigger re-indexing of this item.
* @returns The updated item info.
*/
sync(): Promise<AiSearchItemInfo>;
/**
* Retrieve processing logs for this item (cursor-based pagination).
* @param params Optional pagination parameters (limit, cursor).
* @returns Paginated log entries for this item.
*/
logs(params?: AiSearchItemLogsParams): Promise<AiSearchItemLogsResponse>;
/**
* List indexed chunks for this item (offset-based pagination).
* @param params Optional pagination parameters (limit, offset).
* @returns Paginated chunk entries for this item.
*/
chunks(params?: AiSearchItemChunksParams): Promise<AiSearchItemChunksResponse>;
}
/**
* Items collection service for an AI Search instance.
* Provides list, upload, and access to individual items.
*/
declare abstract class AiSearchItems {
/** List items in this instance. */
list(params?: AiSearchListItemsParams): Promise<AiSearchListItemsResponse>;
/**
* Upload a file as an item. Behaves as an upsert: if an item with the same
* filename already exists, it is overwritten and re-indexed.
* @param name Filename for the uploaded item.
* @param content File content as a ReadableStream, Blob, or string.
* @param options Optional metadata to attach to the item.
* @returns The created item info.
*/
upload(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions): Promise<AiSearchItemInfo>;
/**
* Upload a file and poll until processing completes.
* Behaves as an upsert: if an item with the same filename already exists,
* it is overwritten and re-indexed.
* @param name Filename for the uploaded item.
* @param content File content as a ReadableStream, Blob, or string.
* @param options Optional metadata and polling configuration.
* @returns The item info after processing completes (or timeout).
*/
uploadAndPoll(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions & {
/** Polling interval in milliseconds (default 1000). */
pollIntervalMs?: number;
/** Maximum time to wait in milliseconds (default 30000). */
timeoutMs?: number;
}): Promise<AiSearchItemInfo>;
/**
* Get an item by ID.
* @param itemId The item identifier.
* @returns Item service for info, download, sync, logs, and chunks operations.
*/
get(itemId: string): AiSearchItem;
/**
* Delete an item from the instance.
* @param itemId The item identifier.
*/
delete(itemId: string): Promise<void>;
}
/**
* Single job service for an AI Search instance.
* Provides info, logs, and cancel operations for a specific job.
*/
declare abstract class AiSearchJob {
/** Get metadata about this job. */
info(): Promise<AiSearchJobInfo>;
/** Get logs for this job. */
logs(params?: AiSearchJobLogsParams): Promise<AiSearchJobLogsResponse>;
/**
* Cancel a running job.
* @returns The updated job info.
* @throws AiSearchNotFoundError if the job does not exist.
*/
cancel(): Promise<AiSearchJobInfo>;
}
/**
* Jobs collection service for an AI Search instance.
* Provides list, create, and access to individual jobs.
*/
declare abstract class AiSearchJobs {
/** List jobs for this instance. */
list(params?: AiSearchListJobsParams): Promise<AiSearchListJobsResponse>;
/**
* Create a new indexing job.
* @param params Optional job parameters.
* @returns The created job info.
*/
create(params?: AiSearchCreateJobParams): Promise<AiSearchJobInfo>;
/**
* Get a job by ID.
* @param jobId The job identifier.
* @returns Job service for info, logs, and cancel operations.
*/
get(jobId: string): AiSearchJob;
}
// ============ AI Search Binding Classes ============
/**
* Instance-level AI Search service.
*
* Used as:
* - The return type of `AiSearchNamespace.get(name)` (namespace binding)
* - The type of `env.BLOG_SEARCH` (single instance binding via `ai_search`)
*
* Provides search, chat, update, stats, items, and jobs operations.
*
* @example
* ```ts
* // Via namespace binding
* const instance = env.AI_SEARCH.get("blog");
* const results = await instance.search({
* query: "How does caching work?",
* });
*
* // Via single instance binding
* const results = await env.BLOG_SEARCH.search({
* messages: [{ role: "user", content: "How does caching work?" }],
* });
* ```
*/
declare abstract class AiSearchInstance {
/**
* Search the AI Search instance for relevant chunks.
* @param params Search request with query or messages and optional AI search options.
* @returns Search response with matching chunks and search query.
*/
search(params: AiSearchSearchRequest): Promise<AiSearchSearchResponse>;
/**
* Generate chat completions with AI Search context (streaming).
* @param params Chat completions request with stream: true.
* @returns ReadableStream of server-sent events.
*/
chatCompletions(params: AiSearchChatCompletionsRequest & {
stream: true;
}): Promise<ReadableStream>;
/**
* Generate chat completions with AI Search context.
* @param params Chat completions request.
* @returns Chat completion response with choices and RAG chunks.
*/
chatCompletions(params: AiSearchChatCompletionsRequest): Promise<AiSearchChatCompletionsResponse>;
/**
* Update the instance configuration.
* @param config Partial configuration to update.
* @returns Updated instance info.
*/
update(config: Partial<AiSearchConfig>): Promise<AiSearchInstanceInfo>;
/** Get metadata about this instance. */
info(): Promise<AiSearchInstanceInfo>;
/**
* Get instance statistics (item count, indexing status, etc.).
* @returns Statistics with counts per status, last activity time, and engine details.
*/
stats(): Promise<AiSearchStatsResponse>;
/** Items collection — list, upload, and manage items in this instance. */
get items(): AiSearchItems;
/** Jobs collection — list, create, and inspect indexing jobs. */
get jobs(): AiSearchJobs;
}
/**
* Namespace-level AI Search service.
*
* Used as the type of `env.AI_SEARCH` (namespace binding via `ai_search_namespaces`).
* Scoped to a single namespace. Provides dynamic instance access, creation, deletion,
* and multi-instance search/chat operations.
*
* @example
* ```ts
* // Access an instance within the namespace
* const blog = env.AI_SEARCH.get("blog");
* const results = await blog.search({ query: "How does caching work?" });
*
* // List all instances in the namespace
* const instances = await env.AI_SEARCH.list();
*
* // Create a new instance with built-in storage
* const tenant = await env.AI_SEARCH.create({ id: "tenant-123" });
*
* // Upload items into the instance
* await tenant.items.upload("doc.pdf", fileContent);
*
* // Search across multiple instances
* const multi = await env.AI_SEARCH.search({
* query: "caching",
* ai_search_options: { instance_ids: ["blog", "docs"] },
* });
*
* // Delete an instance
* await env.AI_SEARCH.delete("tenant-123");
* ```
*/
declare abstract class AiSearchNamespace {
/**
* Get an instance by name within the bound namespace.
* @param name Instance name.
* @returns Instance service for search, chat, update, stats, items, and jobs.
*/
get(name: string): AiSearchInstance;
/**
* List instances in the bound namespace.
* @param params Optional pagination, search, and ordering parameters.
* @returns Array of instance metadata with pagination info.
*/
list(params?: AiSearchListInstancesParams): Promise<AiSearchListResponse>;
/**
* Create a new instance within the bound namespace.
* @param config Instance configuration. Only `id` is required — omit `type` and `source` to create with built-in storage.
* @returns Instance service for the newly created instance.
*
* @example
* ```ts
* // Create with built-in storage (upload items manually)
* const instance = await env.AI_SEARCH.create({ id: "my-search" });
*
* // Create with web crawler source
* const instance = await env.AI_SEARCH.create({
* id: "docs-search",
* type: "web-crawler",
* source: "https://developers.cloudflare.com",
* });
* ```
*/
create(config: AiSearchConfig): Promise<AiSearchInstance>;
/**
* Delete an instance from the bound namespace.
* @param name Instance name to delete.
*/
delete(name: string): Promise<void>;
/**
* Search across multiple instances within the bound namespace.
* Fans out to the specified instance_ids and merges results.
* @param params Search request with required `ai_search_options.instance_ids`.
* @returns Search response with chunks tagged by instance_id and optional partial-failure errors.
*/
search(params: AiSearchMultiSearchRequest): Promise<AiSearchMultiSearchResponse>;
/**
* Generate chat completions across multiple instances within the bound namespace (streaming).
* Fans out to the specified instance_ids, merges context, and generates a response.
* @param params Chat completions request with stream: true and required `ai_search_options.instance_ids`.
* @returns ReadableStream of server-sent events.
*/
chatCompletions(params: AiSearchMultiChatCompletionsRequest & {
stream: true;
}): Promise<ReadableStream>;
/**
* Generate chat completions across multiple instances within the bound namespace.
* Fans out to the specified instance_ids, merges context, and generates a response.
* @param params Chat completions request with required `ai_search_options.instance_ids`.
* @returns Chat completion response with choices, chunks tagged by instance_id, and optional partial-failure errors.
*/
chatCompletions(params: AiSearchMultiChatCompletionsRequest): Promise<AiSearchMultiChatCompletionsResponse>;
}
type AiImageClassificationInput = {
image: number[];
};
type AiImageClassificationOutput = {
score?: number;
label?: string;
}[];
declare abstract class BaseAiImageClassification {
inputs: AiImageClassificationInput;
postProcessedOutputs: AiImageClassificationOutput;
}
type AiImageToTextInput = {
image: number[];
prompt?: string;
max_tokens?: number;
temperature?: number;
top_p?: number;
top_k?: number;
seed?: number;
repetition_penalty?: number;
frequency_penalty?: number;
presence_penalty?: number;
raw?: boolean;
messages?: RoleScopedChatInput[];
};
type AiImageToTextOutput = {
description: string;
};
declare abstract class BaseAiImageToText {
inputs: AiImageToTextInput;
postProcessedOutputs: AiImageToTextOutput;
}
type AiImageTextToTextInput = {
image: string;
prompt?: string;
max_tokens?: number;
temperature?: number;
ignore_eos?: boolean;
top_p?: number;
top_k?: number;
seed?: number;
repetition_penalty?: number;
frequency_penalty?: number;
presence_penalty?: number;
raw?: boolean;
messages?: RoleScopedChatInput[];
};
type AiImageTextToTextOutput = {
description: string;
};
declare abstract class BaseAiImageTextToText {
inputs: AiImageTextToTextInput;
postProcessedOutputs: AiImageTextToTextOutput;
}
type AiMultimodalEmbeddingsInput = {
image: string;
text: string[];
};
type AiIMultimodalEmbeddingsOutput = {
data: number[][];
shape: number[];
};
declare abstract class BaseAiMultimodalEmbeddings {
inputs: AiImageTextToTextInput;
postProcessedOutputs: AiImageTextToTextOutput;
}
type AiObjectDetectionInput = {
image: number[];
};
type AiObjectDetectionOutput = {
score?: number;
label?: string;
}[];
declare abstract class BaseAiObjectDetection {
inputs: AiObjectDetectionInput;
postProcessedOutputs: AiObjectDetectionOutput;
}
type AiSentenceSimilarityInput = {
source: string;
sentences: string[];
};
type AiSentenceSimilarityOutput = number[];
declare abstract class BaseAiSentenceSimilarity {
inputs: AiSentenceSimilarityInput;
postProcessedOutputs: AiSentenceSimilarityOutput;
}
type AiAutomaticSpeechRecognitionInput = {
audio: number[];
};
type AiAutomaticSpeechRecognitionOutput = {
text?: string;
words?: {
word: string;
start: number;
end: number;
}[];
vtt?: string;
};
declare abstract class BaseAiAutomaticSpeechRecognition {
inputs: AiAutomaticSpeechRecognitionInput;
postProcessedOutputs: AiAutomaticSpeechRecognitionOutput;
}
type AiSummarizationInput = {
input_text: string;
max_length?: number;
};
type AiSummarizationOutput = {
summary: string;
};
declare abstract class BaseAiSummarization {
inputs: AiSummarizationInput;
postProcessedOutputs: AiSummarizationOutput;
}
type AiTextClassificationInput = {
text: string;
};
type AiTextClassificationOutput = {
score?: number;
label?: string;
}[];
declare abstract class BaseAiTextClassification {
inputs: AiTextClassificationInput;
postProcessedOutputs: AiTextClassificationOutput;
}
type AiTextEmbeddingsInput = {
text: string | string[];
};
type AiTextEmbeddingsOutput = {
shape: number[];
data: number[][];
};
declare abstract class BaseAiTextEmbeddings {
inputs: AiTextEmbeddingsInput;
postProcessedOutputs: AiTextEmbeddingsOutput;
}
type RoleScopedChatInput = {
role: "user" | "assistant" | "system" | "tool" | (string & NonNullable<unknown>);
content: string;
name?: string;
};
type AiTextGenerationToolLegacyInput = {
name: string;
description: string;
parameters?: {
type: "object" | (string & NonNullable<unknown>);
properties: {
[key: string]: {
type: string;
description?: string;
};
};
required: string[];
};
};
type AiTextGenerationToolInput = {
type: "function" | (string & NonNullable<unknown>);
function: {
name: string;
description: string;
parameters?: {
type: "object" | (string & NonNullable<unknown>);
properties: {
[key: string]: {
type: string;
description?: string;
};
};
required: string[];
};
};
};
type AiTextGenerationFunctionsInput = {
name: string;
code: string;
};
type AiTextGenerationResponseFormat = {
type: string;
json_schema?: any;
};
type AiTextGenerationInput = {
prompt?: string;
raw?: boolean;
stream?: boolean;
max_tokens?: number;
temperature?: number;
top_p?: number;
top_k?: number;
seed?: number;
repetition_penalty?: number;
frequency_penalty?: number;
presence_penalty?: number;
messages?: RoleScopedChatInput[];
response_format?: AiTextGenerationResponseFormat;
tools?: AiTextGenerationToolInput[] | AiTextGenerationToolLegacyInput[] | (object & NonNullable<unknown>);
functions?: AiTextGenerationFunctionsInput[];
};
type AiTextGenerationToolLegacyOutput = {
name: string;
arguments: unknown;
};
type AiTextGenerationToolOutput = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
type UsageTags = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
type AiTextGenerationOutput = {
response?: string;
tool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[];
usage?: UsageTags;
};
declare abstract class BaseAiTextGeneration {
inputs: AiTextGenerationInput;
postProcessedOutputs: AiTextGenerationOutput;
}
type AiTextToSpeechInput = {
prompt: string;
lang?: string;
};
type AiTextToSpeechOutput = Uint8Array | {
audio: string;
};
declare abstract class BaseAiTextToSpeech {
inputs: AiTextToSpeechInput;
postProcessedOutputs: AiTextToSpeechOutput;
}
type AiTextToImageInput = {
prompt: string;
negative_prompt?: string;
height?: number;
width?: number;
image?: number[];
image_b64?: string;
mask?: number[];
num_steps?: number;
strength?: number;
guidance?: number;
seed?: number;
};
type AiTextToImageOutput = ReadableStream<Uint8Array>;
declare abstract class BaseAiTextToImage {
inputs: AiTextToImageInput;
postProcessedOutputs: AiTextToImageOutput;
}
type AiTranslationInput = {
text: string;
target_lang: string;
source_lang?: string;
};
type AiTranslationOutput = {
translated_text?: string;
};
declare abstract class BaseAiTranslation {
inputs: AiTranslationInput;
postProcessedOutputs: AiTranslationOutput;
}
/**
* Workers AI support for OpenAI's Chat Completions API
*/
type ChatCompletionContentPartText = {
type: "text";
text: string;
};
type ChatCompletionContentPartImage = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "low" | "high";
};
};
type ChatCompletionContentPartInputAudio = {
type: "input_audio";
input_audio: {
/** Base64 encoded audio data. */
data: string;
format: "wav" | "mp3";
};
};
type ChatCompletionContentPartFile = {
type: "file";
file: {
/** Base64 encoded file data. */
file_data?: string;
/** The ID of an uploaded file. */
file_id?: string;
filename?: string;
};
};
type ChatCompletionContentPartRefusal = {
type: "refusal";
refusal: string;
};
type ChatCompletionContentPart = ChatCompletionContentPartText | ChatCompletionContentPartImage | ChatCompletionContentPartInputAudio | ChatCompletionContentPartFile;
type FunctionDefinition = {
name: string;
description?: string;
parameters?: Record<string, unknown>;
strict?: boolean | null;
};
type ChatCompletionFunctionTool = {
type: "function";
function: FunctionDefinition;
};
type ChatCompletionCustomToolGrammarFormat = {
type: "grammar";
grammar: {
definition: string;
syntax: "lark" | "regex";
};
};
type ChatCompletionCustomToolTextFormat = {
type: "text";
};
type ChatCompletionCustomToolFormat = ChatCompletionCustomToolTextFormat | ChatCompletionCustomToolGrammarFormat;
type ChatCompletionCustomTool = {
type: "custom";
custom: {
name: string;
description?: string;
format?: ChatCompletionCustomToolFormat;
};
};
type ChatCompletionTool = ChatCompletionFunctionTool | ChatCompletionCustomTool;
type ChatCompletionMessageFunctionToolCall = {
id: string;
type: "function";
function: {
name: string;
/** JSON-encoded arguments string. */
arguments: string;
};
};
type ChatCompletionMessageCustomToolCall = {
id: string;
type: "custom";
custom: {
name: string;
input: string;
};
};
type ChatCompletionMessageToolCall = ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall;
type ChatCompletionToolChoiceFunction = {
type: "function";
function: {
name: string;
};
};
type ChatCompletionToolChoiceCustom = {
type: "custom";
custom: {
name: string;
};
};
type ChatCompletionToolChoiceAllowedTools = {
type: "allowed_tools";
allowed_tools: {
mode: "auto" | "required";
tools: Array<Record<string, unknown>>;
};
};
type ChatCompletionToolChoiceOption = "none" | "auto" | "required" | ChatCompletionToolChoiceFunction | ChatCompletionToolChoiceCustom | ChatCompletionToolChoiceAllowedTools;
type DeveloperMessage = {
role: "developer";
content: string | Array<{
type: "text";
text: string;
}>;
name?: string;
};
type SystemMessage = {
role: "system";
content: string | Array<{
type: "text";
text: string;
}>;
name?: string;
};
/**
* Permissive merged content part used inside UserMessage arrays.
*
* Cabidela has a limitation where anyOf/oneOf with enum-based discrimination
* inside nested array items does not correctly match different branches for
* different array elements, so the schema uses a single merged object.
*/
type UserMessageContentPart = {
type: "text" | "image_url" | "input_audio" | "file";
text?: string;
image_url?: {
url?: string;
detail?: "auto" | "low" | "high";
};
input_audio?: {
data?: string;
format?: "wav" | "mp3";
};
file?: {
file_data?: string;
file_id?: string;
filename?: string;
};
};
type UserMessage = {
role: "user";
content: string | Array<UserMessageContentPart>;
name?: string;
};
type AssistantMessageContentPart = {
type: "text" | "refusal";
text?: string;
refusal?: string;
};
type AssistantMessage = {
role: "assistant";
content?: string | null | Array<AssistantMessageContentPart>;
refusal?: string | null;
name?: string;
audio?: {
id: string;
};
tool_calls?: Array<ChatCompletionMessageToolCall>;
function_call?: {
name: string;
arguments: string;
};
};
type ToolMessage = {
role: "tool";
content: string | Array<{
type: "text";
text: string;
}>;
tool_call_id: string;
};
type FunctionMessage = {
role: "function";
content: string;
name: string;
};
type ChatCompletionMessageParam = DeveloperMessage | SystemMessage | UserMessage | AssistantMessage | ToolMessage | FunctionMessage;
type ChatCompletionsResponseFormatText = {
type: "text";
};
type ChatCompletionsResponseFormatJSONObject = {
type: "json_object";
};
type ResponseFormatJSONSchema = {
type: "json_schema";
json_schema: {
name: string;
description?: string;
schema?: Record<string, unknown>;
strict?: boolean | null;
};
};
type ResponseFormat = ChatCompletionsResponseFormatText | ChatCompletionsResponseFormatJSONObject | ResponseFormatJSONSchema;
type ChatCompletionsStreamOptions = {
include_usage?: boolean;
include_obfuscation?: boolean;
};
type PredictionContent = {
type: "content";
content: string | Array<{
type: "text";
text: string;
}>;
};
type AudioParams = {
voice: string | {
id: string;
};
format: "wav" | "aac" | "mp3" | "flac" | "opus" | "pcm16";
};
type WebSearchUserLocation = {
type: "approximate";
approximate: {
city?: string;
country?: string;
region?: string;
timezone?: string;
};
};
type WebSearchOptions = {
search_context_size?: "low" | "medium" | "high";
user_location?: WebSearchUserLocation;
};
type ChatTemplateKwargs = {
/** Whether to enable reasoning, enabled by default. */
enable_thinking?: boolean;
/** If false, preserves reasoning context between turns. */
clear_thinking?: boolean;
};
/** Shared optional properties used by both Prompt and Messages input branches. */
type ChatCompletionsCommonOptions = {
model?: string;
audio?: AudioParams;
frequency_penalty?: number | null;
logit_bias?: Record<string, unknown> | null;
logprobs?: boolean | null;
top_logprobs?: number | null;
max_tokens?: number | null;
max_completion_tokens?: number | null;
metadata?: Record<string, unknown> | null;
modalities?: Array<"text" | "audio"> | null;
n?: number | null;
parallel_tool_calls?: boolean;
prediction?: PredictionContent;
presence_penalty?: number | null;
reasoning_effort?: "low" | "medium" | "high" | null;
chat_template_kwargs?: ChatTemplateKwargs;
response_format?: ResponseFormat;
seed?: number | null;
service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
stop?: string | Array<string> | null;
store?: boolean | null;
stream?: boolean | null;
stream_options?: ChatCompletionsStreamOptions;
temperature?: number | null;
tool_choice?: ChatCompletionToolChoiceOption;
tools?: Array<ChatCompletionTool>;
top_p?: number | null;
user?: string;
web_search_options?: WebSearchOptions;
function_call?: "none" | "auto" | {
name: string;
};
functions?: Array<FunctionDefinition>;
};
type PromptTokensDetails = {
cached_tokens?: number;
audio_tokens?: number;
};
type CompletionTokensDetails = {
reasoning_tokens?: number;
audio_tokens?: number;
accepted_prediction_tokens?: number;
rejected_prediction_tokens?: number;
};
type CompletionUsage = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
prompt_tokens_details?: PromptTokensDetails;
completion_tokens_details?: CompletionTokensDetails;
};
type ChatCompletionTopLogprob = {
token: string;
logprob: number;
bytes: Array<number> | null;
};
type ChatCompletionTokenLogprob = {
token: string;
logprob: number;
bytes: Array<number> | null;
top_logprobs: Array<ChatCompletionTopLogprob>;
};
type ChatCompletionAudio = {
id: string;
/** Base64 encoded audio bytes. */
data: string;
expires_at: number;
transcript: string;
};
type ChatCompletionUrlCitation = {
type: "url_citation";
url_citation: {
url: string;
title: string;
start_index: number;
end_index: number;
};
};
type ChatCompletionResponseMessage = {
role: "assistant";
content: string | null;
refusal: string | null;
annotations?: Array<ChatCompletionUrlCitation>;
audio?: ChatCompletionAudio;
tool_calls?: Array<ChatCompletionMessageToolCall>;
function_call?: {
name: string;
arguments: string;
} | null;
};
type ChatCompletionLogprobs = {
content: Array<ChatCompletionTokenLogprob> | null;
refusal?: Array<ChatCompletionTokenLogprob> | null;
};
type ChatCompletionChoice = {
index: number;
message: ChatCompletionResponseMessage;
finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call";
logprobs: ChatCompletionLogprobs | null;
};
type ChatCompletionsMessagesInput = {
messages: Array<ChatCompletionMessageParam>;
} & ChatCompletionsCommonOptions;
type ChatCompletionsOutput = {
id: string;
object: string;
created: number;
model: string;
choices: Array<ChatCompletionChoice>;
usage?: CompletionUsage;
system_fingerprint?: string | null;
service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
};
/**
* Workers AI support for OpenAI's Responses API
* Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts
*
* It's a stripped down version from its source.
* It currently supports basic function calling, json mode and accepts images as input.
*
* It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools.
* We plan to add those incrementally as model + platform capabilities evolve.
*/
type ResponsesInput = {
background?: boolean | null;
conversation?: string | ResponseConversationParam | null;
include?: Array<ResponseIncludable> | null;
input?: string | ResponseInput;
instructions?: string | null;
max_output_tokens?: number | null;
parallel_tool_calls?: boolean | null;
previous_response_id?: string | null;
prompt_cache_key?: string;
reasoning?: Reasoning | null;
safety_identifier?: string;
service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
stream?: boolean | null;
stream_options?: StreamOptions | null;
temperature?: number | null;
text?: ResponseTextConfig;
tool_choice?: ToolChoiceOptions | ToolChoiceFunction;
tools?: Array<Tool>;
top_p?: number | null;
truncation?: "auto" | "disabled" | null;
};
type ResponsesOutput = {
id?: string;
created_at?: number;
output_text?: string;
error?: ResponseError | null;
incomplete_details?: ResponseIncompleteDetails | null;
instructions?: string | Array<ResponseInputItem> | null;
object?: "response";
output?: Array<ResponseOutputItem>;
parallel_tool_calls?: boolean;
temperature?: number | null;
tool_choice?: ToolChoiceOptions | ToolChoiceFunction;
tools?: Array<Tool>;
top_p?: number | null;
max_output_tokens?: number | null;
previous_response_id?: string | null;
prompt?: ResponsePrompt | null;
reasoning?: Reasoning | null;
safety_identifier?: string;
service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
status?: ResponseStatus;
text?: ResponseTextConfig;
truncation?: "auto" | "disabled" | null;
usage?: ResponseUsage;
};
type EasyInputMessage = {
content: string | ResponseInputMessageContentList;
role: "user" | "assistant" | "system" | "developer";
type?: "message";
};
type ResponsesFunctionTool = {
name: string;
parameters: {
[key: string]: unknown;
} | null;
strict: boolean | null;
type: "function";
description?: string | null;
};
type ResponseIncompleteDetails = {
reason?: "max_output_tokens" | "content_filter";
};
type ResponsePrompt = {
id: string;
variables?: {
[key: string]: string | ResponseInputText | ResponseInputImage;
} | null;
version?: string | null;
};
type Reasoning = {
effort?: ReasoningEffort | null;
generate_summary?: "auto" | "concise" | "detailed" | null;
summary?: "auto" | "concise" | "detailed" | null;
};
type ResponseContent = ResponseInputText | ResponseInputImage | ResponseOutputText | ResponseOutputRefusal | ResponseContentReasoningText;
type ResponseContentReasoningText = {
text: string;
type: "reasoning_text";
};
type ResponseConversationParam = {
id: string;
};
type ResponseCreatedEvent = {
response: Response;
sequence_number: number;
type: "response.created";
};
type ResponseCustomToolCallOutput = {
call_id: string;
output: string | Array<ResponseInputText | ResponseInputImage>;
type: "custom_tool_call_output";
id?: string;
};
type ResponseError = {
code: "server_error" | "rate_limit_exceeded" | "invalid_prompt" | "vector_store_timeout" | "invalid_image" | "invalid_image_format" | "invalid_base64_image" | "invalid_image_url" | "image_too_large" | "image_too_small" | "image_parse_error" | "image_content_policy_violation" | "invalid_image_mode" | "image_file_too_large" | "unsupported_image_media_type" | "empty_image_file" | "failed_to_download_image" | "image_file_not_found";
message: string;
};
type ResponseErrorEvent = {
code: string | null;
message: string;
param: string | null;
sequence_number: number;
type: "error";
};
type ResponseFailedEvent = {
response: Response;
sequence_number: number;
type: "response.failed";
};
type ResponseFormatText = {
type: "text";
};
type ResponseFormatJSONObject = {
type: "json_object";
};
type ResponseFormatTextConfig = ResponseFormatText | ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject;
type ResponseFormatTextJSONSchemaConfig = {
name: string;
schema: {
[key: string]: unknown;
};
type: "json_schema";
description?: string;
strict?: boolean | null;
};
type ResponseFunctionCallArgumentsDeltaEvent = {
delta: string;
item_id: string;
output_index: number;
sequence_number: number;
type: "response.function_call_arguments.delta";
};
type ResponseFunctionCallArgumentsDoneEvent = {
arguments: string;
item_id: string;
name: string;
output_index: number;
sequence_number: number;
type: "response.function_call_arguments.done";
};
type ResponseFunctionCallOutputItem = ResponseInputTextContent | ResponseInputImageContent;
type ResponseFunctionCallOutputItemList = Array<ResponseFunctionCallOutputItem>;
type ResponseFunctionToolCall = {
arguments: string;
call_id: string;
name: string;
type: "function_call";
id?: string;
status?: "in_progress" | "completed" | "incomplete";
};
interface ResponseFunctionToolCallItem extends ResponseFunctionToolCall {
id: string;
}
type ResponseFunctionToolCallOutputItem = {
id: string;
call_id: string;
output: string | Array<ResponseInputText | ResponseInputImage>;
type: "function_call_output";
status?: "in_progress" | "completed" | "incomplete";
};
type ResponseIncludable = "message.input_image.image_url" | "message.output_text.logprobs";
type ResponseIncompleteEvent = {
response: Response;
sequence_number: number;
type: "response.incomplete";
};
type ResponseInput = Array<ResponseInputItem>;
type ResponseInputContent = ResponseInputText | ResponseInputImage;
type ResponseInputImage = {
detail: "low" | "high" | "auto";
type: "input_image";
/**
* Base64 encoded image
*/
image_url?: string | null;
};
type ResponseInputImageContent = {
type: "input_image";
detail?: "low" | "high" | "auto" | null;
/**
* Base64 encoded image
*/
image_url?: string | null;
};
type ResponseInputItem = EasyInputMessage | ResponseInputItemMessage | ResponseOutputMessage | ResponseFunctionToolCall | ResponseInputItemFunctionCallOutput | ResponseReasoningItem;
type ResponseInputItemFunctionCallOutput = {
call_id: string;
output: string | ResponseFunctionCallOutputItemList;
type: "function_call_output";
id?: string | null;
status?: "in_progress" | "completed" | "incomplete" | null;
};
type ResponseInputItemMessage = {
content: ResponseInputMessageContentList;
role: "user" | "system" | "developer";
status?: "in_progress" | "completed" | "incomplete";
type?: "message";
};
type ResponseInputMessageContentList = Array<ResponseInputContent>;
type ResponseInputMessageItem = {
id: string;
content: ResponseInputMessageContentList;
role: "user" | "system" | "developer";
status?: "in_progress" | "completed" | "incomplete";
type?: "message";
};
type ResponseInputText = {
text: string;
type: "input_text";
};
type ResponseInputTextContent = {
text: string;
type: "input_text";
};
type ResponseItem = ResponseInputMessageItem | ResponseOutputMessage | ResponseFunctionToolCallItem | ResponseFunctionToolCallOutputItem;
type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionToolCall | ResponseReasoningItem;
type ResponseOutputItemAddedEvent = {
item: ResponseOutputItem;
output_index: number;
sequence_number: number;
type: "response.output_item.added";
};
type ResponseOutputItemDoneEvent = {
item: ResponseOutputItem;
output_index: number;
sequence_number: number;
type: "response.output_item.done";
};
type ResponseOutputMessage = {
id: string;
content: Array<ResponseOutputText | ResponseOutputRefusal>;
role: "assistant";
status: "in_progress" | "completed" | "incomplete";
type: "message";
};
type ResponseOutputRefusal = {
refusal: string;
type: "refusal";
};
type ResponseOutputText = {
text: string;
type: "output_text";
logprobs?: Array<Logprob>;
};
type ResponseReasoningItem = {
id: string;
summary: Array<ResponseReasoningSummaryItem>;
type: "reasoning";
content?: Array<ResponseReasoningContentItem>;
encrypted_content?: string | null;
status?: "in_progress" | "completed" | "incomplete";
};
type ResponseReasoningSummaryItem = {
text: string;
type: "summary_text";
};
type ResponseReasoningContentItem = {
text: string;
type: "reasoning_text";
};
type ResponseReasoningTextDeltaEvent = {
content_index: number;
delta: string;
item_id: string;
output_index: number;
sequence_number: number;
type: "response.reasoning_text.delta";
};
type ResponseReasoningTextDoneEvent = {
content_index: number;
item_id: string;
output_index: number;
sequence_number: number;
text: string;
type: "response.reasoning_text.done";
};
type ResponseRefusalDeltaEvent = {
content_index: number;
delta: string;
item_id: string;
output_index: number;
sequence_number: number;
type: "response.refusal.delta";
};
type ResponseRefusalDoneEvent = {
content_index: number;
item_id: string;
output_index: number;
refusal: string;
sequence_number: number;
type: "response.refusal.done";
};
type ResponseStatus = "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete";
type ResponseStreamEvent = ResponseCompletedEvent | ResponseCreatedEvent | ResponseErrorEvent | ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent | ResponseFailedEvent | ResponseIncompleteEvent | ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent | ResponseReasoningTextDeltaEvent | ResponseReasoningTextDoneEvent | ResponseRefusalDeltaEvent | ResponseRefusalDoneEvent | ResponseTextDeltaEvent | ResponseTextDoneEvent;
type ResponseCompletedEvent = {
response: Response;
sequence_number: number;
type: "response.completed";
};
type ResponseTextConfig = {
format?: ResponseFormatTextConfig;
verbosity?: "low" | "medium" | "high" | null;
};
type ResponseTextDeltaEvent = {
content_index: number;
delta: string;
item_id: string;
logprobs: Array<Logprob>;
output_index: number;
sequence_number: number;
type: "response.output_text.delta";
};
type ResponseTextDoneEvent = {
content_index: number;
item_id: string;
logprobs: Array<Logprob>;
output_index: number;
sequence_number: number;
text: string;
type: "response.output_text.done";
};
type Logprob = {
token: string;
logprob: number;
top_logprobs?: Array<TopLogprob>;
};
type TopLogprob = {
token?: string;
logprob?: number;
};
type ResponseUsage = {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
type Tool = ResponsesFunctionTool;
type ToolChoiceFunction = {
name: string;
type: "function";
};
type ToolChoiceOptions = "none";
type ReasoningEffort = "minimal" | "low" | "medium" | "high" | null;
type StreamOptions = {
include_obfuscation?: boolean;
};
/** Marks keys from T that aren't in U as optional never */
type Without<T, U> = {
[P in Exclude<keyof T, keyof U>]?: never;
};
/** Either T or U, but not both (mutually exclusive) */
type XOR<T, U> = (T & Without<U, T>) | (U & Without<T, U>);
type Ai_Cf_Baai_Bge_Base_En_V1_5_Input = {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
} | {
/**
* Batch of the embeddings requests to run using async-queue
*/
requests: {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
}[];
};
type Ai_Cf_Baai_Bge_Base_En_V1_5_Output = {
shape?: number[];
/**
* Embeddings of the requested text values
*/
data?: number[][];
/**
* The pooling method used in the embedding process.
*/
pooling?: "mean" | "cls";
} | Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse;
interface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 {
inputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input;
postProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output;
}
type Ai_Cf_Openai_Whisper_Input = string | {
/**
* An array of integers that represent the audio data constrained to 8-bit unsigned integer values
*/
audio: number[];
};
interface Ai_Cf_Openai_Whisper_Output {
/**
* The transcription
*/
text: string;
word_count?: number;
words?: {
word?: string;
/**
* The second this word begins in the recording
*/
start?: number;
/**
* The ending second when the word completes
*/
end?: number;
}[];
vtt?: string;
}
declare abstract class Base_Ai_Cf_Openai_Whisper {
inputs: Ai_Cf_Openai_Whisper_Input;
postProcessedOutputs: Ai_Cf_Openai_Whisper_Output;
}
type Ai_Cf_Meta_M2M100_1_2B_Input = {
/**
* The text to be translated
*/
text: string;
/**
* The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified
*/
source_lang?: string;
/**
* The language code to translate the text into (e.g., 'es' for Spanish)
*/
target_lang: string;
} | {
/**
* Batch of the embeddings requests to run using async-queue
*/
requests: {
/**
* The text to be translated
*/
text: string;
/**
* The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified
*/
source_lang?: string;
/**
* The language code to translate the text into (e.g., 'es' for Spanish)
*/
target_lang: string;
}[];
};
type Ai_Cf_Meta_M2M100_1_2B_Output = {
/**
* The translated text in the target language
*/
translated_text?: string;
} | Ai_Cf_Meta_M2M100_1_2B_AsyncResponse;
interface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Meta_M2M100_1_2B {
inputs: Ai_Cf_Meta_M2M100_1_2B_Input;
postProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output;
}
type Ai_Cf_Baai_Bge_Small_En_V1_5_Input = {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
} | {
/**
* Batch of the embeddings requests to run using async-queue
*/
requests: {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
}[];
};
type Ai_Cf_Baai_Bge_Small_En_V1_5_Output = {
shape?: number[];
/**
* Embeddings of the requested text values
*/
data?: number[][];
/**
* The pooling method used in the embedding process.
*/
pooling?: "mean" | "cls";
} | Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse;
interface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 {
inputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input;
postProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output;
}
type Ai_Cf_Baai_Bge_Large_En_V1_5_Input = {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
} | {
/**
* Batch of the embeddings requests to run using async-queue
*/
requests: {
text: string | string[];
/**
* The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.
*/
pooling?: "mean" | "cls";
}[];
};
type Ai_Cf_Baai_Bge_Large_En_V1_5_Output = {
shape?: number[];
/**
* Embeddings of the requested text values
*/
data?: number[][];
/**
* The pooling method used in the embedding process.
*/
pooling?: "mean" | "cls";
} | Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse;
interface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 {
inputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input;
postProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output;
}
type Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input = string | {
/**
* The input text prompt for the model to generate a response.
*/
prompt?: string;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
image: number[] | (string & NonNullable<unknown>);
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
};
interface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output {
description?: string;
}
declare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M {
inputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input;
postProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output;
}
type Ai_Cf_Openai_Whisper_Tiny_En_Input = string | {
/**
* An array of integers that represent the audio data constrained to 8-bit unsigned integer values
*/
audio: number[];
};
interface Ai_Cf_Openai_Whisper_Tiny_En_Output {
/**
* The transcription
*/
text: string;
word_count?: number;
words?: {
word?: string;
/**
* The second this word begins in the recording
*/
start?: number;
/**
* The ending second when the word completes
*/
end?: number;
}[];
vtt?: string;
}
declare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En {
inputs: Ai_Cf_Openai_Whisper_Tiny_En_Input;
postProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output;
}
interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input {
audio: string | {
body?: object;
contentType?: string;
};
/**
* Supported tasks are 'translate' or 'transcribe'.
*/
task?: string;
/**
* The language of the audio being transcribed or translated.
*/
language?: string;
/**
* Preprocess the audio with a voice activity detection model.
*/
vad_filter?: boolean;
/**
* A text prompt to help provide context to the model on the contents of the audio.
*/
initial_prompt?: string;
/**
* The prefix appended to the beginning of the output of the transcription and can guide the transcription result.
*/
prefix?: string;
/**
* The number of beams to use in beam search decoding. Higher values may improve accuracy at the cost of speed.
*/
beam_size?: number;
/**
* Whether to condition on previous text during transcription. Setting to false may help prevent hallucination loops.
*/
condition_on_previous_text?: boolean;
/**
* Threshold for detecting no-speech segments. Segments with no-speech probability above this value are skipped.
*/
no_speech_threshold?: number;
/**
* Threshold for filtering out segments with high compression ratio, which often indicate repetitive or hallucinated text.
*/
compression_ratio_threshold?: number;
/**
* Threshold for filtering out segments with low average log probability, indicating low confidence.
*/
log_prob_threshold?: number;
/**
* Optional threshold (in seconds) to skip silent periods that may cause hallucinations.
*/
hallucination_silence_threshold?: number;
}
interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output {
transcription_info?: {
/**
* The language of the audio being transcribed or translated.
*/
language?: string;
/**
* The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1.
*/
language_probability?: number;
/**
* The total duration of the original audio file, in seconds.
*/
duration?: number;
/**
* The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds.
*/
duration_after_vad?: number;
};
/**
* The complete transcription of the audio.
*/
text: string;
/**
* The total number of words in the transcription.
*/
word_count?: number;
segments?: {
/**
* The starting time of the segment within the audio, in seconds.
*/
start?: number;
/**
* The ending time of the segment within the audio, in seconds.
*/
end?: number;
/**
* The transcription of the segment.
*/
text?: string;
/**
* The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs.
*/
temperature?: number;
/**
* The average log probability of the predictions for the words in this segment, indicating overall confidence.
*/
avg_logprob?: number;
/**
* The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process.
*/
compression_ratio?: number;
/**
* The probability that the segment contains no speech, represented as a decimal between 0 and 1.
*/
no_speech_prob?: number;
words?: {
/**
* The individual word transcribed from the audio.
*/
word?: string;
/**
* The starting time of the word within the audio, in seconds.
*/
start?: number;
/**
* The ending time of the word within the audio, in seconds.
*/
end?: number;
}[];
}[];
/**
* The transcription in WebVTT format, which includes timing and text information for use in subtitles.
*/
vtt?: string;
}
declare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo {
inputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input;
postProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output;
}
type Ai_Cf_Baai_Bge_M3_Input = Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts | Ai_Cf_Baai_Bge_M3_Input_Embedding | {
/**
* Batch of the embeddings requests to run using async-queue
*/
requests: (Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 | Ai_Cf_Baai_Bge_M3_Input_Embedding_1)[];
};
interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts {
/**
* A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts
*/
query?: string;
/**
* List of provided contexts. Note that the index in this array is important, as the response will refer to it.
*/
contexts: {
/**
* One of the provided context content
*/
text?: string;
}[];
/**
* When provided with too long context should the model error out or truncate the context to fit?
*/
truncate_inputs?: boolean;
}
interface Ai_Cf_Baai_Bge_M3_Input_Embedding {
text: string | string[];
/**
* When provided with too long context should the model error out or truncate the context to fit?
*/
truncate_inputs?: boolean;
}
interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 {
/**
* A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts
*/
query?: string;
/**
* List of provided contexts. Note that the index in this array is important, as the response will refer to it.
*/
contexts: {
/**
* One of the provided context content
*/
text?: string;
}[];
/**
* When provided with too long context should the model error out or truncate the context to fit?
*/
truncate_inputs?: boolean;
}
interface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 {
text: string | string[];
/**
* When provided with too long context should the model error out or truncate the context to fit?
*/
truncate_inputs?: boolean;
}
type Ai_Cf_Baai_Bge_M3_Output = Ai_Cf_Baai_Bge_M3_Output_Query | Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts | Ai_Cf_Baai_Bge_M3_Output_Embedding | Ai_Cf_Baai_Bge_M3_AsyncResponse;
interface Ai_Cf_Baai_Bge_M3_Output_Query {
response?: {
/**
* Index of the context in the request
*/
id?: number;
/**
* Score of the context under the index.
*/
score?: number;
}[];
}
interface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts {
response?: number[][];
shape?: number[];
/**
* The pooling method used in the embedding process.
*/
pooling?: "mean" | "cls";
}
interface Ai_Cf_Baai_Bge_M3_Output_Embedding {
shape?: number[];
/**
* Embeddings of the requested text values
*/
data?: number[][];
/**
* The pooling method used in the embedding process.
*/
pooling?: "mean" | "cls";
}
interface Ai_Cf_Baai_Bge_M3_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Baai_Bge_M3 {
inputs: Ai_Cf_Baai_Bge_M3_Input;
postProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output;
}
interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input {
/**
* A text description of the image you want to generate.
*/
prompt: string;
/**
* The number of diffusion steps; higher values can improve quality but take longer.
*/
steps?: number;
}
interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output {
/**
* The generated image in Base64 format.
*/
image?: string;
}
declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell {
inputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input;
postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output;
}
type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input = Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages;
interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
image?: number[] | (string & NonNullable<unknown>);
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
}
interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
/**
* The tool call id. If you don't know what to put here you can fall back to 000000001
*/
tool_call_id?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[] | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
};
}[];
image?: number[] | (string & NonNullable<unknown>);
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
/**
* If true, the response will be streamed back incrementally.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = {
/**
* The generated text response from the model
*/
response?: string;
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
};
declare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct {
inputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input;
postProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output;
}
type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input = Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch;
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
content: string | {
/**
* Type of the content (text)
*/
type?: string;
/**
* Text content
*/
text?: string;
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch {
requests?: {
/**
* User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique.
*/
external_reference?: string;
/**
* Prompt for the text generation model
*/
prompt?: string;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2;
}[];
}
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
} | string | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse;
interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast {
inputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input;
postProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output;
}
interface Ai_Cf_Meta_Llama_Guard_3_8B_Input {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender must alternate between 'user' and 'assistant'.
*/
role: "user" | "assistant";
/**
* The content of the message as a string.
*/
content: string;
}[];
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Dictate the output format of the generated response.
*/
response_format?: {
/**
* Set to json_object to process and output generated text as JSON.
*/
type?: string;
};
}
interface Ai_Cf_Meta_Llama_Guard_3_8B_Output {
response?: string | {
/**
* Whether the conversation is safe or not.
*/
safe?: boolean;
/**
* A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe.
*/
categories?: string[];
};
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
}
declare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B {
inputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input;
postProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output;
}
interface Ai_Cf_Baai_Bge_Reranker_Base_Input {
/**
* A query you wish to perform against the provided contexts.
*/
/**
* Number of returned results starting with the best score.
*/
top_k?: number;
/**
* List of provided contexts. Note that the index in this array is important, as the response will refer to it.
*/
contexts: {
/**
* One of the provided context content
*/
text?: string;
}[];
}
interface Ai_Cf_Baai_Bge_Reranker_Base_Output {
response?: {
/**
* Index of the context in the request
*/
id?: number;
/**
* Score of the context under the index.
*/
score?: number;
}[];
}
declare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base {
inputs: Ai_Cf_Baai_Bge_Reranker_Base_Input;
postProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output;
}
type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input = Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages;
interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
/**
* The content of the message as a string.
*/
content: string;
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
};
declare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct {
inputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input;
postProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output;
}
type Ai_Cf_Qwen_Qwq_32B_Input = Ai_Cf_Qwen_Qwq_32B_Prompt | Ai_Cf_Qwen_Qwq_32B_Messages;
interface Ai_Cf_Qwen_Qwq_32B_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* JSON schema that should be fulfilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwq_32B_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
/**
* The tool call id. If you don't know what to put here you can fall back to 000000001
*/
tool_call_id?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[] | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
};
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
type Ai_Cf_Qwen_Qwq_32B_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
};
declare abstract class Base_Ai_Cf_Qwen_Qwq_32B {
inputs: Ai_Cf_Qwen_Qwq_32B_Input;
postProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output;
}
type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input = Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages;
interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* JSON schema that should be fulfilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
/**
* The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001
*/
tool_call_id?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[] | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
};
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
};
declare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct {
inputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input;
postProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output;
}
type Ai_Cf_Google_Gemma_3_12B_It_Input = Ai_Cf_Google_Gemma_3_12B_It_Prompt | Ai_Cf_Google_Gemma_3_12B_It_Messages;
interface Ai_Cf_Google_Gemma_3_12B_It_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Google_Gemma_3_12B_It_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
type Ai_Cf_Google_Gemma_3_12B_It_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
/**
* The name of the tool to be called
*/
name?: string;
}[];
};
declare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It {
inputs: Ai_Cf_Google_Gemma_3_12B_It_Input;
postProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output;
}
type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input = Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch;
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* JSON schema that should be fulfilled for the response.
*/
guided_json?: object;
response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
/**
* The tool call id. If you don't know what to put here you can fall back to 000000001
*/
tool_call_id?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[] | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
};
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode;
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch {
requests: (Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner)[];
}
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* JSON schema that should be fulfilled for the response.
*/
guided_json?: object;
response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role?: string;
/**
* The tool call id. If you don't know what to put here you can fall back to 000000001
*/
tool_call_id?: string;
content?: string | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
}[] | {
/**
* Type of the content provided
*/
type?: string;
text?: string;
image_url?: {
/**
* image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted
*/
url?: string;
};
};
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode;
/**
* JSON schema that should be fufilled for the response.
*/
guided_json?: object;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = {
/**
* The generated text response from the model
*/
response: string;
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* An array of tool calls requests made during the response generation
*/
tool_calls?: {
/**
* The tool call id.
*/
id?: string;
/**
* Specifies the type of tool (e.g., 'function').
*/
type?: string;
/**
* Details of the function tool.
*/
function?: {
/**
* The name of the tool to be called
*/
name?: string;
/**
* The arguments passed to be passed to the tool call request
*/
arguments?: object;
};
}[];
};
declare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct {
inputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input;
postProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output;
}
type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch;
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
content: string | {
/**
* Type of the content (text)
*/
type?: string;
/**
* Text content
*/
text?: string;
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch {
requests: (Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1)[];
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
content: string | {
/**
* Type of the content (text)
*/
type?: string;
/**
* Text content
*/
text?: string;
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response | string | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse;
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response {
/**
* Unique identifier for the completion
*/
id?: string;
/**
* Object type identifier
*/
object?: "chat.completion";
/**
* Unix timestamp of when the completion was created
*/
created?: number;
/**
* Model used for the completion
*/
model?: string;
/**
* List of completion choices
*/
choices?: {
/**
* Index of the choice in the list
*/
index?: number;
/**
* The message generated by the model
*/
message?: {
/**
* Role of the message author
*/
role: string;
/**
* The content of the message
*/
content: string;
/**
* Internal reasoning content (if available)
*/
reasoning_content?: string;
/**
* Tool calls made by the assistant
*/
tool_calls?: {
/**
* Unique identifier for the tool call
*/
id: string;
/**
* Type of tool call
*/
type: "function";
function: {
/**
* Name of the function to call
*/
name: string;
/**
* JSON string of arguments for the function
*/
arguments: string;
};
}[];
};
/**
* Reason why the model stopped generating
*/
finish_reason?: string;
/**
* Stop reason (may be null)
*/
stop_reason?: string | null;
/**
* Log probabilities (if requested)
*/
logprobs?: {} | null;
}[];
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* Log probabilities for the prompt (if requested)
*/
prompt_logprobs?: {} | null;
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response {
/**
* Unique identifier for the completion
*/
id?: string;
/**
* Object type identifier
*/
object?: "text_completion";
/**
* Unix timestamp of when the completion was created
*/
created?: number;
/**
* Model used for the completion
*/
model?: string;
/**
* List of completion choices
*/
choices?: {
/**
* Index of the choice in the list
*/
index: number;
/**
* The generated text completion
*/
text: string;
/**
* Reason why the model stopped generating
*/
finish_reason: string;
/**
* Stop reason (may be null)
*/
stop_reason?: string | null;
/**
* Log probabilities (if requested)
*/
logprobs?: {} | null;
/**
* Log probabilities for the prompt (if requested)
*/
prompt_logprobs?: {} | null;
}[];
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
}
interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 {
inputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input;
postProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output;
}
interface Ai_Cf_Deepgram_Nova_3_Input {
audio: {
body: object;
contentType: string;
};
/**
* Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param.
*/
custom_topic_mode?: "extended" | "strict";
/**
* Custom topics you want the model to detect within your input audio or text if present Submit up to 100
*/
custom_topic?: string;
/**
* Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param
*/
custom_intent_mode?: "extended" | "strict";
/**
* Custom intents you want the model to detect within your input audio if present
*/
custom_intent?: string;
/**
* Identifies and extracts key entities from content in submitted audio
*/
detect_entities?: boolean;
/**
* Identifies the dominant language spoken in submitted audio
*/
detect_language?: boolean;
/**
* Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0
*/
diarize?: boolean;
/**
* Identify and extract key entities from content in submitted audio
*/
dictation?: boolean;
/**
* Specify the expected encoding of your submitted audio
*/
encoding?: "linear16" | "flac" | "mulaw" | "amr-nb" | "amr-wb" | "opus" | "speex" | "g729";
/**
* Arbitrary key-value pairs that are attached to the API response for usage in downstream processing
*/
extra?: string;
/**
* Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um'
*/
filler_words?: boolean;
/**
* Key term prompting can boost or suppress specialized terminology and brands.
*/
keyterm?: string;
/**
* Keywords can boost or suppress specialized terminology and brands.
*/
keywords?: string;
/**
* The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available.
*/
language?: string;
/**
* Spoken measurements will be converted to their corresponding abbreviations.
*/
measurements?: boolean;
/**
* Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip.
*/
mip_opt_out?: boolean;
/**
* Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio
*/
mode?: "general" | "medical" | "finance";
/**
* Transcribe each audio channel independently.
*/
multichannel?: boolean;
/**
* Numerals converts numbers from written format to numerical format.
*/
numerals?: boolean;
/**
* Splits audio into paragraphs to improve transcript readability.
*/
paragraphs?: boolean;
/**
* Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely.
*/
profanity_filter?: boolean;
/**
* Add punctuation and capitalization to the transcript.
*/
punctuate?: boolean;
/**
* Redaction removes sensitive information from your transcripts.
*/
redact?: string;
/**
* Search for terms or phrases in submitted audio and replaces them.
*/
replace?: string;
/**
* Search for terms or phrases in submitted audio.
*/
search?: string;
/**
* Recognizes the sentiment throughout a transcript or text.
*/
sentiment?: boolean;
/**
* Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability.
*/
smart_format?: boolean;
/**
* Detect topics throughout a transcript or text.
*/
topics?: boolean;
/**
* Segments speech into meaningful semantic units.
*/
utterances?: boolean;
/**
* Seconds to wait before detecting a pause between words in submitted audio.
*/
utt_split?: number;
/**
* The number of channels in the submitted audio
*/
channels?: number;
/**
* Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets.
*/
interim_results?: boolean;
/**
* Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing
*/
endpointing?: string;
/**
* Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets.
*/
vad_events?: boolean;
/**
* Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets.
*/
utterance_end_ms?: boolean;
}
interface Ai_Cf_Deepgram_Nova_3_Output {
results?: {
channels?: {
alternatives?: {
confidence?: number;
transcript?: string;
words?: {
confidence?: number;
end?: number;
start?: number;
word?: string;
}[];
}[];
}[];
summary?: {
result?: string;
short?: string;
};
sentiments?: {
segments?: {
text?: string;
start_word?: number;
end_word?: number;
sentiment?: string;
sentiment_score?: number;
}[];
average?: {
sentiment?: string;
sentiment_score?: number;
};
};
};
}
declare abstract class Base_Ai_Cf_Deepgram_Nova_3 {
inputs: Ai_Cf_Deepgram_Nova_3_Input;
postProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output;
}
interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input {
queries?: string | string[];
/**
* Optional instruction for the task
*/
instruction?: string;
documents?: string | string[];
text?: string | string[];
}
interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output {
data?: number[][];
shape?: number[];
}
declare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B {
inputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input;
postProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output;
}
type Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input = {
/**
* readable stream with audio data and content-type specified for that data
*/
audio: {
body: object;
contentType: string;
};
/**
* type of data PCM data that's sent to the inference server as raw array
*/
dtype?: "uint8" | "float32" | "float64";
} | {
/**
* base64 encoded audio data
*/
audio: string;
/**
* type of data PCM data that's sent to the inference server as raw array
*/
dtype?: "uint8" | "float32" | "float64";
};
interface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output {
/**
* if true, end-of-turn was detected
*/
is_complete?: boolean;
/**
* probability of the end-of-turn detection
*/
probability?: number;
}
declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 {
inputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input;
postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output;
}
declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B {
inputs: XOR<ResponsesInput, ChatCompletionsMessagesInput>;
postProcessedOutputs: XOR<ResponsesOutput, ChatCompletionsOutput>;
}
declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B {
inputs: XOR<ResponsesInput, ChatCompletionsMessagesInput>;
postProcessedOutputs: XOR<ResponsesOutput, ChatCompletionsOutput>;
}
interface Ai_Cf_Leonardo_Phoenix_1_0_Input {
/**
* A text description of the image you want to generate.
*/
prompt: string;
/**
* Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt
*/
guidance?: number;
/**
* Random seed for reproducibility of the image generation
*/
seed?: number;
/**
* The height of the generated image in pixels
*/
height?: number;
/**
* The width of the generated image in pixels
*/
width?: number;
/**
* The number of diffusion steps; higher values can improve quality but take longer
*/
num_steps?: number;
/**
* Specify what to exclude from the generated images
*/
negative_prompt?: string;
}
/**
* The generated image in JPEG format
*/
type Ai_Cf_Leonardo_Phoenix_1_0_Output = string;
declare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 {
inputs: Ai_Cf_Leonardo_Phoenix_1_0_Input;
postProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output;
}
interface Ai_Cf_Leonardo_Lucid_Origin_Input {
/**
* A text description of the image you want to generate.
*/
prompt: string;
/**
* Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt
*/
guidance?: number;
/**
* Random seed for reproducibility of the image generation
*/
seed?: number;
/**
* The height of the generated image in pixels
*/
height?: number;
/**
* The width of the generated image in pixels
*/
width?: number;
/**
* The number of diffusion steps; higher values can improve quality but take longer
*/
num_steps?: number;
/**
* The number of diffusion steps; higher values can improve quality but take longer
*/
steps?: number;
}
interface Ai_Cf_Leonardo_Lucid_Origin_Output {
/**
* The generated image in Base64 format.
*/
image?: string;
}
declare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin {
inputs: Ai_Cf_Leonardo_Lucid_Origin_Input;
postProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output;
}
interface Ai_Cf_Deepgram_Aura_1_Input {
/**
* Speaker used to produce the audio.
*/
speaker?: "angus" | "asteria" | "arcas" | "orion" | "orpheus" | "athena" | "luna" | "zeus" | "perseus" | "helios" | "hera" | "stella";
/**
* Encoding of the output audio.
*/
encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac";
/**
* Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..
*/
container?: "none" | "wav" | "ogg";
/**
* The text content to be converted to speech
*/
text: string;
/**
* Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable
*/
sample_rate?: number;
/**
* The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.
*/
bit_rate?: number;
}
/**
* The generated audio in MP3 format
*/
type Ai_Cf_Deepgram_Aura_1_Output = string;
declare abstract class Base_Ai_Cf_Deepgram_Aura_1 {
inputs: Ai_Cf_Deepgram_Aura_1_Input;
postProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output;
}
interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input {
/**
* Input text to translate. Can be a single string or a list of strings.
*/
text: string | string[];
/**
* Target langauge to translate to
*/
target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva";
}
interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output {
/**
* Translated texts
*/
translations: string[];
}
declare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B {
inputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input;
postProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output;
}
type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch;
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
content: string | {
/**
* Type of the content (text)
*/
type?: string;
/**
* Text content
*/
text?: string;
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch {
requests: (Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1)[];
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 {
/**
* The input text prompt for the model to generate a response.
*/
prompt: string;
/**
* Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.
*/
lora?: string;
response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 {
/**
* An array of message objects representing the conversation history.
*/
messages: {
/**
* The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
*/
role: string;
content: string | {
/**
* Type of the content (text)
*/
type?: string;
/**
* Text content
*/
text?: string;
}[];
}[];
functions?: {
name: string;
code: string;
}[];
/**
* A list of tools available for the assistant to use.
*/
tools?: ({
/**
* The name of the tool. More descriptive the better.
*/
name: string;
/**
* A brief description of what the tool does.
*/
description: string;
/**
* Schema defining the parameters accepted by the tool.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
} | {
/**
* Specifies the type of tool (e.g., 'function').
*/
type: string;
/**
* Details of the function tool.
*/
function: {
/**
* The name of the function.
*/
name: string;
/**
* A brief description of what the function does.
*/
description: string;
/**
* Schema defining the parameters accepted by the function.
*/
parameters: {
/**
* The type of the parameters object (usually 'object').
*/
type: string;
/**
* List of required parameter names.
*/
required?: string[];
/**
* Definitions of each parameter.
*/
properties: {
[k: string]: {
/**
* The data type of the parameter.
*/
type: string;
/**
* A description of the expected parameter.
*/
description: string;
};
};
};
};
})[];
response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3;
/**
* If true, a chat template is not applied and you must adhere to the specific model's expected formatting.
*/
raw?: boolean;
/**
* If true, the response will be streamed back incrementally using SSE, Server Sent Events.
*/
stream?: boolean;
/**
* The maximum number of tokens to generate in the response.
*/
max_tokens?: number;
/**
* Controls the randomness of the output; higher values produce more random results.
*/
temperature?: number;
/**
* Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.
*/
top_p?: number;
/**
* Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.
*/
top_k?: number;
/**
* Random seed for reproducibility of the generation.
*/
seed?: number;
/**
* Penalty for repeated tokens; higher values discourage repetition.
*/
repetition_penalty?: number;
/**
* Decreases the likelihood of the model repeating the same lines verbatim.
*/
frequency_penalty?: number;
/**
* Increases the likelihood of the model introducing new topics.
*/
presence_penalty?: number;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 {
type?: "json_object" | "json_schema";
json_schema?: unknown;
}
type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response | string | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse;
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response {
/**
* Unique identifier for the completion
*/
id?: string;
/**
* Object type identifier
*/
object?: "chat.completion";
/**
* Unix timestamp of when the completion was created
*/
created?: number;
/**
* Model used for the completion
*/
model?: string;
/**
* List of completion choices
*/
choices?: {
/**
* Index of the choice in the list
*/
index?: number;
/**
* The message generated by the model
*/
message?: {
/**
* Role of the message author
*/
role: string;
/**
* The content of the message
*/
content: string;
/**
* Internal reasoning content (if available)
*/
reasoning_content?: string;
/**
* Tool calls made by the assistant
*/
tool_calls?: {
/**
* Unique identifier for the tool call
*/
id: string;
/**
* Type of tool call
*/
type: "function";
function: {
/**
* Name of the function to call
*/
name: string;
/**
* JSON string of arguments for the function
*/
arguments: string;
};
}[];
};
/**
* Reason why the model stopped generating
*/
finish_reason?: string;
/**
* Stop reason (may be null)
*/
stop_reason?: string | null;
/**
* Log probabilities (if requested)
*/
logprobs?: {} | null;
}[];
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
/**
* Log probabilities for the prompt (if requested)
*/
prompt_logprobs?: {} | null;
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response {
/**
* Unique identifier for the completion
*/
id?: string;
/**
* Object type identifier
*/
object?: "text_completion";
/**
* Unix timestamp of when the completion was created
*/
created?: number;
/**
* Model used for the completion
*/
model?: string;
/**
* List of completion choices
*/
choices?: {
/**
* Index of the choice in the list
*/
index: number;
/**
* The generated text completion
*/
text: string;
/**
* Reason why the model stopped generating
*/
finish_reason: string;
/**
* Stop reason (may be null)
*/
stop_reason?: string | null;
/**
* Log probabilities (if requested)
*/
logprobs?: {} | null;
/**
* Log probabilities for the prompt (if requested)
*/
prompt_logprobs?: {} | null;
}[];
/**
* Usage statistics for the inference request
*/
usage?: {
/**
* Total number of tokens in input
*/
prompt_tokens?: number;
/**
* Total number of tokens in output
*/
completion_tokens?: number;
/**
* Total number of input and output tokens
*/
total_tokens?: number;
};
}
interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse {
/**
* The async request id that can be used to obtain the results.
*/
request_id?: string;
}
declare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It {
inputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input;
postProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output;
}
interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input {
/**
* Input text to embed. Can be a single string or a list of strings.
*/
text: string | string[];
}
interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output {
/**
* Embedding vectors, where each vector is a list of floats.
*/
data: number[][];
/**
* Shape of the embedding data as [number_of_embeddings, embedding_dimension].
*
* @minItems 2
* @maxItems 2
*/
shape: [
number,
number
];
}
declare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B {
inputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input;
postProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output;
}
interface Ai_Cf_Deepgram_Flux_Input {
/**
* Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM.
*/
encoding: "linear16";
/**
* Sample rate of the audio stream in Hz.
*/
sample_rate: string;
/**
* End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9.
*/
eager_eot_threshold?: string;
/**
* End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9.
*/
eot_threshold?: string;
/**
* A turn will be finished when this much time has passed after speech, regardless of EOT confidence.
*/
eot_timeout_ms?: string;
/**
* Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms.
*/
keyterm?: string;
/**
* Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip
*/
mip_opt_out?: "true" | "false";
/**
* Label your requests for the purpose of identification during usage reporting
*/
tag?: string;
}
/**
* Output will be returned as websocket messages.
*/
interface Ai_Cf_Deepgram_Flux_Output {
/**
* The unique identifier of the request (uuid)
*/
request_id?: string;
/**
* Starts at 0 and increments for each message the server sends to the client.
*/
sequence_id?: number;
/**
* The type of event being reported.
*/
event?: "Update" | "StartOfTurn" | "EagerEndOfTurn" | "TurnResumed" | "EndOfTurn";
/**
* The index of the current turn
*/
turn_index?: number;
/**
* Start time in seconds of the audio range that was transcribed
*/
audio_window_start?: number;
/**
* End time in seconds of the audio range that was transcribed
*/
audio_window_end?: number;
/**
* Text that was said over the course of the current turn
*/
transcript?: string;
/**
* The words in the transcript
*/
words?: {
/**
* The individual punctuated, properly-cased word from the transcript
*/
word: string;
/**
* Confidence that this word was transcribed correctly
*/
confidence: number;
}[];
/**
* Confidence that no more speech is coming in this turn
*/
end_of_turn_confidence?: number;
}
declare abstract class Base_Ai_Cf_Deepgram_Flux {
inputs: Ai_Cf_Deepgram_Flux_Input;
postProcessedOutputs: Ai_Cf_Deepgram_Flux_Output;
}
interface Ai_Cf_Deepgram_Aura_2_En_Input {
/**
* Speaker used to produce the audio.
*/
speaker?: "amalthea" | "andromeda" | "apollo" | "arcas" | "aries" | "asteria" | "athena" | "atlas" | "aurora" | "callista" | "cora" | "cordelia" | "delia" | "draco" | "electra" | "harmonia" | "helena" | "hera" | "hermes" | "hyperion" | "iris" | "janus" | "juno" | "jupiter" | "luna" | "mars" | "minerva" | "neptune" | "odysseus" | "ophelia" | "orion" | "orpheus" | "pandora" | "phoebe" | "pluto" | "saturn" | "thalia" | "theia" | "vesta" | "zeus";
/**
* Encoding of the output audio.
*/
encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac";
/**
* Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..
*/
container?: "none" | "wav" | "ogg";
/**
* The text content to be converted to speech
*/
text: string;
/**
* Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable
*/
sample_rate?: number;
/**
* The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.
*/
bit_rate?: number;
}
/**
* The generated audio in MP3 format
*/
type Ai_Cf_Deepgram_Aura_2_En_Output = string;
declare abstract class Base_Ai_Cf_Deepgram_Aura_2_En {
inputs: Ai_Cf_Deepgram_Aura_2_En_Input;
postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output;
}
interface Ai_Cf_Deepgram_Aura_2_Es_Input {
/**
* Speaker used to produce the audio.
*/
speaker?: "sirio" | "nestor" | "carina" | "celeste" | "alvaro" | "diana" | "aquila" | "selena" | "estrella" | "javier";
/**
* Encoding of the output audio.
*/
encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac";
/**
* Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..
*/
container?: "none" | "wav" | "ogg";
/**
* The text content to be converted to speech
*/
text: string;
/**
* Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable
*/
sample_rate?: number;
/**
* The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.
*/
bit_rate?: number;
}
/**
* The generated audio in MP3 format
*/
type Ai_Cf_Deepgram_Aura_2_Es_Output = string;
declare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es {
inputs: Ai_Cf_Deepgram_Aura_2_Es_Input;
postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output;
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input {
multipart: {
body?: object;
contentType?: string;
};
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output {
/**
* Generated image as Base64 string.
*/
image?: string;
}
declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev {
inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input;
postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output;
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input {
multipart: {
body?: object;
contentType?: string;
};
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output {
/**
* Generated image as Base64 string.
*/
image?: string;
}
declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B {
inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input;
postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output;
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input {
multipart: {
body?: object;
contentType?: string;
};
}
interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output {
/**
* Generated image as Base64 string.
*/
image?: string;
}
declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B {
inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input;
postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output;
}
declare abstract class Base_Ai_Cf_Zai_Org_Glm_4_7_Flash {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_6 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
interface AiModels {
"@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification;
"@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage;
"@cf/runwayml/stable-diffusion-v1-5-inpainting": BaseAiTextToImage;
"@cf/runwayml/stable-diffusion-v1-5-img2img": BaseAiTextToImage;
"@cf/lykon/dreamshaper-8-lcm": BaseAiTextToImage;
"@cf/bytedance/stable-diffusion-xl-lightning": BaseAiTextToImage;
"@cf/myshell-ai/melotts": BaseAiTextToSpeech;
"@cf/google/embeddinggemma-300m": BaseAiTextEmbeddings;
"@cf/microsoft/resnet-50": BaseAiImageClassification;
"@cf/meta/llama-2-7b-chat-int8": BaseAiTextGeneration;
"@cf/mistral/mistral-7b-instruct-v0.1": BaseAiTextGeneration;
"@cf/meta/llama-2-7b-chat-fp16": BaseAiTextGeneration;
"@hf/thebloke/llama-2-13b-chat-awq": BaseAiTextGeneration;
"@hf/thebloke/mistral-7b-instruct-v0.1-awq": BaseAiTextGeneration;
"@hf/thebloke/zephyr-7b-beta-awq": BaseAiTextGeneration;
"@hf/thebloke/openhermes-2.5-mistral-7b-awq": BaseAiTextGeneration;
"@hf/thebloke/neural-chat-7b-v3-1-awq": BaseAiTextGeneration;
"@hf/thebloke/deepseek-coder-6.7b-base-awq": BaseAiTextGeneration;
"@hf/thebloke/deepseek-coder-6.7b-instruct-awq": BaseAiTextGeneration;
"@cf/deepseek-ai/deepseek-math-7b-instruct": BaseAiTextGeneration;
"@cf/defog/sqlcoder-7b-2": BaseAiTextGeneration;
"@cf/openchat/openchat-3.5-0106": BaseAiTextGeneration;
"@cf/tiiuae/falcon-7b-instruct": BaseAiTextGeneration;
"@cf/thebloke/discolm-german-7b-v1-awq": BaseAiTextGeneration;
"@cf/qwen/qwen1.5-0.5b-chat": BaseAiTextGeneration;
"@cf/qwen/qwen1.5-7b-chat-awq": BaseAiTextGeneration;
"@cf/qwen/qwen1.5-14b-chat-awq": BaseAiTextGeneration;
"@cf/tinyllama/tinyllama-1.1b-chat-v1.0": BaseAiTextGeneration;
"@cf/microsoft/phi-2": BaseAiTextGeneration;
"@cf/qwen/qwen1.5-1.8b-chat": BaseAiTextGeneration;
"@cf/mistral/mistral-7b-instruct-v0.2-lora": BaseAiTextGeneration;
"@hf/nousresearch/hermes-2-pro-mistral-7b": BaseAiTextGeneration;
"@hf/nexusflow/starling-lm-7b-beta": BaseAiTextGeneration;
"@hf/google/gemma-7b-it": BaseAiTextGeneration;
"@cf/meta-llama/llama-2-7b-chat-hf-lora": BaseAiTextGeneration;
"@cf/google/gemma-2b-it-lora": BaseAiTextGeneration;
"@cf/google/gemma-7b-it-lora": BaseAiTextGeneration;
"@hf/mistral/mistral-7b-instruct-v0.2": BaseAiTextGeneration;
"@cf/meta/llama-3-8b-instruct": BaseAiTextGeneration;
"@cf/fblgit/una-cybertron-7b-v2-bf16": BaseAiTextGeneration;
"@cf/meta/llama-3-8b-instruct-awq": BaseAiTextGeneration;
"@cf/meta/llama-3.1-8b-instruct-fp8": BaseAiTextGeneration;
"@cf/meta/llama-3.1-8b-instruct-awq": BaseAiTextGeneration;
"@cf/meta/llama-3.2-3b-instruct": BaseAiTextGeneration;
"@cf/meta/llama-3.2-1b-instruct": BaseAiTextGeneration;
"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": BaseAiTextGeneration;
"@cf/ibm-granite/granite-4.0-h-micro": BaseAiTextGeneration;
"@cf/facebook/bart-large-cnn": BaseAiSummarization;
"@cf/llava-hf/llava-1.5-7b-hf": BaseAiImageToText;
"@cf/baai/bge-base-en-v1.5": Base_Ai_Cf_Baai_Bge_Base_En_V1_5;
"@cf/openai/whisper": Base_Ai_Cf_Openai_Whisper;
"@cf/meta/m2m100-1.2b": Base_Ai_Cf_Meta_M2M100_1_2B;
"@cf/baai/bge-small-en-v1.5": Base_Ai_Cf_Baai_Bge_Small_En_V1_5;
"@cf/baai/bge-large-en-v1.5": Base_Ai_Cf_Baai_Bge_Large_En_V1_5;
"@cf/unum/uform-gen2-qwen-500m": Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M;
"@cf/openai/whisper-tiny-en": Base_Ai_Cf_Openai_Whisper_Tiny_En;
"@cf/openai/whisper-large-v3-turbo": Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo;
"@cf/baai/bge-m3": Base_Ai_Cf_Baai_Bge_M3;
"@cf/black-forest-labs/flux-1-schnell": Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell;
"@cf/meta/llama-3.2-11b-vision-instruct": Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct;
"@cf/meta/llama-3.3-70b-instruct-fp8-fast": Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast;
"@cf/meta/llama-guard-3-8b": Base_Ai_Cf_Meta_Llama_Guard_3_8B;
"@cf/baai/bge-reranker-base": Base_Ai_Cf_Baai_Bge_Reranker_Base;
"@cf/qwen/qwen2.5-coder-32b-instruct": Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct;
"@cf/qwen/qwq-32b": Base_Ai_Cf_Qwen_Qwq_32B;
"@cf/mistralai/mistral-small-3.1-24b-instruct": Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct;
"@cf/google/gemma-3-12b-it": Base_Ai_Cf_Google_Gemma_3_12B_It;
"@cf/meta/llama-4-scout-17b-16e-instruct": Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct;
"@cf/qwen/qwen3-30b-a3b-fp8": Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8;
"@cf/deepgram/nova-3": Base_Ai_Cf_Deepgram_Nova_3;
"@cf/qwen/qwen3-embedding-0.6b": Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B;
"@cf/pipecat-ai/smart-turn-v2": Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2;
"@cf/openai/gpt-oss-120b": Base_Ai_Cf_Openai_Gpt_Oss_120B;
"@cf/openai/gpt-oss-20b": Base_Ai_Cf_Openai_Gpt_Oss_20B;
"@cf/leonardo/phoenix-1.0": Base_Ai_Cf_Leonardo_Phoenix_1_0;
"@cf/leonardo/lucid-origin": Base_Ai_Cf_Leonardo_Lucid_Origin;
"@cf/deepgram/aura-1": Base_Ai_Cf_Deepgram_Aura_1;
"@cf/ai4bharat/indictrans2-en-indic-1B": Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B;
"@cf/aisingapore/gemma-sea-lion-v4-27b-it": Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It;
"@cf/pfnet/plamo-embedding-1b": Base_Ai_Cf_Pfnet_Plamo_Embedding_1B;
"@cf/deepgram/flux": Base_Ai_Cf_Deepgram_Flux;
"@cf/deepgram/aura-2-en": Base_Ai_Cf_Deepgram_Aura_2_En;
"@cf/deepgram/aura-2-es": Base_Ai_Cf_Deepgram_Aura_2_Es;
"@cf/black-forest-labs/flux-2-dev": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev;
"@cf/black-forest-labs/flux-2-klein-4b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B;
"@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B;
"@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash;
"@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5;
"@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6;
"@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B;
"@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT;
}
type AiOptions = {
/**
* Send requests as an asynchronous batch job, only works for supported models
* https://developers.cloudflare.com/workers-ai/features/batch-api
*/
queueRequest?: boolean;
/**
* Establish websocket connections, only works for supported models
*/
websocket?: boolean;
/**
* Tag your requests to group and view them in Cloudflare dashboard.
*
* Rules:
* Tags must only contain letters, numbers, and the symbols: : - . / @
* Each tag can have maximum 50 characters.
* Maximum 5 tags are allowed each request.
* Duplicate tags will removed.
*/
tags?: string[];
gateway?: GatewayOptions;
returnRawResponse?: boolean;
prefix?: string;
extraHeaders?: object;
signal?: AbortSignal;
};
type AiModelsSearchParams = {
author?: string;
hide_experimental?: boolean;
page?: number;
per_page?: number;
search?: string;
source?: number;
task?: string;
};
type AiModelsSearchObject = {
id: string;
source: number;
name: string;
description: string;
task: {
id: string;
name: string;
description: string;
};
tags: string[];
properties: {
property_id: string;
value: string;
}[];
};
type ChatCompletionsBase = ChatCompletionsMessagesInput;
type ChatCompletionsInput = ChatCompletionsMessagesInput;
interface InferenceUpstreamError extends Error {
}
interface AiInternalError extends Error {
}
type AiModelListType = Record<string, any>;
type AiAsyncBatchResponse = {
request_id: string;
};
declare abstract class Ai<AiModelList extends AiModelListType = AiModels> {
aiGatewayLogId: string | null;
gateway(gatewayId: string): AiGateway;
/**
* @deprecated Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
aiSearch(): AiSearchNamespace;
/**
* @deprecated AutoRAG has been replaced by AI Search.
* Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*
* @param autoragId Instance ID
*/
autorag(autoragId: string): AutoRAG;
// Batch request
run<Name extends keyof AiModelList>(model: Name, inputs: {
requests: AiModelList[Name]['inputs'][];
}, options: AiOptions & {
queueRequest: true;
}): Promise<AiAsyncBatchResponse>;
// Raw response
run<Name extends keyof AiModelList>(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & {
returnRawResponse: true;
}): Promise<Response>;
// WebSocket
run<Name extends keyof AiModelList>(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & {
websocket: true;
}): Promise<Response>;
// Streaming
run<Name extends keyof AiModelList>(model: Name, inputs: AiModelList[Name]['inputs'] & {
stream: true;
}, options?: AiOptions): Promise<ReadableStream>;
// Normal (default) - known model
run<Name extends keyof AiModelList>(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise<AiModelList[Name]['postProcessedOutputs']>;
// Unknown model (fallback).
//
// The `Exclude<..., keyof AiModelList>` constraint forces TypeScript to
// route any model name that is a literal key of `AiModelList` to one of
// the known-model overloads above (so input/output mismatches surface as
// type errors rather than silently falling back to `Record<string, unknown>`).
// Names that aren't in `AiModelList` — e.g. third-party gateway models
// like `"google/nano-banana"` — still hit this overload.
run<Model extends string>(model: Model extends keyof AiModelList ? never : Model, inputs: Record<string, unknown>, options?: AiOptions): Promise<Record<string, unknown>>;
models(params?: AiModelsSearchParams): Promise<AiModelsSearchObject[]>;
toMarkdown(): ToMarkdownService;
toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise<ConversionResponse[]>;
toMarkdown(files: MarkdownDocument, options?: ConversionRequestOptions): Promise<ConversionResponse>;
}
type GatewayRetries = {
maxAttempts?: 1 | 2 | 3 | 4 | 5;
retryDelayMs?: number;
backoff?: 'constant' | 'linear' | 'exponential';
};
type GatewayOptions = {
id: string;
cacheKey?: string;
cacheTtl?: number;
skipCache?: boolean;
metadata?: Record<string, number | string | boolean | null | bigint>;
collectLog?: boolean;
eventId?: string;
requestTimeoutMs?: number;
retries?: GatewayRetries;
};
type UniversalGatewayOptions = Exclude<GatewayOptions, 'id'> & {
/**
** @deprecated
*/
id?: string;
};
type AiGatewayPatchLog = {
score?: number | null;
feedback?: -1 | 1 | null;
metadata?: Record<string, number | string | boolean | null | bigint> | null;
};
type AiGatewayLog = {
id: string;
provider: string;
model: string;
model_type?: string;
path: string;
duration: number;
request_type?: string;
request_content_type?: string;
status_code: number;
response_content_type?: string;
success: boolean;
cached: boolean;
tokens_in?: number;
tokens_out?: number;
metadata?: Record<string, number | string | boolean | null | bigint>;
step?: number;
cost?: number;
custom_cost?: boolean;
request_size: number;
request_head?: string;
request_head_complete: boolean;
response_size: number;
response_head?: string;
response_head_complete: boolean;
created_at: Date;
};
type AIGatewayProviders = 'workers-ai' | 'anthropic' | 'aws-bedrock' | 'azure-openai' | 'google-vertex-ai' | 'huggingface' | 'openai' | 'perplexity-ai' | 'replicate' | 'groq' | 'cohere' | 'google-ai-studio' | 'mistral' | 'grok' | 'openrouter' | 'deepseek' | 'cerebras' | 'cartesia' | 'elevenlabs' | 'adobe-firefly';
type AIGatewayHeaders = {
'cf-aig-metadata': Record<string, number | string | boolean | null | bigint> | string;
'cf-aig-custom-cost': {
per_token_in?: number;
per_token_out?: number;
} | {
total_cost?: number;
} | string;
'cf-aig-cache-ttl': number | string;
'cf-aig-skip-cache': boolean | string;
'cf-aig-cache-key': string;
'cf-aig-event-id': string;
'cf-aig-request-timeout': number | string;
'cf-aig-max-attempts': number | string;
'cf-aig-retry-delay': number | string;
'cf-aig-backoff': string;
'cf-aig-collect-log': boolean | string;
Authorization: string;
'Content-Type': string;
[key: string]: string | number | boolean | object;
};
type AIGatewayUniversalRequest = {
provider: AIGatewayProviders | string; // eslint-disable-line
endpoint: string;
headers: Partial<AIGatewayHeaders>;
query: unknown;
};
interface AiGatewayInternalError extends Error {
}
interface AiGatewayLogNotFound extends Error {
}
declare abstract class AiGateway {
patchLog(logId: string, data: AiGatewayPatchLog): Promise<void>;
getLog(logId: string): Promise<AiGatewayLog>;
run(data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], options?: {
gateway?: UniversalGatewayOptions;
extraHeaders?: object;
signal?: AbortSignal;
}): Promise<Response>;
getUrl(provider?: AIGatewayProviders | string): Promise<string>; // eslint-disable-line
}
// Copyright (c) 2022-2025 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
/**
* Artifacts — Git-compatible file storage on Cloudflare Workers.
*
* Provides programmatic access to create, manage, and fork repositories,
* and to issue and revoke scoped access tokens.
*/
/** Information about a repository. */
interface ArtifactsRepoInfo {
/** Unique repository ID. */
id: string;
/** Repository name. */
name: string;
/** Repository description, or null if not set. */
description: string | null;
/** Default branch name (e.g. "main"). */
defaultBranch: string;
/** ISO 8601 creation timestamp. */
createdAt: string;
/** ISO 8601 last-updated timestamp. */
updatedAt: string;
/** ISO 8601 timestamp of the last push, or null if never pushed. */
lastPushAt: string | null;
/** Fork source (e.g. "github:owner/repo", "artifacts:namespace/repo"), or null if not a fork. */
source: string | null;
/** Whether the repository is read-only. */
readOnly: boolean;
/** HTTPS git remote URL. */
remote: string;
}
/** Result of creating a repository — includes the initial access token. */
interface ArtifactsCreateRepoResult {
/** Unique repository ID. */
id: string;
/** Repository name. */
name: string;
/** Repository description, or null if not set. */
description: string | null;
/** Default branch name. */
defaultBranch: string;
/** HTTPS git remote URL. */
remote: string;
/** Plaintext access token (only returned at creation time). */
token: string;
/** ISO 8601 token expiry timestamp. */
tokenExpiresAt: string;
}
/** Paginated list of repositories. */
interface ArtifactsRepoListResult {
/** Repositories in this page (without the `remote` field). */
repos: Omit<ArtifactsRepoInfo, 'remote'>[];
/** Total number of repositories in the namespace. */
total: number;
/** Cursor for the next page, if there are more results. */
cursor?: string;
}
/** Result of creating an access token. */
interface ArtifactsCreateTokenResult {
/** Unique token ID. */
id: string;
/** Plaintext token (only returned at creation time). */
plaintext: string;
/** Token scope: "read" or "write". */
scope: 'read' | 'write';
/** ISO 8601 token expiry timestamp. */
expiresAt: string;
}
/** Token metadata (no plaintext). */
interface ArtifactsTokenInfo {
/** Unique token ID. */
id: string;
/** Token scope: "read" or "write". */
scope: 'read' | 'write';
/** Token state: "active", "expired", or "revoked". */
state: 'active' | 'expired' | 'revoked';
/** ISO 8601 creation timestamp. */
createdAt: string;
/** ISO 8601 expiry timestamp. */
expiresAt: string;
}
/** Paginated list of tokens for a repository. */
interface ArtifactsTokenListResult {
/** Tokens in this page. */
tokens: ArtifactsTokenInfo[];
/** Total number of tokens for the repository. */
total: number;
}
/**
* Handle for a single repository. Returned by Artifacts.get().
*
* Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs.
*/
interface ArtifactsRepo extends ArtifactsRepoInfo {
/**
* Create an access token for this repo.
* @param scope Token scope: "write" (default) or "read".
* @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000).
* @throws {ArtifactsError} with code `INVALID_TTL` if ttl is out of range.
*/
createToken(scope?: 'write' | 'read', ttl?: number): Promise<ArtifactsCreateTokenResult>;
/** List tokens for this repo (metadata only, no plaintext). */
listTokens(): Promise<ArtifactsTokenListResult>;
/**
* Revoke a token by plaintext or ID.
* @param tokenOrId Plaintext token or token ID.
* @returns true if revoked, false if not found.
* @throws {ArtifactsError} with code `INVALID_INPUT` if tokenOrId is empty.
*/
revokeToken(tokenOrId: string): Promise<boolean>;
// ── Fork ──
/**
* Fork this repo to a new repo.
* @param name Target repository name.
* @param opts Optional: description, readOnly flag, defaultBranchOnly (default true).
* @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid.
* @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists.
* @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if a fork is already running.
*/
fork(name: string, opts?: {
description?: string;
readOnly?: boolean;
defaultBranchOnly?: boolean;
}): Promise<ArtifactsCreateRepoResult>;
}
// ── Error types ──────────────────────────────────────────────────────────────
/**
* Error codes returned by Artifacts binding operations.
*
* Each code maps to a numeric code available on `ArtifactsError.numericCode`.
*/
type ArtifactsErrorCode = 'ALREADY_EXISTS' | 'NOT_FOUND' | 'IMPORT_IN_PROGRESS' | 'FORK_IN_PROGRESS' | 'INVALID_INPUT' | 'INVALID_REPO_NAME' | 'INVALID_TTL' | 'INVALID_URL' | 'REMOTE_AUTH_REQUIRED' | 'UPSTREAM_UNAVAILABLE' | 'MEMORY_LIMIT' | 'INTERNAL_ERROR';
/**
* Error thrown by Artifacts binding operations.
*
* Uses a string `.code` discriminator following the Cloudflare platform
* convention (StreamError, ImagesError, etc.). The `.numericCode` matches
* the REST API `errors[].code` values.
*/
interface ArtifactsError extends Error {
readonly name: 'ArtifactsError';
/** String error code for programmatic matching. */
readonly code: ArtifactsErrorCode;
/** Numeric error code matching the REST API. */
readonly numericCode: number;
}
// ── Binding ──────────────────────────────────────────────────────────────────
/**
* Artifacts binding — namespace-level operations.
*
* Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs.
*/
interface Artifacts {
/**
* Create a new repository with an initial access token.
* @param name Repository name (alphanumeric, dots, hyphens, underscores).
* @param opts Optional: readOnly flag, description, default branch name.
* @returns Repo metadata with initial token.
* @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid.
* @throws {ArtifactsError} with code `ALREADY_EXISTS` if the repo already exists.
*/
create(name: string, opts?: {
readOnly?: boolean;
description?: string;
setDefaultBranch?: string;
}): Promise<ArtifactsCreateRepoResult>;
/**
* Get a handle to an existing repository.
* @param name Repository name.
* @returns Repo handle.
* @throws {ArtifactsError} with code `NOT_FOUND` if the repo does not exist.
* @throws {ArtifactsError} with code `IMPORT_IN_PROGRESS` if the repo is still importing.
* @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if the repo is still forking.
*/
get(name: string): Promise<ArtifactsRepo>;
/**
* Import a repository from an external git remote.
* @param params Source URL and optional branch/depth, plus target name and options.
* @returns Repo metadata with initial token.
* @throws {ArtifactsError} with code `INVALID_REPO_NAME` if the target name is invalid.
* @throws {ArtifactsError} with code `INVALID_INPUT` if the source URL is not valid HTTPS.
* @throws {ArtifactsError} with code `INVALID_URL` if the source URL does not point to a git repository.
* @throws {ArtifactsError} with code `REMOTE_AUTH_REQUIRED` if the remote requires authentication.
* @throws {ArtifactsError} with code `NOT_FOUND` if the remote repository does not exist.
* @throws {ArtifactsError} with code `UPSTREAM_UNAVAILABLE` if the remote cannot be reached.
* @throws {ArtifactsError} with code `MEMORY_LIMIT` if the import exceeds service memory limits.
* @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists.
*/
import(params: {
source: {
url: string;
branch?: string;
depth?: number;
};
target: {
name: string;
opts?: {
description?: string;
readOnly?: boolean;
};
};
}): Promise<ArtifactsCreateRepoResult>;
/**
* List repositories with cursor-based pagination.
* @param opts Optional: limit (1–200, default 50), cursor for next page.
*/
list(opts?: {
limit?: number;
cursor?: string;
}): Promise<ArtifactsRepoListResult>;
/**
* Delete a repository and all associated tokens.
* @param name Repository name.
* @returns true if deleted, false if not found.
* @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid.
*/
delete(name: string): Promise<boolean>;
}
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
interface AutoRAGInternalError extends Error {
}
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
interface AutoRAGNotFoundError extends Error {
}
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
interface AutoRAGUnauthorizedError extends Error {
}
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
interface AutoRAGNameNotSetError extends Error {
}
type ComparisonFilter = {
key: string;
type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
value: string | number | boolean;
};
type CompoundFilter = {
type: 'and' | 'or';
filters: ComparisonFilter[];
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagSearchRequest = {
query: string;
filters?: CompoundFilter | ComparisonFilter;
max_num_results?: number;
ranking_options?: {
ranker?: string;
score_threshold?: number;
};
reranking?: {
enabled?: boolean;
model?: string;
};
rewrite_query?: boolean;
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagAiSearchRequest = AutoRagSearchRequest & {
stream?: boolean;
system_prompt?: string;
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagAiSearchRequestStreaming = Omit<AutoRagAiSearchRequest, 'stream'> & {
stream: true;
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagSearchResponse = {
object: 'vector_store.search_results.page';
search_query: string;
data: {
file_id: string;
filename: string;
score: number;
attributes: Record<string, string | number | boolean | null>;
content: {
type: 'text';
text: string;
}[];
}[];
has_more: boolean;
next_page: string | null;
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagListResponse = {
id: string;
enable: boolean;
type: string;
source: string;
vectorize_name: string;
paused: boolean;
status: string;
}[];
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
type AutoRagAiSearchResponse = AutoRagSearchResponse & {
response: string;
};
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
declare abstract class AutoRAG {
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
list(): Promise<AutoRagListResponse>;
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
search(params: AutoRagSearchRequest): Promise<AutoRagSearchResponse>;
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
aiSearch(params: AutoRagAiSearchRequestStreaming): Promise<Response>;
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
aiSearch(params: AutoRagAiSearchRequest): Promise<AutoRagAiSearchResponse>;
/**
* @deprecated Use the standalone AI Search Workers binding instead.
* See https://developers.cloudflare.com/ai-search/usage/workers-binding/
*/
aiSearch(params: AutoRagAiSearchRequest): Promise<AutoRagAiSearchResponse | Response>;
}
type BrowserRunLifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
type BrowserRunResourceType = 'document' | 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'texttrack' | 'xhr' | 'fetch' | 'prefetch' | 'eventsource' | 'websocket' | 'manifest' | 'signedexchange' | 'ping' | 'cspviolationreport' | 'preflight' | 'other';
/** Options fields shared by all quick actions. */
interface BrowserRunBaseOptions {
/** Adds `<script>` tags into the page with the desired URL or content.
* @see https://pptr.dev/api/puppeteer.frameaddscripttagoptions
*/
addScriptTag?: Array<{
content?: string;
url?: string;
type?: string;
id?: string;
}>;
/** Adds `<link rel="stylesheet">` or `<style>` tags into the page.
* @see https://pptr.dev/api/puppeteer.frameaddstyletagoptions
*/
addStyleTag?: Array<{
content?: string;
url?: string;
}>;
/** Provide credentials for HTTP authentication. @see https://pptr.dev/api/puppeteer.credentials */
authenticate?: {
username: string;
password: string;
};
/** Set cookies before navigating. @see https://pptr.dev/api/puppeteer.cookieparam */
cookies?: Array<{
name: string;
value: string;
url?: string;
domain?: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'Strict' | 'Lax' | 'None';
expires?: number;
priority?: 'Low' | 'Medium' | 'High';
sameParty?: boolean;
sourceScheme?: 'Unset' | 'NonSecure' | 'Secure';
sourcePort?: number;
partitionKey?: string;
}>;
/** Emulate a specific CSS media type (e.g. `"screen"`, `"print"`). */
emulateMediaType?: string;
/** Navigation options. @see https://pptr.dev/api/puppeteer.gotooptions */
gotoOptions?: {
/** Navigation timeout in milliseconds (max 60 000). @default 30000 */
timeout?: number;
/** When to consider navigation complete. @default "domcontentloaded" */
waitUntil?: BrowserRunLifecycleEvent | BrowserRunLifecycleEvent[];
referer?: string;
referrerPolicy?: string;
};
/** Block requests matching these regex patterns. Mutually exclusive with `allowRequestPattern`. */
rejectRequestPattern?: string[];
/** Only allow requests matching these regex patterns. Mutually exclusive with `rejectRequestPattern`. */
allowRequestPattern?: string[];
/** Block requests of these resource types. Mutually exclusive with `allowResourceTypes`. */
rejectResourceTypes?: BrowserRunResourceType[];
/** Only allow requests of these resource types. Mutually exclusive with `rejectResourceTypes`. */
allowResourceTypes?: BrowserRunResourceType[];
/** Additional HTTP headers sent with every request. */
setExtraHTTPHeaders?: Record<string, string>;
/** Whether JavaScript is enabled on the page. */
setJavaScriptEnabled?: boolean;
/** Override the default user agent string.
* @default "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
* */
userAgent?: string;
/** Set the browser viewport size.
* @see https://pptr.dev/api/puppeteer.viewport
* @default {width:1920,height:1080}
* */
viewport?: {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
};
/** Wait for a CSS selector to appear in the page before proceeding.
* @see https://pptr.dev/api/puppeteer.waitforselectoroptions
*/
waitForSelector?: {
selector: string;
hidden?: true;
visible?: true;
/** Timeout in milliseconds. Max 120000 */
timeout?: number;
};
/** Wait for a fixed delay in milliseconds before proceeding. Max 120000 */
waitForTimeout?: number;
/** When true, continue on best-effort when awaited events fail or timeout. */
bestAttempt?: boolean;
/** Maximum duration in milliseconds for the browser action after page load. Max 120000 */
actionTimeout?: number;
/** Cache time to live in seconds (0-86400). Set to 0 to disable.
* @default 5
*/
cacheTTL?: number;
}
/** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/
type BrowserRunCommonOptions = (BrowserRunBaseOptions & {
/** URL to navigate to, e.g. `"https://example.com"`. */
url: string;
}) | (BrowserRunBaseOptions & {
/** Set the HTML content of the page directly. */
html: string;
});
type BrowserRunPuppeteerScreenshotOptions = {
/** @default "png" */
type?: 'png' | 'jpeg' | 'webp';
/** @default "binary" */
encoding?: 'binary' | 'base64';
quality?: number;
fullPage?: boolean;
clip?: {
x: number;
y: number;
width: number;
height: number;
scale?: number;
};
omitBackground?: boolean;
optimizeForSpeed?: boolean;
captureBeyondViewport?: boolean;
fromSurface?: boolean;
};
type BrowserRunScreenshotOptions = BrowserRunCommonOptions & {
/** CSS selector of the element to screenshot. */
selector?: string;
/** When true, scroll the entire page before taking the screenshot. */
scrollPage?: boolean;
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: BrowserRunPuppeteerScreenshotOptions;
};
type BrowserRunPDFOptions = BrowserRunCommonOptions & {
/** @see https://pptr.dev/api/puppeteer.pdfoptions */
pdfOptions?: {
/** @default 1 */
scale?: number;
/** @default false */
displayHeaderFooter?: boolean;
headerTemplate?: string;
footerTemplate?: string;
/** @default false */
printBackground?: boolean;
/** @default false */
landscape?: boolean;
pageRanges?: string;
/** @default "letter" */
format?: 'letter' | 'legal' | 'tabloid' | 'ledger' | 'a0' | 'a1' | 'a2' | 'a3' | 'a4' | 'a5' | 'a6';
width?: string | number;
height?: string | number;
/** @default false */
preferCSSPageSize?: boolean;
margin?: {
top?: string | number;
right?: string | number;
bottom?: string | number;
left?: string | number;
};
/** @default false */
omitBackground?: boolean;
/** @default true */
tagged?: boolean;
/** @default false */
outline?: boolean;
/** @default 30000 */
timeout?: number;
};
};
type BrowserRunScrapeOptions = BrowserRunCommonOptions & {
/** CSS selectors to scrape. At least one element is required. */
elements: Array<{
selector: string;
}>;
};
type BrowserRunLinksOptions = BrowserRunCommonOptions & {
/** When true, only return links that are visible on the page. @default false */
visibleLinksOnly?: boolean;
/** When true, exclude links pointing to external domains. @default false */
excludeExternalLinks?: boolean;
};
type BrowserRunSnapshotOptions = BrowserRunCommonOptions & {
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: Omit<BrowserRunPuppeteerScreenshotOptions, 'encoding'>;
};
interface BrowserRunJsonBaseOptions {
/** Custom AI models to try in order. Max 3. Falls back to next on error. */
custom_ai?: Array<{
/** Model ID in `<provider>/<model_name>` format, e.g. `"workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast"`. */
model: string;
/** Bearer token. Not needed for workers-ai models. */
authorization?: string;
}>;
}
/**
* Options for the `json` quick action.
* At least one of `prompt` or `response_format` must be provided.
*/
type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions & ({
/** Natural-language prompt describing what data to extract. */
prompt: string;
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
response_format?: AiTextGenerationResponseFormat;
} | {
/** Natural-language prompt describing what data to extract. */
prompt?: string;
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
response_format: AiTextGenerationResponseFormat;
});
type BrowserRunContentOptions = BrowserRunCommonOptions;
type BrowserRunMarkdownOptions = BrowserRunCommonOptions;
type BrowserRunResponseMeta = {
/** HTTP status code of the rendered page */
status: number;
/** Page title */
title: string;
};
/** Success response for `content` action. */
type BrowserRunContentSuccessResponse = {
success: true;
/** Extracted HTML content */
result: string;
meta: BrowserRunResponseMeta;
};
/** Success response for `links` action. */
type BrowserRunLinksSuccessResponse = {
success: true;
/** Extracted links */
result: string[];
};
/** Success response for `scrape` action. */
type BrowserRunScrapeSuccessResponse = {
success: true;
result: Array<{
/** The CSS selector used to find elements. */
selector: string;
/** Array of elements matching the selector. */
results: Array<{
/** Outer HTML of the element. */
html: string;
/** Text content of the element. */
text: string;
/** Width of the element in pixels. */
width: number;
/** Height of the element in pixels. */
height: number;
/** Top position of the element relative to the viewport in pixels. */
top: number;
/** Left position of the element relative to the viewport in pixels. */
left: number;
/** Array of HTML attributes on the element. */
attributes: Array<{
/** Attribute name. */
name: string;
/** Attribute value. */
value: string;
}>;
}>;
}>;
};
/** Success response for `snapshot` action. */
type BrowserRunSnapshotSuccessResponse = {
success: true;
result: {
/** HTML content of the page. */
content: string;
/** Base64-encoded screenshot image. */
screenshot: string;
};
meta: BrowserRunResponseMeta;
};
/** Success response for `json` action. */
type BrowserRunJsonSuccessResponse = {
success: true;
/** JSON data extracted from the page using an AI model */
result: Record<string, unknown>;
};
/** Success response for `markdown` action. */
type BrowserRunMarkdownSuccessResponse = {
success: true;
/** Extracted markdown content */
result: string;
};
/** Error response for BrowserRun actions. */
type BrowserRunErrorResponse = {
success: false;
errors: {
message: string;
code?: number;
detail?: string;
path?: string;
}[];
};
/** Error response for BrowserRun `json` action. */
type BrowserRunJsonErrorResponse = BrowserRunErrorResponse & {
/** Raw AI response text for debugging */
rawAiResponse?: string;
};
/**
* Browser Run API binding for automating headless browsers.
* @see https://developers.cloudflare.com/browser-run/
*/
declare abstract class BrowserRun {
/**
* Send a raw HTTP request to the Browser Run API.
* Used by libraries like `@cloudflare/puppeteer` to acquire and connect to a browser instance.
* @see https://developers.cloudflare.com/browser-run/
*/
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
/**
* Take a screenshot of a web page.
* @param action - Must be `'screenshot'`.
* @param options - Screenshot options including viewport, selectors, and image format.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - Binary image data with `Content-Type: image/png`, `image/jpeg`, or `image/webp` (when `encoding: 'binary'`, the default)
* - Data URI string with `Content-Type: text/plain` (when `encoding: 'base64'`)
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'screenshot', options: BrowserRunScreenshotOptions): Promise<Response>;
/**
* Generate a PDF of a web page.
* @param action - Must be `'pdf'`.
* @param options - PDF generation options including page size, margins, and headers/footers.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - Binary PDF data with `Content-Type: application/pdf`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'pdf', options: BrowserRunPDFOptions): Promise<Response>;
/**
* Get the HTML content of a web page.
* @param action - Must be `'content'`.
* @param options - Navigation and page interaction options.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunContentSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'content', options: BrowserRunContentOptions): Promise<Response>;
/**
* Scrape elements from a web page by CSS selector.
* @param action - Must be `'scrape'`.
* @param options - Scrape options with CSS selectors for elements to extract.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunScrapeSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'scrape', options: BrowserRunScrapeOptions): Promise<Response>;
/**
* Extract all links from a web page.
* @param action - Must be `'links'`.
* @param options - Options to filter visible or internal links only.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunLinksSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'links', options: BrowserRunLinksOptions): Promise<Response>;
/**
* Get both the HTML content and a base64-encoded screenshot of a web page.
* @param action - Must be `'snapshot'`.
* @param options - Snapshot options including screenshot settings (encoding is always base64).
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunSnapshotSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'snapshot', options: BrowserRunSnapshotOptions): Promise<Response>;
/**
* Extract structured JSON data from a web page using AI.
* @param action - Must be `'json'`.
* @param options - JSON extraction options with prompt or response_format schema.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunJsonSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
* - HTTP 422 with code `2012` for HTML-to-markdown conversion failures
* - HTTP 422/500 for AI extraction failures (may include `rawAiResponse` field)
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'json', options: BrowserRunJsonOptions): Promise<Response>;
/**
* Convert a web page to Markdown.
* @param action - Must be `'markdown'`.
* @param options - Navigation and page interaction options.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunMarkdownSuccessResponse` JSON with `Content-Type: application/json`
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
* - HTTP 422 with code `2012` for HTML-to-markdown conversion failures
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>;
}
/**
* In addition to the properties you can set in the RequestInit dict
* that you pass as an argument to the Request constructor, you can
* set certain properties of a `cf` object to control how Cloudflare
* features are applied to that new Request.
*
* Note: Currently, these properties cannot be tested in the
* playground.
*/
interface RequestInitCfProperties extends Record<string, unknown> {
cacheEverything?: boolean;
/**
* A request's cache key is what determines if two requests are
* "the same" for caching purposes. If a request has the same cache key
* as some previous request, then we can serve the same cached response for
* both. (e.g. 'some-key')
*
* Only available for Enterprise customers.
*/
cacheKey?: string;
/**
* This allows you to append additional Cache-Tag response headers
* to the origin response without modifications to the origin server.
* This will allow for greater control over the Purge by Cache Tag feature
* utilizing changes only in the Workers process.
*
* Only available for Enterprise customers.
*/
cacheTags?: string[];
/**
* Force response to be cached for a given number of seconds. (e.g. 300)
*/
cacheTtl?: number;
/**
* Force response to be cached for a given number of seconds based on the Origin status code.
* (e.g. { '200-299': 86400, '404': 1, '500-599': 0 })
*/
cacheTtlByStatus?: Record<string, number>;
/** Controls how responses with a `Vary` header are cached for this request. */
vary?: RequestInitCfPropertiesVary;
/**
* Explicit Cache-Control header value to set on the response stored in cache.
* This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400').
*
* Cannot be used together with `cacheTtl` or the `cache` request option (`no-store`/`no-cache`),
* as these are mutually exclusive cache control mechanisms. Setting both will throw a TypeError.
*
* Can be used together with `cacheTtlByStatus`.
*/
cacheControl?: string;
/**
* Whether the response should be eligible for Cache Reserve storage.
*/
cacheReserveEligible?: boolean;
/**
* Whether to respect strong ETags (as opposed to weak ETags) from the origin.
*/
respectStrongEtag?: boolean;
/**
* Whether to strip ETag headers from the origin response before caching.
*/
stripEtags?: boolean;
/**
* Whether to strip Last-Modified headers from the origin response before caching.
*/
stripLastModified?: boolean;
/**
* Whether to enable Cache Deception Armor, which protects against web cache
* deception attacks by verifying the Content-Type matches the URL extension.
*/
cacheDeceptionArmor?: boolean;
/**
* Minimum file size in bytes for a response to be eligible for Cache Reserve storage.
*/
cacheReserveMinimumFileSize?: number;
scrapeShield?: boolean;
apps?: boolean;
/**
* Controls whether an outbound gRPC-web subrequest from this Worker is
* converted to gRPC at the Cloudflare edge.
*
* - `"passthrough"`: forward the subrequest unchanged as gRPC-web (default).
* - `"convert"`: convert the gRPC-web subrequest to gRPC at the edge.
*
* Provides per-request control over the same edge conversion behavior
* gated by the `auto_grpc_convert` compatibility flag.
*/
grpcWeb?: "passthrough" | "convert";
image?: RequestInitCfPropertiesImage;
minify?: RequestInitCfPropertiesImageMinify;
mirage?: boolean;
polish?: "lossy" | "lossless" | "off";
r2?: RequestInitCfPropertiesR2;
/**
* Redirects the request to an alternate origin server. You can use this,
* for example, to implement load balancing across several origins.
* (e.g.us-east.example.com)
*
* Note - For security reasons, the hostname set in resolveOverride must
* be proxied on the same Cloudflare zone of the incoming request.
* Otherwise, the setting is ignored. CNAME hosts are allowed, so to
* resolve to a host under a different domain or a DNS only domain first
* declare a CNAME record within your own zone’s DNS mapping to the
* external hostname, set proxy on Cloudflare, then set resolveOverride
* to point to that CNAME record.
*/
resolveOverride?: string;
}
/**
* Controls how Workers Standard Vary handles a request header listed by an
* origin `Vary` response header:
*
* - `"normalize"`: normalize the request header value before it is used in the
* cache variance key.
* - `"passthrough"`: use the raw request header value in the cache variance
* key.
* - `"bypass"`: bypass cache when the header appears in the origin `Vary`
* response header.
*/
type RequestInitCfPropertiesVaryAction = "normalize" | "passthrough" | "bypass";
/** Configuration for Workers Standard Vary support. */
interface RequestInitCfPropertiesVary {
/** The fallback action for varied request headers not listed in `headers`. */
default: RequestInitCfPropertiesVaryHeader;
/**
* Lowercase request header names and their Vary configuration.
*
* The `accept` header can include `media_types`, the `accept-language`
* header can include `languages`, and other headers support only `action`.
*/
headers?: RequestInitCfPropertiesVaryHeaders;
}
/** Common Vary behavior for a single request header. */
interface RequestInitCfPropertiesVaryHeader {
/** How this request header contributes to cache variance. */
action: RequestInitCfPropertiesVaryAction;
}
/** Vary behavior for the `accept` request header. */
interface RequestInitCfPropertiesVaryAcceptHeader extends RequestInitCfPropertiesVaryHeader {
/**
* Media types to keep when normalizing the `Accept` request header.
*
* Named `media_types` to match the serialized `cf.vary` configuration.
*/
media_types?: string[];
}
/** Vary behavior for the `accept-language` request header. */
interface RequestInitCfPropertiesVaryAcceptLanguageHeader extends RequestInitCfPropertiesVaryHeader {
/**
* Language tags to keep when normalizing the `Accept-Language` request
* header.
*/
languages?: string[];
}
/**
* Lowercase request header names and their Vary behavior.
*
* The index signature allows arbitrary custom request headers beyond the
* well-known `accept` and `accept-language` specializations.
*/
interface RequestInitCfPropertiesVaryHeaders {
accept?: RequestInitCfPropertiesVaryAcceptHeader;
"accept-language"?: RequestInitCfPropertiesVaryAcceptLanguageHeader;
[header: string]: RequestInitCfPropertiesVaryHeader | RequestInitCfPropertiesVaryAcceptHeader | RequestInitCfPropertiesVaryAcceptLanguageHeader | undefined;
}
interface BasicImageTransformations {
/**
* Maximum width in image pixels. The value must be an integer.
*/
width?: number;
/**
* Maximum height in image pixels. The value must be an integer.
*/
height?: number;
/**
* When cropping with fit: "cover", this defines the side or point that should
* be left uncropped. The value is either a string
* "left", "right", "top", "bottom", "auto", or "center" (the default),
* or an object {x, y} containing focal point coordinates in the original
* image expressed as fractions ranging from 0.0 (top or left) to 1.0
* (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will
* crop bottom or left and right sides as necessary, but won’t crop anything
* from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to
* preserve as much as possible around a point at 20% of the height of the
* source image.
*/
gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates;
/**
* Specifies how closely the image is cropped toward detected faces when combined
* with the gravity=face option. Accepts a valid range between 0.0 (includes as much
* of the background as possible) and 1.0 (crops the image as closely to the face as
* possible). The default is 0.
*/
zoom?: number;
/**
* Resizing mode as a string. It affects interpretation of width and height
* options:
* - scale-down: Similar to contain, but the image is never enlarged. If
* the image is larger than given width or height, it will be resized.
* Otherwise its original size will be kept.
* - scale-up: Similar to contain, but the image is never shrunk. If the
* image is smaller than the given width or height, it will be resized.
* Otherwise its original size will be kept.
* - contain: Resizes to maximum size that fits within the given width and
* height. If only a single dimension is given (e.g. only width), the
* image will be shrunk or enlarged to exactly match that dimension.
* Aspect ratio is always preserved.
* - cover: Resizes (shrinks or enlarges) to fill the entire area of width
* and height. If the image has an aspect ratio different from the ratio
* of width and height, it will be cropped to fit.
* - crop: The image will be shrunk and cropped to fit within the area
* specified by width and height. The image will not be enlarged. For images
* smaller than the given dimensions it's the same as scale-down. For
* images larger than the given dimensions, it's the same as cover.
* See also trim.
* - pad: Resizes to the maximum size that fits within the given width and
* height, and then fills the remaining area with a background color
* (white by default). Use of this mode is not recommended, as the same
* effect can be more efficiently achieved with the contain mode and the
* CSS object-fit: contain property.
* - squeeze: Stretches and deforms to the width and height given, even if it
* breaks aspect ratio
*/
fit?: "scale-down" | "scale-up" | "contain" | "cover" | "crop" | "pad" | "squeeze";
/**
* Allows you to trim your image. Takes dpr into account and is performed before
* resizing or rotation.
*
* It can be used as:
* - left, top, right, bottom - it will specify the number of pixels to cut
* off each side
* - width, height - the width/height you'd like to end up with - can be used
* in combination with the properties above
* - border - this will automatically trim the surroundings of an image based on
* it's color. It consists of three properties:
* - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit)
* - tolerance: difference from color to treat as color
* - keep: the number of pixels of border to keep
*/
trim?: "border" | {
top?: number;
bottom?: number;
left?: number;
right?: number;
width?: number;
height?: number;
border?: boolean | {
color?: string;
tolerance?: number;
keep?: number;
};
};
/**
* Background color to add underneath the image. Applies only to images with
* transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…),
* hsl(…), etc.)
*/
background?: string;
/**
* Flips the images horizontally, vertically, or both. Flipping is applied before
* rotation, so if you apply flip=h,rotate=90 then the image will be flipped
* horizontally, then rotated by 90 degrees.
*/
flip?: 'h' | 'v' | 'hv';
/**
* Number of degrees (90, 180, 270) to rotate the image by. width and height
* options refer to axes after rotation.
*/
rotate?: 0 | 90 | 180 | 270 | 360;
/**
* Strength of sharpening filter to apply to the image. Floating-point
* number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a
* recommended value for downscaled images.
*/
sharpen?: number;
/**
* Radius of a blur filter (approximate gaussian). Maximum supported radius
* is 250.
*/
blur?: number;
/**
* Increase contrast by a factor. A value of 1.0 equals no change, a value of
* 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is
* ignored.
*/
contrast?: number;
/**
* Increase brightness by a factor. A value of 1.0 equals no change, a value
* of 0.5 equals half brightness, and a value of 2.0 equals twice as bright.
* 0 is ignored.
*/
brightness?: number;
/**
* Increase exposure by a factor. A value of 1.0 equals no change, a value of
* 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored.
*/
gamma?: number;
/**
* Increase contrast by a factor. A value of 1.0 equals no change, a value of
* 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is
* ignored.
*/
saturation?: number;
/**
* Device Pixel Ratio. Default 1. Multiplier for width/height that makes it
* easier to specify higher-DPI sizes in <img srcset>.
*/
dpr?: number;
/**
* Adds a border around the image. The border is added after resizing. Border
* width takes dpr into account, and can be specified either using a single
* width property, or individually for each side.
*/
border?: {
color: string;
width: number;
} | {
color: string;
top: number;
right: number;
bottom: number;
left: number;
};
/**
* Image segmentation using artificial intelligence models. Sets pixels not
* within selected segment area to transparent e.g "foreground" sets every
* background pixel as transparent.
*/
segment?: "foreground";
/**
* Controls the algorithm used when an image needs to be enlarged. This
* parameter works with any fit mode that upscales, such as `contain`,
* `cover`, and `scale-up`. It has no effect when `fit=scale-down` or when
* the target dimensions are smaller than the source.
* - interpolate: Uses bicubic interpolation, which may reduce image quality.
* This is the default behavior when `upscale` is not specified.
* - generate: Uses AI upscaling to produce sharper, more detailed results
* when enlarging images.
*/
upscale?: "interpolate" | "generate";
}
interface BasicImageTransformationsGravityCoordinates {
x?: number;
y?: number;
mode?: 'remainder' | 'box-center';
}
interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations {
/**
* Absolute URL of the image file to use for the drawing. It can be any of
* the supported file formats. For drawing of watermarks or non-rectangular
* overlays we recommend using PNG or WebP images.
*/
url: string;
/**
* Floating-point number between 0 (transparent) and 1 (opaque).
* For example, opacity: 0.5 makes overlay semitransparent.
*/
opacity?: number;
/**
* - If set to true, the overlay image will be tiled to cover the entire
* area. This is useful for stock-photo-like watermarks.
* - If set to "x", the overlay image will be tiled horizontally only
* (form a line).
* - If set to "y", the overlay image will be tiled vertically only
* (form a line).
*/
repeat?: true | "x" | "y";
/**
* How to combine the foreground and backdrop pixels to create the result
*/
composite?:
/** Foreground drawn on top of backdrop (default) */
'over'
/** Foreground shown only where backdrop is opaque */
| 'in'
/** Foreground drawn on top, but clipped to the backdrop's shape */
| 'atop'
/** Foreground shown only where backdrop is transparent */
| 'out'
/** Foreground and backdrop visible only where the other is not */
| 'xor'
/** Foreground and backdrop channels added (brightening) */
| 'lighter';
/**
* Position of the overlay image relative to a given edge. Each property is
* an offset in pixels. 0 aligns exactly to the edge. For example, left: 10
* positions left side of the overlay 10 pixels from the left edge of the
* image it's drawn over. bottom: 0 aligns bottom of the overlay with bottom
* of the background image.
*
* Setting both left & right, or both top & bottom is an error.
*
* If no position is specified, the image will be centered.
*/
top?: number;
left?: number;
bottom?: number;
right?: number;
}
interface RequestInitCfPropertiesImage extends BasicImageTransformations {
/**
* Quality setting from 1-100 (useful values are in 60-90 range). Lower values
* make images look worse, but load faster. The default is 85. It applies only
* to JPEG and WebP images. It doesn’t have any effect on PNG.
*/
quality?: number | "low" | "medium-low" | "medium-high" | "high";
/**
* Output format to generate. It can be:
* - avif: generate images in AVIF format.
* - webp: generate images in Google WebP format. Set quality to 100 to get
* the WebP-lossless format.
* - json: instead of generating an image, outputs information about the
* image, in JSON format. The JSON object will contain image size
* (before and after resizing), source image’s MIME type, file size, etc.
* - jpeg: generate images in JPEG format.
* - png: generate images in PNG format.
*/
format?: "avif" | "webp" | "json" | "jpeg" | "png" | "baseline-jpeg" | "png-force" | "svg";
/**
* Whether to preserve animation frames from input files. Default is true.
* Setting it to false reduces animations to still images. This setting is
* recommended when enlarging images or processing arbitrary user content,
* because large GIF animations can weigh tens or even hundreds of megabytes.
* It is also useful to set anim:false when using format:"json" to get the
* response quicker without the number of frames.
*/
anim?: boolean;
/**
* What EXIF data should be preserved in the output image. Note that EXIF
* rotation and embedded color profiles are always applied ("baked in" into
* the image), and aren't affected by this option. Note that if the Polish
* feature is enabled, all metadata may have been removed already and this
* option may have no effect.
* - keep: Preserve most of EXIF metadata, including GPS location if there's
* any.
* - copyright: Only keep the copyright tag, and discard everything else.
* This is the default behavior for JPEG files.
* - none: Discard all invisible EXIF metadata. Currently WebP and PNG
* output formats always discard metadata.
*/
metadata?: "keep" | "copyright" | "none";
/**
* Overlays are drawn in the order they appear in the array (last array
* entry is the topmost layer).
*/
draw?: RequestInitCfPropertiesImageDraw[];
/**
* Fetching image from authenticated origin. Setting this property will
* pass authentication headers (Authorization, Cookie, etc.) through to
* the origin.
*/
"origin-auth"?: "share-publicly";
/**
* Slightly reduces latency on a cache miss by selecting a
* quickest-to-compress file format, at a cost of increased file size and
* lower image quality. It will usually override the format option and choose
* JPEG over WebP or AVIF. We do not recommend using this option, except in
* unusual circumstances like resizing uncacheable dynamically-generated
* images.
*/
compression?: "fast";
}
interface RequestInitCfPropertiesImageMinify {
javascript?: boolean;
css?: boolean;
html?: boolean;
}
interface RequestInitCfPropertiesR2 {
/**
* Colo id of bucket that an object is stored in
*/
bucketColoId?: number;
}
/**
* Request metadata provided by Cloudflare's edge.
*/
type IncomingRequestCfProperties<HostMetadata = unknown> = IncomingRequestCfPropertiesBase & IncomingRequestCfPropertiesBotManagementEnterprise & IncomingRequestCfPropertiesCloudflareForSaaSEnterprise<HostMetadata> & IncomingRequestCfPropertiesGeographicInformation & IncomingRequestCfPropertiesCloudflareAccessOrApiShield;
interface IncomingRequestCfPropertiesBase extends Record<string, unknown> {
/**
* [ASN](https://www.iana.org/assignments/as-numbers/as-numbers.xhtml) of the incoming request.
*
* @example 395747
*/
asn?: number;
/**
* The organization which owns the ASN of the incoming request.
*
* @example "Google Cloud"
*/
asOrganization?: string;
/**
* The original value of the `Accept-Encoding` header if Cloudflare modified it.
*
* @example "gzip, deflate, br"
*/
clientAcceptEncoding?: string;
/**
* The number of milliseconds it took for the request to reach your worker.
*
* @example 22
*/
clientTcpRtt?: number;
/**
* The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code)
* airport code of the data center that the request hit.
*
* @example "DFW"
*/
colo: string;
/**
* Represents the upstream's response to a
* [TCP `keepalive` message](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html)
* from cloudflare.
*
* For workers with no upstream, this will always be `1`.
*
* @example 3
*/
edgeRequestKeepAliveStatus: IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus;
/**
* The HTTP Protocol the request used.
*
* @example "HTTP/2"
*/
httpProtocol: string;
/**
* The browser-requested prioritization information in the request object.
*
* If no information was set, defaults to the empty string `""`
*
* @example "weight=192;exclusive=0;group=3;group-weight=127"
* @default ""
*/
requestPriority: string;
/**
* The TLS version of the connection to Cloudflare.
* In requests served over plaintext (without TLS), this property is the empty string `""`.
*
* @example "TLSv1.3"
*/
tlsVersion: string;
/**
* The cipher for the connection to Cloudflare.
* In requests served over plaintext (without TLS), this property is the empty string `""`.
*
* @example "AEAD-AES128-GCM-SHA256"
*/
tlsCipher: string;
/**
* Metadata containing the [`HELLO`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2) and [`FINISHED`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9) messages from this request's TLS handshake.
*
* If the incoming request was served over plaintext (without TLS) this field is undefined.
*/
tlsExportedAuthenticator?: IncomingRequestCfPropertiesExportedAuthenticatorMetadata;
}
interface IncomingRequestCfPropertiesBotManagementBase {
/**
* Cloudflare’s [level of certainty](https://developers.cloudflare.com/bots/concepts/bot-score/) that a request comes from a bot,
* represented as an integer percentage between `1` (almost certainly a bot) and `99` (almost certainly human).
*
* @example 54
*/
score: number;
/**
* A boolean value that is true if the request comes from a good bot, like Google or Bing.
* Most customers choose to allow this traffic. For more details, see [Traffic from known bots](https://developers.cloudflare.com/firewall/known-issues-and-faq/#how-does-firewall-rules-handle-traffic-from-known-bots).
*/
verifiedBot: boolean;
/**
* A boolean value that is true if the request originates from a
* Cloudflare-verified proxy service.
*/
corporateProxy: boolean;
/**
* A boolean value that's true if the request matches [file extensions](https://developers.cloudflare.com/bots/reference/static-resources/) for many types of static resources.
*/
staticResource: boolean;
/**
* List of IDs that correlate to the Bot Management heuristic detections made on a request (you can have multiple heuristic detections on the same request).
*/
detectionIds: number[];
}
interface IncomingRequestCfPropertiesBotManagement {
/**
* Results of Cloudflare's Bot Management analysis
*/
botManagement: IncomingRequestCfPropertiesBotManagementBase;
/**
* Duplicate of `botManagement.score`.
*
* @deprecated
*/
clientTrustScore: number;
}
interface IncomingRequestCfPropertiesBotManagementEnterprise extends IncomingRequestCfPropertiesBotManagement {
/**
* Results of Cloudflare's Bot Management analysis
*/
botManagement: IncomingRequestCfPropertiesBotManagementBase & {
/**
* A [JA3 Fingerprint](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/) to help profile specific SSL/TLS clients
* across different destination IPs, Ports, and X509 certificates.
*/
ja3Hash: string;
};
}
interface IncomingRequestCfPropertiesCloudflareForSaaSEnterprise<HostMetadata> {
/**
* Custom metadata set per-host in [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/).
*
* This field is only present if you have Cloudflare for SaaS enabled on your account
* and you have followed the [required steps to enable it]((https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/custom-metadata/)).
*/
hostMetadata?: HostMetadata;
}
interface IncomingRequestCfPropertiesCloudflareAccessOrApiShield {
/**
* Information about the client certificate presented to Cloudflare.
*
* This is populated when the incoming request is served over TLS using
* either Cloudflare Access or API Shield (mTLS)
* and the presented SSL certificate has a valid
* [Certificate Serial Number](https://ldapwiki.com/wiki/Certificate%20Serial%20Number)
* (i.e., not `null` or `""`).
*
* Otherwise, a set of placeholder values are used.
*
* The property `certPresented` will be set to `"1"` when
* the object is populated (i.e. the above conditions were met).
*/
tlsClientAuth: IncomingRequestCfPropertiesTLSClientAuth | IncomingRequestCfPropertiesTLSClientAuthPlaceholder;
}
/**
* Metadata about the request's TLS handshake
*/
interface IncomingRequestCfPropertiesExportedAuthenticatorMetadata {
/**
* The client's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal
*
* @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d"
*/
clientHandshake: string;
/**
* The server's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal
*
* @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d"
*/
serverHandshake: string;
/**
* The client's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal
*
* @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b"
*/
clientFinished: string;
/**
* The server's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal
*
* @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b"
*/
serverFinished: string;
}
/**
* Geographic data about the request's origin.
*/
interface IncomingRequestCfPropertiesGeographicInformation {
/**
* The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from.
*
* If your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `"T1"`, indicating a request that originated over TOR.
*
* If Cloudflare is unable to determine where the request originated this property is omitted.
*
* The country code `"T1"` is used for requests originating on TOR.
*
* @example "GB"
*/
country?: Iso3166Alpha2Code | "T1";
/**
* If present, this property indicates that the request originated in the EU
*
* @example "1"
*/
isEUCountry?: "1";
/**
* A two-letter code indicating the continent the request originated from.
*
* @example "AN"
*/
continent?: ContinentCode;
/**
* The city the request originated from
*
* @example "Austin"
*/
city?: string;
/**
* Postal code of the incoming request
*
* @example "78701"
*/
postalCode?: string;
/**
* Latitude of the incoming request
*
* @example "30.27130"
*/
latitude?: string;
/**
* Longitude of the incoming request
*
* @example "-97.74260"
*/
longitude?: string;
/**
* Timezone of the incoming request
*
* @example "America/Chicago"
*/
timezone?: string;
/**
* If known, the ISO 3166-2 name for the first level region associated with
* the IP address of the incoming request
*
* @example "Texas"
*/
region?: string;
/**
* If known, the ISO 3166-2 code for the first-level region associated with
* the IP address of the incoming request
*
* @example "TX"
*/
regionCode?: string;
/**
* Metro code (DMA) of the incoming request
*
* @example "635"
*/
metroCode?: string;
}
/** Data about the incoming request's TLS certificate */
interface IncomingRequestCfPropertiesTLSClientAuth {
/** Always `"1"`, indicating that the certificate was presented */
certPresented: "1";
/**
* Result of certificate verification.
*
* @example "FAILED:self signed certificate"
*/
certVerified: Exclude<CertVerificationStatus, "NONE">;
/** The presented certificate's revokation status.
*
* - A value of `"1"` indicates the certificate has been revoked
* - A value of `"0"` indicates the certificate has not been revoked
*/
certRevoked: "1" | "0";
/**
* The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html)
*
* @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare"
*/
certIssuerDN: string;
/**
* The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html)
*
* @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare"
*/
certSubjectDN: string;
/**
* The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted)
*
* @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare"
*/
certIssuerDNRFC2253: string;
/**
* The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted)
*
* @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare"
*/
certSubjectDNRFC2253: string;
/** The certificate issuer's distinguished name (legacy policies) */
certIssuerDNLegacy: string;
/** The certificate subject's distinguished name (legacy policies) */
certSubjectDNLegacy: string;
/**
* The certificate's serial number
*
* @example "00936EACBE07F201DF"
*/
certSerial: string;
/**
* The certificate issuer's serial number
*
* @example "2489002934BDFEA34"
*/
certIssuerSerial: string;
/**
* The certificate's Subject Key Identifier
*
* @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4"
*/
certSKI: string;
/**
* The certificate issuer's Subject Key Identifier
*
* @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4"
*/
certIssuerSKI: string;
/**
* The certificate's SHA-1 fingerprint
*
* @example "6b9109f323999e52259cda7373ff0b4d26bd232e"
*/
certFingerprintSHA1: string;
/**
* The certificate's SHA-256 fingerprint
*
* @example "acf77cf37b4156a2708e34c4eb755f9b5dbbe5ebb55adfec8f11493438d19e6ad3f157f81fa3b98278453d5652b0c1fd1d71e5695ae4d709803a4d3f39de9dea"
*/
certFingerprintSHA256: string;
/**
* The effective starting date of the certificate
*
* @example "Dec 22 19:39:00 2018 GMT"
*/
certNotBefore: string;
/**
* The effective expiration date of the certificate
*
* @example "Dec 22 19:39:00 2018 GMT"
*/
certNotAfter: string;
/**
* The client leaf certificate in [RFC 9440](https://www.rfc-editor.org/rfc/rfc9440)
* format (`:base64-DER:`). Empty if no client certificate was presented or if
* the leaf certificate exceeded 10 KB (see {@link certRFC9440TooLarge}).
*
* Suitable for forwarding to an origin via the `Client-Cert` HTTP header.
*/
certRFC9440: string;
/**
* `true` if the leaf certificate exceeded 10 KB and was omitted from
* {@link certRFC9440}.
*/
certRFC9440TooLarge: boolean;
/**
* The intermediate certificate chain in [RFC 9440](https://www.rfc-editor.org/rfc/rfc9440)
* format as a comma-separated list. Empty if no intermediates were sent or
* if the chain exceeded 16 KB (see {@link certChainRFC9440TooLarge}).
*
* Suitable for forwarding to an origin via the `Client-Cert-Chain` HTTP header.
*/
certChainRFC9440: string;
/**
* `true` if the intermediate chain exceeded 16 KB and was omitted from
* {@link certChainRFC9440}.
*/
certChainRFC9440TooLarge: boolean;
}
/** Placeholder values for TLS Client Authorization */
interface IncomingRequestCfPropertiesTLSClientAuthPlaceholder {
certPresented: "0";
certVerified: "NONE";
certRevoked: "0";
certIssuerDN: "";
certSubjectDN: "";
certIssuerDNRFC2253: "";
certSubjectDNRFC2253: "";
certIssuerDNLegacy: "";
certSubjectDNLegacy: "";
certSerial: "";
certIssuerSerial: "";
certSKI: "";
certIssuerSKI: "";
certFingerprintSHA1: "";
certFingerprintSHA256: "";
certNotBefore: "";
certNotAfter: "";
certRFC9440: "";
certRFC9440TooLarge: false;
certChainRFC9440: "";
certChainRFC9440TooLarge: false;
}
/** Possible outcomes of TLS verification */
declare type CertVerificationStatus =
/** Authentication succeeded */
"SUCCESS"
/** No certificate was presented */
| "NONE"
/** Failed because the certificate was self-signed */
| "FAILED:self signed certificate"
/** Failed because the certificate failed a trust chain check */
| "FAILED:unable to verify the first certificate"
/** Failed because the certificate not yet valid */
| "FAILED:certificate is not yet valid"
/** Failed because the certificate is expired */
| "FAILED:certificate has expired"
/** Failed for another unspecified reason */
| "FAILED";
/**
* An upstream endpoint's response to a TCP `keepalive` message from Cloudflare.
*/
declare type IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus = 0 /** Unknown */ | 1 /** no keepalives (not found) */ | 2 /** no connection re-use, opening keepalive connection failed */ | 3 /** no connection re-use, keepalive accepted and saved */ | 4 /** connection re-use, refused by the origin server (`TCP FIN`) */ | 5; /** connection re-use, accepted by the origin server */
/** ISO 3166-1 Alpha-2 codes */
declare type Iso3166Alpha2Code = "AD" | "AE" | "AF" | "AG" | "AI" | "AL" | "AM" | "AO" | "AQ" | "AR" | "AS" | "AT" | "AU" | "AW" | "AX" | "AZ" | "BA" | "BB" | "BD" | "BE" | "BF" | "BG" | "BH" | "BI" | "BJ" | "BL" | "BM" | "BN" | "BO" | "BQ" | "BR" | "BS" | "BT" | "BV" | "BW" | "BY" | "BZ" | "CA" | "CC" | "CD" | "CF" | "CG" | "CH" | "CI" | "CK" | "CL" | "CM" | "CN" | "CO" | "CR" | "CU" | "CV" | "CW" | "CX" | "CY" | "CZ" | "DE" | "DJ" | "DK" | "DM" | "DO" | "DZ" | "EC" | "EE" | "EG" | "EH" | "ER" | "ES" | "ET" | "FI" | "FJ" | "FK" | "FM" | "FO" | "FR" | "GA" | "GB" | "GD" | "GE" | "GF" | "GG" | "GH" | "GI" | "GL" | "GM" | "GN" | "GP" | "GQ" | "GR" | "GS" | "GT" | "GU" | "GW" | "GY" | "HK" | "HM" | "HN" | "HR" | "HT" | "HU" | "ID" | "IE" | "IL" | "IM" | "IN" | "IO" | "IQ" | "IR" | "IS" | "IT" | "JE" | "JM" | "JO" | "JP" | "KE" | "KG" | "KH" | "KI" | "KM" | "KN" | "KP" | "KR" | "KW" | "KY" | "KZ" | "LA" | "LB" | "LC" | "LI" | "LK" | "LR" | "LS" | "LT" | "LU" | "LV" | "LY" | "MA" | "MC" | "MD" | "ME" | "MF" | "MG" | "MH" | "MK" | "ML" | "MM" | "MN" | "MO" | "MP" | "MQ" | "MR" | "MS" | "MT" | "MU" | "MV" | "MW" | "MX" | "MY" | "MZ" | "NA" | "NC" | "NE" | "NF" | "NG" | "NI" | "NL" | "NO" | "NP" | "NR" | "NU" | "NZ" | "OM" | "PA" | "PE" | "PF" | "PG" | "PH" | "PK" | "PL" | "PM" | "PN" | "PR" | "PS" | "PT" | "PW" | "PY" | "QA" | "RE" | "RO" | "RS" | "RU" | "RW" | "SA" | "SB" | "SC" | "SD" | "SE" | "SG" | "SH" | "SI" | "SJ" | "SK" | "SL" | "SM" | "SN" | "SO" | "SR" | "SS" | "ST" | "SV" | "SX" | "SY" | "SZ" | "TC" | "TD" | "TF" | "TG" | "TH" | "TJ" | "TK" | "TL" | "TM" | "TN" | "TO" | "TR" | "TT" | "TV" | "TW" | "TZ" | "UA" | "UG" | "UM" | "US" | "UY" | "UZ" | "VA" | "VC" | "VE" | "VG" | "VI" | "VN" | "VU" | "WF" | "WS" | "YE" | "YT" | "ZA" | "ZM" | "ZW";
/** The 2-letter continent codes Cloudflare uses */
declare type ContinentCode = "AF" | "AN" | "AS" | "EU" | "NA" | "OC" | "SA";
type CfProperties<HostMetadata = unknown> = IncomingRequestCfProperties<HostMetadata> | RequestInitCfProperties;
interface D1Meta {
duration: number;
size_after: number;
rows_read: number;
rows_written: number;
last_row_id: number;
changed_db: boolean;
changes: number;
/**
* The region of the database instance that executed the query.
*/
served_by_region?: string;
/**
* The three letters airport code of the colo that executed the query.
*/
served_by_colo?: string;
/**
* True if-and-only-if the database instance that executed the query was the primary.
*/
served_by_primary?: boolean;
timings?: {
/**
* The duration of the SQL query execution by the database instance. It doesn't include any network time.
*/
sql_duration_ms: number;
};
/**
* Number of total attempts to execute the query, due to automatic retries.
* Note: All other fields in the response like `timings` only apply to the last attempt.
*/
total_attempts?: number;
}
interface D1Response {
success: true;
meta: D1Meta & Record<string, unknown>;
error?: never;
}
type D1Result<T = unknown> = D1Response & {
results: T[];
};
interface D1ExecResult {
count: number;
duration: number;
}
type D1SessionConstraint =
// Indicates that the first query should go to the primary, and the rest queries
// using the same D1DatabaseSession will go to any replica that is consistent with
// the bookmark maintained by the session (returned by the first query).
'first-primary'
// Indicates that the first query can go anywhere (primary or replica), and the rest queries
// using the same D1DatabaseSession will go to any replica that is consistent with
// the bookmark maintained by the session (returned by the first query).
| 'first-unconstrained';
type D1SessionBookmark = string;
declare abstract class D1Database {
prepare(query: string): D1PreparedStatement;
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
exec(query: string): Promise<D1ExecResult>;
/**
* Creates a new D1 Session anchored at the given constraint or the bookmark.
* All queries executed using the created session will have sequential consistency,
* meaning that all writes done through the session will be visible in subsequent reads.
*
* @param constraintOrBookmark Either the session constraint or the explicit bookmark to anchor the created session.
*/
withSession(constraintOrBookmark?: D1SessionBookmark | D1SessionConstraint): D1DatabaseSession;
/**
* @deprecated dump() will be removed soon, only applies to deprecated alpha v1 databases.
*/
dump(): Promise<ArrayBuffer>;
}
declare abstract class D1DatabaseSession {
prepare(query: string): D1PreparedStatement;
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
/**
* @returns The latest session bookmark across all executed queries on the session.
* If no query has been executed yet, `null` is returned.
*/
getBookmark(): D1SessionBookmark | null;
}
declare abstract class D1PreparedStatement {
bind(...values: unknown[]): D1PreparedStatement;
first<T = unknown>(colName: string): Promise<T | null>;
first<T = Record<string, unknown>>(): Promise<T | null>;
run<T = Record<string, unknown>>(): Promise<D1Result<T>>;
all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
raw<T = unknown[]>(options: {
columnNames: true;
}): Promise<[
string[],
...T[]
]>;
raw<T = unknown[]>(options?: {
columnNames?: false;
}): Promise<T[]>;
}
// `Disposable` was added to TypeScript's standard lib types in version 5.2.
// To support older TypeScript versions, define an empty `Disposable` interface.
// Users won't be able to use `using`/`Symbol.dispose` without upgrading to 5.2,
// but this will ensure type checking on older versions still passes.
// TypeScript's interface merging will ensure our empty interface is effectively
// ignored when `Disposable` is included in the standard lib.
interface Disposable {
}
/**
* The returned data after sending an email
*/
interface EmailSendResult {
/**
* The Email Message ID
*/
messageId: string;
}
/**
* An email message that can be sent from a Worker.
*/
interface EmailMessage {
/**
* Envelope From attribute of the email message.
*/
readonly from: string;
/**
* Envelope To attribute of the email message.
*/
readonly to: string;
}
/**
* An email message that is sent to a consumer Worker and can be rejected/forwarded.
*/
interface ForwardableEmailMessage extends EmailMessage {
/**
* Stream of the email message content.
*/
readonly raw: ReadableStream<Uint8Array>;
/**
* An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers).
*/
readonly headers: Headers;
/**
* Size of the email message content.
*/
readonly rawSize: number;
/**
* Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason.
* @param reason The reject reason.
* @returns void
*/
setReject(reason: string): void;
/**
* Forward this email message to a verified destination address of the account.
* @param rcptTo Verified destination address.
* @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers).
* @returns A promise that resolves when the email message is forwarded.
*/
forward(rcptTo: string, headers?: Headers): Promise<EmailSendResult>;
/**
* Reply to the sender of this email message with a new EmailMessage object.
* @param message The reply message.
* @returns A promise that resolves when the email message is replied.
*/
reply(message: EmailMessage): Promise<EmailSendResult>;
}
/** A file attachment for an email message */
type EmailAttachment = {
disposition: 'inline';
contentId: string;
filename: string;
type: string;
content: string | ArrayBuffer | ArrayBufferView;
} | {
disposition: 'attachment';
contentId?: undefined;
filename: string;
type: string;
content: string | ArrayBuffer | ArrayBufferView;
};
/** An Email Address */
interface EmailAddress {
name: string;
email: string;
}
/**
* A binding that allows a Worker to send email messages.
*/
interface SendEmail {
send(message: EmailMessage): Promise<EmailSendResult>;
send(builder: {
from: string | EmailAddress;
to: string | EmailAddress | (string | EmailAddress)[];
subject: string;
replyTo?: string | EmailAddress;
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
headers?: Record<string, string>;
text?: string;
html?: string;
attachments?: EmailAttachment[];
}): Promise<EmailSendResult>;
}
declare abstract class EmailEvent extends ExtendableEvent {
readonly message: ForwardableEmailMessage;
}
declare type EmailExportedHandler<Env = unknown, Props = unknown> = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
declare module "cloudflare:email" {
let _EmailMessage: {
prototype: EmailMessage;
new (from: string, to: string, raw: ReadableStream | string): EmailMessage;
};
export { _EmailMessage as EmailMessage };
}
/**
* Evaluation context for targeting rules.
* Keys are attribute names (e.g. "userId", "country"), values are the attribute values.
*/
type FlagshipEvaluationContext = Record<string, string | number | boolean>;
interface FlagshipEvaluationDetails<T> {
flagKey: string;
value: T;
variant?: string | undefined;
reason?: string | undefined;
errorCode?: string | undefined;
errorMessage?: string | undefined;
}
interface FlagshipEvaluationError extends Error {
}
/**
* Feature flags binding for evaluating feature flags from a Cloudflare Workers script.
*
* @example
* ```typescript
* // Get a boolean flag value with a default
* const enabled = await env.FLAGS.getBooleanValue('my-feature', false);
*
* // Get a flag value with evaluation context for targeting
* const variant = await env.FLAGS.getStringValue('experiment', 'control', {
* userId: 'user-123',
* country: 'US',
* });
*
* // Get full evaluation details including variant and reason
* const details = await env.FLAGS.getBooleanDetails('my-feature', false);
* console.log(details.variant, details.reason);
* ```
*/
declare abstract class Flagship {
/**
* Get a flag value without type checking.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Optional default value returned when evaluation fails.
* @param context Optional evaluation context for targeting rules.
*/
get(flagKey: string, defaultValue?: unknown, context?: FlagshipEvaluationContext): Promise<unknown>;
/**
* Get a boolean flag value.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getBooleanValue(flagKey: string, defaultValue: boolean, context?: FlagshipEvaluationContext): Promise<boolean>;
/**
* Get a string flag value.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getStringValue(flagKey: string, defaultValue: string, context?: FlagshipEvaluationContext): Promise<string>;
/**
* Get a number flag value.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getNumberValue(flagKey: string, defaultValue: number, context?: FlagshipEvaluationContext): Promise<number>;
/**
* Get an object flag value.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getObjectValue<T extends object>(flagKey: string, defaultValue: T, context?: FlagshipEvaluationContext): Promise<T>;
/**
* Get a boolean flag value with full evaluation details.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getBooleanDetails(flagKey: string, defaultValue: boolean, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<boolean>>;
/**
* Get a string flag value with full evaluation details.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getStringDetails(flagKey: string, defaultValue: string, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<string>>;
/**
* Get a number flag value with full evaluation details.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getNumberDetails(flagKey: string, defaultValue: number, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<number>>;
/**
* Get an object flag value with full evaluation details.
* @param flagKey The key of the flag to evaluate.
* @param defaultValue Default value returned when evaluation fails or the flag type does not match.
* @param context Optional evaluation context for targeting rules.
*/
getObjectDetails<T extends object>(flagKey: string, defaultValue: T, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<T>>;
}
/**
* Hello World binding to serve as an explanatory example. DO NOT USE
*/
interface HelloWorldBinding {
/**
* Retrieve the current stored value
*/
get(): Promise<{
value: string;
ms?: number;
}>;
/**
* Set a new stored value
*/
set(value: string): Promise<void>;
}
interface Hyperdrive {
/**
* Connect directly to Hyperdrive as if it's your database, returning a TCP socket.
*
* Calling this method returns an identical socket to if you call
* `connect("host:port")` using the `host` and `port` fields from this object.
* Pick whichever approach works better with your preferred DB client library.
*
* Note that this socket is not yet authenticated -- it's expected that your
* code (or preferably, the client library of your choice) will authenticate
* using the information in this class's readonly fields.
*/
connect(): Socket;
/**
* A valid DB connection string that can be passed straight into the typical
* client library/driver/ORM. This will typically be the easiest way to use
* Hyperdrive.
*/
readonly connectionString: string;
/*
* A randomly generated hostname that is only valid within the context of the
* currently running Worker which, when passed into `connect()` function from
* the "cloudflare:sockets" module, will connect to the Hyperdrive instance
* for your database.
*/
readonly host: string;
/*
* The port that must be paired the the host field when connecting.
*/
readonly port: number;
/*
* The username to use when authenticating to your database via Hyperdrive.
* Unlike the host and password, this will be the same every time
*/
readonly user: string;
/*
* The randomly generated password to use when authenticating to your
* database via Hyperdrive. Like the host field, this password is only valid
* within the context of the currently running Worker instance from which
* it's read.
*/
readonly password: string;
/*
* The name of the database to connect to.
*/
readonly database: string;
}
// Copyright (c) 2024 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
type ImageInfoResponse = {
format: 'image/svg+xml';
} | {
format: string;
fileSize: number;
width: number;
height: number;
};
type ImageTransform = {
width?: number;
height?: number;
background?: string;
blur?: number;
border?: {
color?: string;
width?: number;
} | {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
brightness?: number;
contrast?: number;
fit?: 'scale-down' | 'contain' | 'pad' | 'squeeze' | 'cover' | 'crop';
flip?: 'h' | 'v' | 'hv';
gamma?: number;
segment?: 'foreground';
gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | {
x?: number;
y?: number;
mode: 'remainder' | 'box-center';
};
rotate?: 0 | 90 | 180 | 270;
saturation?: number;
sharpen?: number;
trim?: 'border' | {
top?: number;
bottom?: number;
left?: number;
right?: number;
width?: number;
height?: number;
border?: boolean | {
color?: string;
tolerance?: number;
keep?: number;
};
};
};
type ImageDrawOptions = {
opacity?: number;
repeat?: boolean | string;
composite?: ImageCompositeMode;
top?: number;
left?: number;
bottom?: number;
right?: number;
};
type ImageCompositeMode =
/** Foreground drawn on top of backdrop (default) */
'over'
/** Foreground shown only where backdrop is opaque */
| 'in'
/** Foreground drawn on top, but clipped to the backdrop's shape */
| 'atop'
/** Foreground shown only where backdrop is transparent */
| 'out'
/** Foreground and backdrop visible only where the other is not */
| 'xor'
/** Foreground and backdrop channels added (brightening) */
| 'lighter';
type ImageInputOptions = {
encoding?: 'base64';
};
type ImageOutputOptions = {
format: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | 'image/avif' | 'rgb' | 'rgba';
quality?: number;
background?: string;
anim?: boolean;
};
interface ImageMetadata {
id: string;
filename?: string;
uploaded?: string;
requireSignedURLs: boolean;
meta?: Record<string, unknown>;
variants: string[];
draft?: boolean;
creator?: string;
}
interface ImageUploadOptions {
id?: string;
filename?: string;
requireSignedURLs?: boolean;
metadata?: Record<string, unknown>;
creator?: string;
encoding?: 'base64';
}
interface ImageUpdateOptions {
requireSignedURLs?: boolean;
metadata?: Record<string, unknown>;
creator?: string;
}
interface ImageListOptions {
limit?: number;
cursor?: string;
sortOrder?: 'asc' | 'desc';
creator?: string;
}
interface ImageList {
images: ImageMetadata[];
cursor?: string;
listComplete: boolean;
}
interface ImageHandle {
/**
* Get metadata for a hosted image
* @returns Image metadata, or null if not found
*/
details(): Promise<ImageMetadata | null>;
/**
* Get the raw image data for a hosted image
* @returns ReadableStream of image bytes, or null if not found
*/
bytes(): Promise<ReadableStream<Uint8Array> | null>;
/**
* Update hosted image metadata
* @param options Properties to update
* @returns Updated image metadata
* @throws {@link ImagesError} if update fails
*/
update(options: ImageUpdateOptions): Promise<ImageMetadata>;
/**
* Delete a hosted image
* @returns True if deleted, false if not found
*/
delete(): Promise<boolean>;
}
interface HostedImagesBinding {
/**
* Get a handle for a hosted image
* @param imageId The ID of the image (UUID or custom ID)
* @returns A handle for per-image operations
*/
image(imageId: string): ImageHandle;
/**
* Upload a new hosted image
* @param image The image file to upload
* @param options Upload configuration
* @returns Metadata for the uploaded image
* @throws {@link ImagesError} if upload fails
*/
upload(image: ReadableStream<Uint8Array> | ArrayBuffer, options?: ImageUploadOptions): Promise<ImageMetadata>;
/**
* List hosted images with pagination
* @param options List configuration
* @returns List of images with pagination info
* @throws {@link ImagesError} if list fails
*/
list(options?: ImageListOptions): Promise<ImageList>;
}
interface ImagesBinding {
/**
* Get image metadata (type, width and height)
* @throws {@link ImagesError} with code 9412 if input is not an image
* @param stream The image bytes
*/
info(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): Promise<ImageInfoResponse>;
/**
* Begin applying a series of transformations to an image
* @param stream The image bytes
* @returns A transform handle
*/
input(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): ImageTransformer;
/**
* Access hosted images CRUD operations
*/
readonly hosted: HostedImagesBinding;
}
interface ImageTransformer {
/**
* Apply transform next, returning a transform handle.
* You can then apply more transformations, draw, or retrieve the output.
* @param transform
*/
transform(transform: ImageTransform): ImageTransformer;
/**
* Draw an image on this transformer, returning a transform handle.
* You can then apply more transformations, draw, or retrieve the output.
* @param image The image (or transformer that will give the image) to draw
* @param options The options configuring how to draw the image
*/
draw(image: ReadableStream<Uint8Array> | ImageTransformer, options?: ImageDrawOptions): ImageTransformer;
/**
* Retrieve the image that results from applying the transforms to the
* provided input
* @param options Options that apply to the output e.g. output format
*/
output(options: ImageOutputOptions): Promise<ImageTransformationResult>;
}
type ImageTransformationOutputOptions = {
encoding?: 'base64';
};
interface ImageTransformationResult {
/**
* The image as a response, ready to store in cache or return to users
*/
response(): Response;
/**
* The content type of the returned image
*/
contentType(): string;
/**
* The bytes of the response
*/
image(options?: ImageTransformationOutputOptions): ReadableStream<Uint8Array>;
}
interface ImagesError extends Error {
readonly code: number;
readonly message: string;
readonly stack?: string;
}
/**
* Media binding for transforming media streams.
* Provides the entry point for media transformation operations.
*/
interface MediaBinding {
/**
* Creates a media transformer from an input stream.
* @param media - The input media bytes
* @returns A MediaTransformer instance for applying transformations
*/
input(media: ReadableStream<Uint8Array>): MediaTransformer;
}
/**
* Media transformer for applying transformation operations to media content.
* Handles sizing, fitting, and other input transformation parameters.
*/
interface MediaTransformer {
/**
* Applies transformation options to the media content.
* @param transform - Configuration for how the media should be transformed
* @returns A generator for producing the transformed media output
*/
transform(transform?: MediaTransformationInputOptions): MediaTransformationGenerator;
/**
* Generates the final media output with specified options.
* @param output - Configuration for the output format and parameters
* @returns The final transformation result containing the transformed media
*/
output(output?: MediaTransformationOutputOptions): MediaTransformationResult;
}
/**
* Generator for producing media transformation results.
* Configures the output format and parameters for the transformed media.
*/
interface MediaTransformationGenerator {
/**
* Generates the final media output with specified options.
* @param output - Configuration for the output format and parameters
* @returns The final transformation result containing the transformed media
*/
output(output?: MediaTransformationOutputOptions): MediaTransformationResult;
}
/**
* Result of a media transformation operation.
* Provides multiple ways to access the transformed media content.
*/
interface MediaTransformationResult {
/**
* Returns the transformed media as a readable stream of bytes.
* @returns A promise containing a readable stream with the transformed media
*/
media(): Promise<ReadableStream<Uint8Array>>;
/**
* Returns the transformed media as an HTTP response object.
* @returns The transformed media as a Promise<Response>, ready to store in cache or return to users
*/
response(): Promise<Response>;
/**
* Returns the MIME type of the transformed media.
* @returns A promise containing the content type string (e.g., 'image/jpeg', 'video/mp4')
*/
contentType(): Promise<string>;
}
/**
* Configuration options for transforming media input.
* Controls how the media should be resized and fitted.
*/
type MediaTransformationInputOptions = {
/** How the media should be resized to fit the specified dimensions */
fit?: 'contain' | 'cover' | 'scale-down';
/** Target width in pixels */
width?: number;
/** Target height in pixels */
height?: number;
};
/**
* Configuration options for Media Transformations output.
* Controls the format, timing, and type of the generated output.
*/
type MediaTransformationOutputOptions = {
/**
* Output mode determining the type of media to generate
*/
mode?: 'video' | 'spritesheet' | 'frame' | 'audio';
/** Whether to include audio in the output */
audio?: boolean;
/**
* Starting timestamp for frame extraction or start time for clips. (e.g. '2s').
*/
time?: string;
/**
* Duration for video clips, audio extraction, and spritesheet generation (e.g. '5s').
*/
duration?: string;
/**
* Number of frames in the spritesheet.
*/
imageCount?: number;
/**
* Output format for the generated media.
*/
format?: 'jpg' | 'png' | 'm4a';
};
/**
* Error object for media transformation operations.
* Extends the standard Error interface with additional media-specific information.
*/
interface MediaError extends Error {
readonly code: number;
readonly message: string;
readonly stack?: string;
}
declare module 'cloudflare:node' {
interface NodeStyleServer {
listen(...args: unknown[]): this;
address(): {
port?: number | null | undefined;
};
}
export function httpServerHandler(port: number): ExportedHandler;
export function httpServerHandler(options: {
port: number;
}): ExportedHandler;
export function httpServerHandler(server: NodeStyleServer): ExportedHandler;
}
type Params<P extends string = any> = Record<P, string | string[]>;
type EventContext<Env, P extends string, Data> = {
request: Request<unknown, IncomingRequestCfProperties<unknown>>;
functionPath: string;
waitUntil: (promise: Promise<any>) => void;
passThroughOnException: () => void;
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
env: Env & {
ASSETS: {
fetch: typeof fetch;
};
};
params: Params<P>;
data: Data;
};
type PagesFunction<Env = unknown, Params extends string = any, Data extends Record<string, unknown> = Record<string, unknown>> = (context: EventContext<Env, Params, Data>) => Response | Promise<Response>;
type EventPluginContext<Env, P extends string, Data, PluginArgs> = {
request: Request<unknown, IncomingRequestCfProperties<unknown>>;
functionPath: string;
waitUntil: (promise: Promise<any>) => void;
passThroughOnException: () => void;
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
env: Env & {
ASSETS: {
fetch: typeof fetch;
};
};
params: Params<P>;
data: Data;
pluginArgs: PluginArgs;
};
type PagesPluginFunction<Env = unknown, Params extends string = any, Data extends Record<string, unknown> = Record<string, unknown>, PluginArgs = unknown> = (context: EventPluginContext<Env, Params, Data, PluginArgs>) => Response | Promise<Response>;
declare module "assets:*" {
export const onRequest: PagesFunction;
}
// Copyright (c) 2022-2023 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
declare module "cloudflare:pipelines" {
export abstract class PipelineTransformationEntrypoint<Env = unknown, I extends PipelineRecord = PipelineRecord, O extends PipelineRecord = PipelineRecord> {
protected env: Env;
protected ctx: ExecutionContext;
constructor(ctx: ExecutionContext, env: Env);
/**
* run receives an array of PipelineRecord which can be
* transformed and returned to the pipeline
* @param records Incoming records from the pipeline to be transformed
* @param metadata Information about the specific pipeline calling the transformation entrypoint
* @returns A promise containing the transformed PipelineRecord array
*/
public run(records: I[], metadata: PipelineBatchMetadata): Promise<O[]>;
}
export type PipelineRecord = Record<string, unknown>;
export type PipelineBatchMetadata = {
pipelineId: string;
pipelineName: string;
};
export interface Pipeline<T extends PipelineRecord = PipelineRecord> {
/**
* The Pipeline interface represents the type of a binding to a Pipeline
*
* @param records The records to send to the pipeline
*/
send(records: T[]): Promise<void>;
}
}
// PubSubMessage represents an incoming PubSub message.
// The message includes metadata about the broker, the client, and the payload
// itself.
// https://developers.cloudflare.com/pub-sub/
interface PubSubMessage {
// Message ID
readonly mid: number;
// MQTT broker FQDN in the form mqtts://BROKER.NAMESPACE.cloudflarepubsub.com:PORT
readonly broker: string;
// The MQTT topic the message was sent on.
readonly topic: string;
// The client ID of the client that published this message.
readonly clientId: string;
// The unique identifier (JWT ID) used by the client to authenticate, if token
// auth was used.
readonly jti?: string;
// A Unix timestamp (seconds from Jan 1, 1970), set when the Pub/Sub Broker
// received the message from the client.
readonly receivedAt: number;
// An (optional) string with the MIME type of the payload, if set by the
// client.
readonly contentType: string;
// Set to 1 when the payload is a UTF-8 string
// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901063
readonly payloadFormatIndicator: number;
// Pub/Sub (MQTT) payloads can be UTF-8 strings, or byte arrays.
// You can use payloadFormatIndicator to inspect this before decoding.
payload: string | Uint8Array;
}
// JsonWebKey extended by kid parameter
interface JsonWebKeyWithKid extends JsonWebKey {
// Key Identifier of the JWK
readonly kid: string;
}
interface RateLimitOptions {
key: string;
}
interface RateLimitOutcome {
success: boolean;
}
interface RateLimit {
/**
* Rate limit a request based on the provided options.
* @see https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
* @returns A promise that resolves with the outcome of the rate limit.
*/
limit(options: RateLimitOptions): Promise<RateLimitOutcome>;
}
// Namespace for RPC utility types. Unfortunately, we can't use a `module` here as these types need
// to referenced by `Fetcher`. This is included in the "importable" version of the types which
// strips all `module` blocks.
declare namespace Rpc {
// Branded types for identifying `WorkerEntrypoint`/`DurableObject`/`Target`s.
// TypeScript uses *structural* typing meaning anything with the same shape as type `T` is a `T`.
// For the classes exported by `cloudflare:workers` we want *nominal* typing (i.e. we only want to
// accept `WorkerEntrypoint` from `cloudflare:workers`, not any other class with the same shape)
export const __RPC_STUB_BRAND: '__RPC_STUB_BRAND';
export const __RPC_TARGET_BRAND: '__RPC_TARGET_BRAND';
export const __WORKER_ENTRYPOINT_BRAND: '__WORKER_ENTRYPOINT_BRAND';
export const __DURABLE_OBJECT_BRAND: '__DURABLE_OBJECT_BRAND';
export const __WORKFLOW_ENTRYPOINT_BRAND: '__WORKFLOW_ENTRYPOINT_BRAND';
export interface RpcTargetBranded {
[__RPC_TARGET_BRAND]: never;
}
export interface WorkerEntrypointBranded {
[__WORKER_ENTRYPOINT_BRAND]: never;
}
export interface DurableObjectBranded {
[__DURABLE_OBJECT_BRAND]: never;
}
export interface WorkflowEntrypointBranded {
[__WORKFLOW_ENTRYPOINT_BRAND]: never;
}
export type EntrypointBranded = WorkerEntrypointBranded | DurableObjectBranded | WorkflowEntrypointBranded;
// Types that can be used through `Stub`s
export type Stubable = RpcTargetBranded | ((...args: any[]) => any);
// Types that can be passed over RPC
// The reason for using a generic type here is to build a serializable subset of structured
// cloneable composite types. This allows types defined with the "interface" keyword to pass the
// serializable check as well. Otherwise, only types defined with the "type" keyword would pass.
type Serializable<T> =
// Structured cloneables
BaseType
// Structured cloneable composites
| Map<T extends Map<infer U, unknown> ? Serializable<U> : never, T extends Map<unknown, infer U> ? Serializable<U> : never> | Set<T extends Set<infer U> ? Serializable<U> : never> | ReadonlyArray<T extends ReadonlyArray<infer U> ? Serializable<U> : never> | {
[K in keyof T]: K extends number | string ? Serializable<T[K]> : never;
}
// Special types
| Stub<Stubable>
// Serialized as stubs, see `Stubify`
| Stubable;
// Base type for all RPC stubs, including common memory management methods.
// `T` is used as a marker type for unwrapping `Stub`s later.
interface StubBase<T extends Stubable> extends Disposable {
[__RPC_STUB_BRAND]: T;
dup(): this;
}
export type Stub<T extends Stubable> = Provider<T> & StubBase<T>;
// This represents all the types that can be sent as-is over an RPC boundary
type BaseType = void | undefined | null | boolean | number | bigint | string | TypedArray | ArrayBuffer | DataView | Date | Error | RegExp | ReadableStream<Uint8Array> | WritableStream<Uint8Array> | Request | Response | Headers;
// Recursively rewrite all `Stubable` types with `Stub`s
// prettier-ignore
type Stubify<T> = T extends Stubable ? Stub<T> : T extends Map<infer K, infer V> ? Map<Stubify<K>, Stubify<V>> : T extends Set<infer V> ? Set<Stubify<V>> : T extends Array<infer V> ? Array<Stubify<V>> : T extends ReadonlyArray<infer V> ? ReadonlyArray<Stubify<V>> : T extends BaseType ? T : T extends {
[key: string | number]: any;
} ? {
[K in keyof T]: Stubify<T[K]>;
} : T;
// Recursively rewrite all `Stub<T>`s with the corresponding `T`s.
// Note we use `StubBase` instead of `Stub` here to avoid circular dependencies:
// `Stub` depends on `Provider`, which depends on `Unstubify`, which would depend on `Stub`.
// prettier-ignore
type Unstubify<T> = T extends StubBase<infer V> ? V : T extends Map<infer K, infer V> ? Map<Unstubify<K>, Unstubify<V>> : T extends Set<infer V> ? Set<Unstubify<V>> : T extends Array<infer V> ? Array<Unstubify<V>> : T extends ReadonlyArray<infer V> ? ReadonlyArray<Unstubify<V>> : T extends BaseType ? T : T extends {
[key: string | number]: unknown;
} ? {
[K in keyof T]: Unstubify<T[K]>;
} : T;
type UnstubifyAll<A extends any[]> = {
[I in keyof A]: Unstubify<A[I]>;
};
// Utility type for adding `Provider`/`Disposable`s to `object` types only.
// Note `unknown & T` is equivalent to `T`.
type MaybeProvider<T> = T extends object ? Provider<T> : unknown;
type MaybeDisposable<T> = T extends object ? Disposable : unknown;
// Type for method return or property on an RPC interface.
// - Stubable types are replaced by stubs.
// - Serializable types are passed by value, with stubable types replaced by stubs
// and a top-level `Disposer`.
// Everything else can't be passed over PRC.
// Technically, we use custom thenables here, but they quack like `Promise`s.
// Intersecting with `(Maybe)Provider` allows pipelining.
// prettier-ignore
type Result<R> = R extends Stubable ? Promise<Stub<R>> & Provider<R> : R extends Serializable<R> ? Promise<Stubify<R> & MaybeDisposable<R>> & MaybeProvider<R> : never;
// Type for method or property on an RPC interface.
// For methods, unwrap `Stub`s in parameters, and rewrite returns to be `Result`s.
// Unwrapping `Stub`s allows calling with `Stubable` arguments.
// For properties, rewrite types to be `Result`s.
// In each case, unwrap `Promise`s.
type MethodOrProperty<V> = V extends (...args: infer P) => infer R ? (...args: UnstubifyAll<P>) => Result<Awaited<R>> : Result<Awaited<V>>;
// Type for the callable part of an `Provider` if `T` is callable.
// This is intersected with methods/properties.
type MaybeCallableProvider<T> = T extends (...args: any[]) => any ? MethodOrProperty<T> : unknown;
// Base type for all other types providing RPC-like interfaces.
// Rewrites all methods/properties to be `MethodOrProperty`s, while preserving callable types.
// `Reserved` names (e.g. stub method names like `dup()`) and symbols can't be accessed over RPC.
export type Provider<T extends object, Reserved extends string = never> = MaybeCallableProvider<T> & Pick<{
[K in keyof T]: MethodOrProperty<T[K]>;
}, Exclude<keyof T, Reserved | symbol | keyof StubBase<never>>>;
}
declare namespace Cloudflare {
// Type of `env`.
//
// The specific project can extend `Env` by redeclaring it in project-specific files. Typescript
// will merge all declarations.
//
// You can use `wrangler types` to generate the `Env` type automatically.
interface Env {
}
// Project-specific parameters used to inform types.
//
// This interface is, again, intended to be declared in project-specific files, and then that
// declaration will be merged with this one.
//
// A project should have a declaration like this:
//
// interface GlobalProps {
// // Declares the main module's exports. Used to populate Cloudflare.Exports aka the type
// // of `ctx.exports`.
// mainModule: typeof import("my-main-module");
//
// // Declares which of the main module's exports are configured with durable storage, and
// // thus should behave as Durable Object namsepace bindings.
// durableNamespaces: "MyDurableObject" | "AnotherDurableObject";
// }
//
// You can use `wrangler types` to generate `GlobalProps` automatically.
interface GlobalProps {
}
// Evaluates to the type of a property in GlobalProps, defaulting to `Default` if it is not
// present.
type GlobalProp<K extends string, Default> = K extends keyof GlobalProps ? GlobalProps[K] : Default;
// The type of the program's main module exports, if known. Requires `GlobalProps` to declare the
// `mainModule` property.
type MainModule = GlobalProp<"mainModule", {}>;
// The type of ctx.exports, which contains loopback bindings for all top-level exports.
type Exports = {
[K in keyof MainModule]: LoopbackForExport<MainModule[K]>
// If the export is listed in `durableNamespaces`, then it is also a
// DurableObjectNamespace.
& (K extends GlobalProp<"durableNamespaces", never> ? MainModule[K] extends new (...args: any[]) => infer DoInstance ? DoInstance extends Rpc.DurableObjectBranded ? DurableObjectNamespace<DoInstance> : DurableObjectNamespace<undefined> : DurableObjectNamespace<undefined> : {});
};
}
declare namespace CloudflareWorkersModule {
export type RpcStub<T extends Rpc.Stubable> = Rpc.Stub<T>;
export const RpcStub: {
new <T extends Rpc.Stubable>(value: T): Rpc.Stub<T>;
};
export abstract class RpcTarget implements Rpc.RpcTargetBranded {
[Rpc.__RPC_TARGET_BRAND]: never;
}
// `protected` fields don't appear in `keyof`s, so can't be accessed over RPC
export abstract class WorkerEntrypoint<Env = Cloudflare.Env, Props = {}> implements Rpc.WorkerEntrypointBranded {
[Rpc.__WORKER_ENTRYPOINT_BRAND]: never;
protected ctx: ExecutionContext<Props>;
protected env: Env;
constructor(ctx: ExecutionContext, env: Env);
email?(message: ForwardableEmailMessage): void | Promise<void>;
fetch?(request: Request): Response | Promise<Response>;
connect?(socket: Socket): void | Promise<void>;
queue?(batch: MessageBatch): void | Promise<void>;
scheduled?(controller: ScheduledController): void | Promise<void>;
tail?(events: TraceItem[]): void | Promise<void>;
tailStream?(event: TailStream.TailEvent<TailStream.Onset>): TailStream.TailEventHandlerType | Promise<TailStream.TailEventHandlerType>;
test?(controller: TestController): void | Promise<void>;
trace?(traces: TraceItem[]): void | Promise<void>;
}
export abstract class DurableObject<Env = Cloudflare.Env, Props = {}> implements Rpc.DurableObjectBranded {
[Rpc.__DURABLE_OBJECT_BRAND]: never;
protected ctx: DurableObjectState<Props>;
protected env: Env;
constructor(ctx: DurableObjectState, env: Env);
alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise<void>;
fetch?(request: Request): Response | Promise<Response>;
connect?(socket: Socket): void | Promise<void>;
webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void>;
webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise<void>;
webSocketError?(ws: WebSocket, error: unknown): void | Promise<void>;
}
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
export type WorkflowDelayDuration = WorkflowSleepDuration;
export type WorkflowTimeoutDuration = WorkflowSleepDuration;
export type WorkflowRetentionDuration = WorkflowSleepDuration;
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
export type WorkflowStepSensitivity = 'output';
export type WorkflowStepConfig = {
retries?: {
limit: number;
delay: WorkflowDelayDuration | number;
backoff?: WorkflowBackoff;
};
timeout?: WorkflowTimeoutDuration | number;
sensitive?: WorkflowStepSensitivity;
};
export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>;
export type WorkflowCronSchedule = {
/** Cron expression that triggered this event. */
cron: string;
/** Timestamp of the scheduled trigger, in milliseconds since the Unix epoch. */
scheduledTime: number;
};
export type WorkflowEvent<T> = {
payload: Readonly<T>;
timestamp: Date;
instanceId: string;
workflowName: string;
schedule?: WorkflowCronSchedule;
};
export type WorkflowStepEvent<T> = {
payload: Readonly<T>;
timestamp: Date;
type: string;
sensitive?: WorkflowStepSensitivity;
};
export type WorkflowStepContext = {
step: {
name: string;
count: number;
};
attempt: number;
config: WorkflowStepConfig;
};
export type WorkflowRollbackContext<T = unknown> = {
ctx: WorkflowStepContext;
error: Error;
output: T | undefined;
/** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */
stepName: string;
};
export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>;
export type WorkflowStepRollbackOptions<T = unknown> = {
rollback: WorkflowRollbackHandler<T>;
rollbackConfig?: WorkflowStepRollbackConfig;
};
export abstract class WorkflowStep {
do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>;
sleepUntil: (name: string, timestamp: Date | number) => Promise<void>;
waitForEvent<T extends Rpc.Serializable<T>>(name: string, options: {
type: string;
timeout?: WorkflowTimeoutDuration | number;
}): Promise<WorkflowStepEvent<T>>;
}
export type WorkflowInstanceStatus = 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown';
export abstract class WorkflowEntrypoint<Env = unknown, T extends Rpc.Serializable<T> | unknown = unknown> implements Rpc.WorkflowEntrypointBranded {
[Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never;
protected ctx: ExecutionContext;
protected env: Env;
constructor(ctx: ExecutionContext, env: Env);
run(event: Readonly<WorkflowEvent<T>>, step: WorkflowStep): Promise<unknown>;
}
export function waitUntil(promise: Promise<unknown>): void;
export function withEnv(newEnv: unknown, fn: () => unknown): unknown;
export function withExports(newExports: unknown, fn: () => unknown): unknown;
export function withEnvAndExports(newEnv: unknown, newExports: unknown, fn: () => unknown): unknown;
export const env: Cloudflare.Env;
export const exports: Cloudflare.Exports;
export const cache: CacheContext;
export const tracing: Tracing;
}
declare module 'cloudflare:workers' {
export = CloudflareWorkersModule;
}
interface SecretsStoreSecret {
/**
* Get a secret from the Secrets Store, returning a string of the secret value
* if it exists, or throws an error if it does not exist
*/
get(): Promise<string>;
}
declare module "cloudflare:sockets" {
function _connect(address: string | SocketAddress, options?: SocketOptions): Socket;
export { _connect as connect };
}
/**
* Binding entrypoint for Cloudflare Stream.
*
* Usage:
* - Binding-level operations:
* `await env.STREAM.videos.upload`
* `await env.STREAM.videos.createDirectUpload`
* `await env.STREAM.videos.*`
* `await env.STREAM.watermarks.*`
* - Per-video operations:
* `await env.STREAM.video(id).downloads.*`
* `await env.STREAM.video(id).captions.*`
*
* Example usage:
* ```ts
* await env.STREAM.video(id).downloads.generate();
*
* const video = env.STREAM.video(id)
* const captions = video.captions.list();
* const videoDetails = video.details()
* ```
*/
interface StreamBinding {
/**
* Returns a handle scoped to a single video for per-video operations.
* @param id The unique identifier for the video.
* @returns A handle for per-video operations.
*/
video(id: string): StreamVideoHandle;
/**
* Uploads a new video from a provided URL.
* @param url The URL to upload from.
* @param params Optional upload parameters.
* @returns The uploaded video details.
* @throws {BadRequestError} if the upload parameter is invalid or the URL is invalid
* @throws {QuotaReachedError} if the account storage capacity is exceeded
* @throws {MaxFileSizeError} if the file size is too large
* @throws {RateLimitedError} if the server received too many requests
* @throws {AlreadyUploadedError} if a video was already uploaded to this URL
* @throws {InternalError} if an unexpected error occurs
*/
upload(url: string, params?: StreamUrlUploadParams): Promise<StreamVideo>;
/**
* Creates a direct upload that allows video uploads without an API key.
* @param params Parameters for the direct upload
* @returns The direct upload details.
* @throws {BadRequestError} if the parameters are invalid
* @throws {RateLimitedError} if the server received too many requests
* @throws {InternalError} if an unexpected error occurs
*/
createDirectUpload(params: StreamDirectUploadCreateParams): Promise<StreamDirectUpload>;
videos: StreamVideos;
watermarks: StreamWatermarks;
}
/**
* Handle for operations scoped to a single Stream video.
*/
interface StreamVideoHandle {
/**
* The unique identifier for the video.
*/
id: string;
/**
* Get a full videos details
* @returns The full video details.
* @throws {NotFoundError} if the video is not found
* @throws {InternalError} if an unexpected error occurs
*/
details(): Promise<StreamVideo>;
/**
* Update details for a single video.
* @param params The fields to update for the video.
* @returns The updated video details.
* @throws {NotFoundError} if the video is not found
* @throws {BadRequestError} if the parameters are invalid
* @throws {InternalError} if an unexpected error occurs
*/
update(params: StreamUpdateVideoParams): Promise<StreamVideo>;
/**
* Deletes a video and its copies from Cloudflare Stream.
* @returns A promise that resolves when deletion completes.
* @throws {NotFoundError} if the video is not found
* @throws {InternalError} if an unexpected error occurs
*/
delete(): Promise<void>;
/**
* Creates a signed URL token for a video.
* @returns The signed token that was created.
* @throws {InternalError} if the signing key cannot be retrieved or the token cannot be signed
*/
generateToken(): Promise<string>;
downloads: StreamScopedDownloads;
captions: StreamScopedCaptions;
}
interface StreamVideo {
/**
* The unique identifier for the video.
*/
id: string;
/**
* A user-defined identifier for the media creator.
*/
creator: string | null;
/**
* The thumbnail URL for the video.
*/
thumbnail: string;
/**
* The thumbnail timestamp percentage.
*/
thumbnailTimestampPct: number;
/**
* Indicates whether the video is ready to stream.
*/
readyToStream: boolean;
/**
* The date and time the video became ready to stream.
*/
readyToStreamAt: string | null;
/**
* Processing status information.
*/
status: StreamVideoStatus;
/**
* A user modifiable key-value store.
*/
meta: Record<string, string>;
/**
* The date and time the video was created.
*/
created: string;
/**
* The date and time the video was last modified.
*/
modified: string;
/**
* The date and time at which the video will be deleted.
*/
scheduledDeletion: string | null;
/**
* The size of the video in bytes.
*/
size: number;
/**
* The preview URL for the video.
*/
preview?: string;
/**
* Origins allowed to display the video.
*/
allowedOrigins: Array<string>;
/**
* Indicates whether signed URLs are required.
*/
requireSignedURLs: boolean | null;
/**
* The date and time the video was uploaded.
*/
uploaded: string | null;
/**
* The date and time when the upload URL expires.
*/
uploadExpiry: string | null;
/**
* The maximum size in bytes for direct uploads.
*/
maxSizeBytes: number | null;
/**
* The maximum duration in seconds for direct uploads.
*/
maxDurationSeconds: number | null;
/**
* The video duration in seconds. -1 indicates unknown.
*/
duration: number;
/**
* Input metadata for the original upload.
*/
input: StreamVideoInput;
/**
* Playback URLs for the video.
*/
hlsPlaybackUrl: string;
dashPlaybackUrl: string;
/**
* The watermark applied to the video, if any.
*/
watermark: StreamWatermark | null;
/**
* The live input id associated with the video, if any.
*/
liveInputId?: string | null;
/**
* The source video id if this is a clip.
*/
clippedFromId: string | null;
/**
* Public details associated with the video.
*/
publicDetails: StreamPublicDetails | null;
}
type StreamVideoStatus = {
/**
* The current processing state.
*/
state: string;
/**
* The current processing step.
*/
step?: string;
/**
* The percent complete as a string.
*/
pctComplete?: string;
/**
* An error reason code, if applicable.
*/
errorReasonCode: string;
/**
* An error reason text, if applicable.
*/
errorReasonText: string;
};
type StreamVideoInput = {
/**
* The input width in pixels.
*/
width: number;
/**
* The input height in pixels.
*/
height: number;
};
type StreamPublicDetails = {
/**
* The public title for the video.
*/
title: string | null;
/**
* The public share link.
*/
share_link: string | null;
/**
* The public channel link.
*/
channel_link: string | null;
/**
* The public logo URL.
*/
logo: string | null;
};
type StreamDirectUpload = {
/**
* The URL an unauthenticated upload can use for a single multipart request.
*/
uploadURL: string;
/**
* A Cloudflare-generated unique identifier for a media item.
*/
id: string;
/**
* The watermark profile applied to the upload.
*/
watermark: StreamWatermark | null;
/**
* The scheduled deletion time, if any.
*/
scheduledDeletion: string | null;
};
type StreamDirectUploadCreateParams = {
/**
* The maximum duration in seconds for a video upload.
*/
maxDurationSeconds: number;
/**
* The date and time after upload when videos will not be accepted.
*/
expiry?: string;
/**
* A user-defined identifier for the media creator.
*/
creator?: string;
/**
* A user modifiable key-value store used to reference other systems of record for
* managing videos.
*/
meta?: Record<string, string>;
/**
* Lists the origins allowed to display the video.
*/
allowedOrigins?: Array<string>;
/**
* Indicates whether the video can be accessed using the id. When set to `true`,
* a signed token must be generated with a signing key to view the video.
*/
requireSignedURLs?: boolean;
/**
* The thumbnail timestamp percentage.
*/
thumbnailTimestampPct?: number;
/**
* The date and time at which the video will be deleted. Include `null` to remove
* a scheduled deletion.
*/
scheduledDeletion?: string | null;
/**
* The watermark profile to apply.
*/
watermark?: StreamDirectUploadWatermark;
};
type StreamDirectUploadWatermark = {
/**
* The unique identifier for the watermark profile.
*/
id: string;
};
type StreamUrlUploadParams = {
/**
* Lists the origins allowed to display the video. Enter allowed origin
* domains in an array and use `*` for wildcard subdomains. Empty arrays allow the
* video to be viewed on any origin.
*/
allowedOrigins?: Array<string>;
/**
* A user-defined identifier for the media creator.
*/
creator?: string;
/**
* A user modifiable key-value store used to reference other systems of
* record for managing videos.
*/
meta?: Record<string, string>;
/**
* Indicates whether the video can be a accessed using the id. When
* set to `true`, a signed token must be generated with a signing key to view the
* video.
*/
requireSignedURLs?: boolean;
/**
* Indicates the date and time at which the video will be deleted. Omit
* the field to indicate no change, or include with a `null` value to remove an
* existing scheduled deletion. If specified, must be at least 30 days from upload
* time.
*/
scheduledDeletion?: string | null;
/**
* The timestamp for a thumbnail image calculated as a percentage value
* of the video's duration. To convert from a second-wise timestamp to a
* percentage, divide the desired timestamp by the total duration of the video. If
* this value is not set, the default thumbnail image is taken from 0s of the
* video.
*/
thumbnailTimestampPct?: number;
/**
* The identifier for the watermark profile
*/
watermarkId?: string;
};
interface StreamScopedCaptions {
/**
* Uploads the caption or subtitle file to the endpoint for a specific BCP47 language.
* One caption or subtitle file per language is allowed.
* @param language The BCP 47 language tag for the caption or subtitle.
* @param input The caption or subtitle stream to upload.
* @returns The created caption entry.
* @throws {NotFoundError} if the video is not found
* @throws {BadRequestError} if the language or file is invalid
* @throws {InternalError} if an unexpected error occurs
*/
upload(language: string, input: ReadableStream): Promise<StreamCaption>;
/**
* Generate captions or subtitles for the provided language via AI.
* @param language The BCP 47 language tag to generate.
* @returns The generated caption entry.
* @throws {NotFoundError} if the video is not found
* @throws {BadRequestError} if the language is invalid
* @throws {StreamError} if a generated caption already exists
* @throws {StreamError} if the video duration is too long
* @throws {StreamError} if the video is missing audio
* @throws {StreamError} if the requested language is not supported
* @throws {InternalError} if an unexpected error occurs
*/
generate(language: string): Promise<StreamCaption>;
/**
* Lists the captions or subtitles.
* Use the language parameter to filter by a specific language.
* @param language The optional BCP 47 language tag to filter by.
* @returns The list of captions or subtitles.
* @throws {NotFoundError} if the video or caption is not found
* @throws {InternalError} if an unexpected error occurs
*/
list(language?: string): Promise<StreamCaption[]>;
/**
* Removes the captions or subtitles from a video.
* @param language The BCP 47 language tag to remove.
* @returns A promise that resolves when deletion completes.
* @throws {NotFoundError} if the video or caption is not found
* @throws {InternalError} if an unexpected error occurs
*/
delete(language: string): Promise<void>;
}
interface StreamScopedDownloads {
/**
* Generates a download for a video when a video is ready to view. Available
* types are `default` and `audio`. Defaults to `default` when omitted.
* @param downloadType The download type to create.
* @returns The current downloads for the video.
* @throws {NotFoundError} if the video is not found
* @throws {BadRequestError} if the download type is invalid
* @throws {StreamError} if the video duration is too long to generate a download
* @throws {StreamError} if the video is not ready to stream
* @throws {InternalError} if an unexpected error occurs
*/
generate(downloadType?: StreamDownloadType): Promise<StreamDownloadGetResponse>;
/**
* Lists the downloads created for a video.
* @returns The current downloads for the video.
* @throws {NotFoundError} if the video or downloads are not found
* @throws {InternalError} if an unexpected error occurs
*/
get(): Promise<StreamDownloadGetResponse>;
/**
* Delete the downloads for a video. Available types are `default` and `audio`.
* Defaults to `default` when omitted.
* @param downloadType The download type to delete.
* @returns A promise that resolves when deletion completes.
* @throws {NotFoundError} if the video or downloads are not found
* @throws {InternalError} if an unexpected error occurs
*/
delete(downloadType?: StreamDownloadType): Promise<void>;
}
interface StreamVideos {
/**
* Lists all videos in a users account.
* @returns The list of videos.
* @throws {BadRequestError} if the parameters are invalid
* @throws {InternalError} if an unexpected error occurs
*/
list(params?: StreamVideosListParams): Promise<StreamVideo[]>;
}
interface StreamWatermarks {
/**
* Generate a new watermark profile
* @param input The image stream to upload
* @param params The watermark creation parameters.
* @returns The created watermark profile.
* @throws {BadRequestError} if the parameters are invalid
* @throws {InvalidURLError} if the URL is invalid
* @throws {TooManyWatermarksError} if the number of allowed watermarks is reached
* @throws {InternalError} if an unexpected error occurs
*/
generate(input: ReadableStream, params: StreamWatermarkCreateParams): Promise<StreamWatermark>;
/**
* Generate a new watermark profile
* @param url The image url to upload
* @param params The watermark creation parameters.
* @returns The created watermark profile.
* @throws {BadRequestError} if the parameters are invalid
* @throws {InvalidURLError} if the URL is invalid
* @throws {TooManyWatermarksError} if the number of allowed watermarks is reached
* @throws {InternalError} if an unexpected error occurs
*/
generate(url: string, params: StreamWatermarkCreateParams): Promise<StreamWatermark>;
/**
* Lists all watermark profiles for an account.
* @returns The list of watermark profiles.
* @throws {InternalError} if an unexpected error occurs
*/
list(): Promise<StreamWatermark[]>;
/**
* Retrieves details for a single watermark profile.
* @param watermarkId The watermark profile identifier.
* @returns The watermark profile details.
* @throws {NotFoundError} if the watermark is not found
* @throws {InternalError} if an unexpected error occurs
*/
get(watermarkId: string): Promise<StreamWatermark>;
/**
* Deletes a watermark profile.
* @param watermarkId The watermark profile identifier.
* @returns A promise that resolves when deletion completes.
* @throws {NotFoundError} if the watermark is not found
* @throws {InternalError} if an unexpected error occurs
*/
delete(watermarkId: string): Promise<void>;
}
type StreamUpdateVideoParams = {
/**
* Lists the origins allowed to display the video. Enter allowed origin
* domains in an array and use `*` for wildcard subdomains. Empty arrays allow the
* video to be viewed on any origin.
*/
allowedOrigins?: Array<string>;
/**
* A user-defined identifier for the media creator.
*/
creator?: string;
/**
* The maximum duration in seconds for a video upload. Can be set for a
* video that is not yet uploaded to limit its duration. Uploads that exceed the
* specified duration will fail during processing. A value of `-1` means the value
* is unknown.
*/
maxDurationSeconds?: number;
/**
* A user modifiable key-value store used to reference other systems of
* record for managing videos.
*/
meta?: Record<string, string>;
/**
* Indicates whether the video can be a accessed using the id. When
* set to `true`, a signed token must be generated with a signing key to view the
* video.
*/
requireSignedURLs?: boolean;
/**
* Indicates the date and time at which the video will be deleted. Omit
* the field to indicate no change, or include with a `null` value to remove an
* existing scheduled deletion. If specified, must be at least 30 days from upload
* time.
*/
scheduledDeletion?: string | null;
/**
* The timestamp for a thumbnail image calculated as a percentage value
* of the video's duration. To convert from a second-wise timestamp to a
* percentage, divide the desired timestamp by the total duration of the video. If
* this value is not set, the default thumbnail image is taken from 0s of the
* video.
*/
thumbnailTimestampPct?: number;
};
type StreamCaption = {
/**
* Whether the caption was generated via AI.
*/
generated?: boolean;
/**
* The language label displayed in the native language to users.
*/
label: string;
/**
* The language tag in BCP 47 format.
*/
language: string;
/**
* The status of a generated caption.
*/
status?: 'ready' | 'inprogress' | 'error';
};
type StreamDownloadStatus = 'ready' | 'inprogress' | 'error';
type StreamDownloadType = 'default' | 'audio';
type StreamDownload = {
/**
* Indicates the progress as a percentage between 0 and 100.
*/
percentComplete: number;
/**
* The status of a generated download.
*/
status: StreamDownloadStatus;
/**
* The URL to access the generated download.
*/
url?: string;
};
/**
* An object with download type keys. Each key is optional and only present if that
* download type has been created.
*/
type StreamDownloadGetResponse = {
/**
* The audio-only download. Only present if this download type has been created.
*/
audio?: StreamDownload;
/**
* The default video download. Only present if this download type has been created.
*/
default?: StreamDownload;
};
type StreamWatermarkPosition = 'upperRight' | 'upperLeft' | 'lowerLeft' | 'lowerRight' | 'center';
type StreamWatermark = {
/**
* The unique identifier for a watermark profile.
*/
id: string;
/**
* The size of the image in bytes.
*/
size: number;
/**
* The height of the image in pixels.
*/
height: number;
/**
* The width of the image in pixels.
*/
width: number;
/**
* The date and a time a watermark profile was created.
*/
created: string;
/**
* The source URL for a downloaded image. If the watermark profile was created via
* direct upload, this field is null.
*/
downloadedFrom: string | null;
/**
* A short description of the watermark profile.
*/
name: string;
/**
* The translucency of the image. A value of `0.0` makes the image completely
* transparent, and `1.0` makes the image completely opaque. Note that if the image
* is already semi-transparent, setting this to `1.0` will not make the image
* completely opaque.
*/
opacity: number;
/**
* The whitespace between the adjacent edges (determined by position) of the video
* and the image. `0.0` indicates no padding, and `1.0` indicates a fully padded
* video width or length, as determined by the algorithm.
*/
padding: number;
/**
* The size of the image relative to the overall size of the video. This parameter
* will adapt to horizontal and vertical videos automatically. `0.0` indicates no
* scaling (use the size of the image as-is), and `1.0 `fills the entire video.
*/
scale: number;
/**
* The location of the image. Valid positions are: `upperRight`, `upperLeft`,
* `lowerLeft`, `lowerRight`, and `center`. Note that `center` ignores the
* `padding` parameter.
*/
position: StreamWatermarkPosition;
};
type StreamWatermarkCreateParams = {
/**
* A short description of the watermark profile.
*/
name?: string;
/**
* The translucency of the image. A value of `0.0` makes the image completely
* transparent, and `1.0` makes the image completely opaque. Note that if the
* image is already semi-transparent, setting this to `1.0` will not make the
* image completely opaque.
*/
opacity?: number;
/**
* The whitespace between the adjacent edges (determined by position) of the
* video and the image. `0.0` indicates no padding, and `1.0` indicates a fully
* padded video width or length, as determined by the algorithm.
*/
padding?: number;
/**
* The size of the image relative to the overall size of the video. This
* parameter will adapt to horizontal and vertical videos automatically. `0.0`
* indicates no scaling (use the size of the image as-is), and `1.0 `fills the
* entire video.
*/
scale?: number;
/**
* The location of the image.
*/
position?: StreamWatermarkPosition;
};
type StreamVideosListParams = {
/**
* The maximum number of videos to return.
*/
limit?: number;
/**
* Return videos created before this timestamp.
* (RFC3339/RFC3339Nano)
*/
before?: string;
/**
* Comparison operator for the `before` field.
* @default 'lt'
*/
beforeComp?: StreamPaginationComparison;
/**
* Return videos created after this timestamp.
* (RFC3339/RFC3339Nano)
*/
after?: string;
/**
* Comparison operator for the `after` field.
* @default 'gte'
*/
afterComp?: StreamPaginationComparison;
};
type StreamPaginationComparison = 'eq' | 'gt' | 'gte' | 'lt' | 'lte';
/**
* Error object for Stream binding operations.
*/
interface StreamError extends Error {
readonly code: number;
readonly statusCode: number;
readonly message: string;
readonly stack?: string;
}
interface InternalError extends StreamError {
name: 'InternalError';
}
interface BadRequestError extends StreamError {
name: 'BadRequestError';
}
interface NotFoundError extends StreamError {
name: 'NotFoundError';
}
interface ForbiddenError extends StreamError {
name: 'ForbiddenError';
}
interface RateLimitedError extends StreamError {
name: 'RateLimitedError';
}
interface QuotaReachedError extends StreamError {
name: 'QuotaReachedError';
}
interface MaxFileSizeError extends StreamError {
name: 'MaxFileSizeError';
}
interface InvalidURLError extends StreamError {
name: 'InvalidURLError';
}
interface AlreadyUploadedError extends StreamError {
name: 'AlreadyUploadedError';
}
interface TooManyWatermarksError extends StreamError {
name: 'TooManyWatermarksError';
}
type MarkdownDocument = {
name: string;
blob: Blob;
};
type ConversionResponse = {
id: string;
name: string;
mimeType: string;
format: 'markdown';
tokens: number;
data: string;
} | {
id: string;
name: string;
mimeType: string;
format: 'error';
error: string;
};
type ImageConversionOptions = {
descriptionLanguage?: 'en' | 'es' | 'fr' | 'it' | 'pt' | 'de';
};
type EmbeddedImageConversionOptions = ImageConversionOptions & {
convert?: boolean;
maxConvertedImages?: number;
};
type ConversionOptions = {
html?: {
images?: EmbeddedImageConversionOptions & {
convertOGImage?: boolean;
};
hostname?: string;
cssSelector?: string;
};
docx?: {
images?: EmbeddedImageConversionOptions;
};
image?: ImageConversionOptions;
pdf?: {
images?: EmbeddedImageConversionOptions;
metadata?: boolean;
};
};
type ConversionRequestOptions = {
gateway?: GatewayOptions;
extraHeaders?: object;
conversionOptions?: ConversionOptions;
};
type SupportedFileFormat = {
mimeType: string;
extension: string;
};
declare abstract class ToMarkdownService {
transform(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise<ConversionResponse[]>;
transform(files: MarkdownDocument, options?: ConversionRequestOptions): Promise<ConversionResponse>;
supported(): Promise<SupportedFileFormat[]>;
}
declare namespace TailStream {
interface Header {
readonly name: string;
readonly value: string;
}
interface FetchEventInfo {
readonly type: "fetch";
readonly method: string;
readonly url: string;
readonly cfJson?: object;
readonly headers: Header[];
}
interface JsRpcEventInfo {
readonly type: "jsrpc";
}
interface ScheduledEventInfo {
readonly type: "scheduled";
readonly scheduledTime: Date;
readonly cron: string;
}
interface AlarmEventInfo {
readonly type: "alarm";
readonly scheduledTime: Date;
}
interface QueueEventInfo {
readonly type: "queue";
readonly queueName: string;
readonly batchSize: number;
}
interface EmailEventInfo {
readonly type: "email";
readonly mailFrom: string;
readonly rcptTo: string;
readonly rawSize: number;
}
interface TraceEventInfo {
readonly type: "trace";
readonly traces: (string | null)[];
}
interface HibernatableWebSocketEventInfoMessage {
readonly type: "message";
}
interface HibernatableWebSocketEventInfoError {
readonly type: "error";
}
interface HibernatableWebSocketEventInfoClose {
readonly type: "close";
readonly code: number;
readonly wasClean: boolean;
}
interface HibernatableWebSocketEventInfo {
readonly type: "hibernatableWebSocket";
readonly info: HibernatableWebSocketEventInfoClose | HibernatableWebSocketEventInfoError | HibernatableWebSocketEventInfoMessage;
}
interface CustomEventInfo {
readonly type: "custom";
}
interface FetchResponseInfo {
readonly type: "fetch";
readonly statusCode: number;
}
interface ConnectEventInfo {
readonly type: "connect";
}
type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime";
interface ScriptVersion {
readonly id: string;
readonly tag?: string;
readonly message?: string;
}
interface TracePreviewInfo {
readonly id: string;
readonly slug: string;
readonly name: string;
}
interface Onset {
readonly type: "onset";
readonly attributes: Attribute[];
// id for the span being opened by this Onset event.
readonly spanId: string;
readonly dispatchNamespace?: string;
readonly entrypoint?: string;
readonly executionModel: string;
readonly scriptName?: string;
readonly scriptTags?: string[];
readonly scriptVersion?: ScriptVersion;
readonly preview?: TracePreviewInfo;
readonly info: FetchEventInfo | ConnectEventInfo | JsRpcEventInfo | ScheduledEventInfo | AlarmEventInfo | QueueEventInfo | EmailEventInfo | TraceEventInfo | HibernatableWebSocketEventInfo | CustomEventInfo;
}
interface Outcome {
readonly type: "outcome";
readonly outcome: EventOutcome;
readonly cpuTime: number;
readonly wallTime: number;
}
interface SpanOpen {
readonly type: "spanOpen";
readonly name: string;
// id for the span being opened by this SpanOpen event.
readonly spanId: string;
readonly info?: FetchEventInfo | JsRpcEventInfo | Attributes;
}
interface SpanClose {
readonly type: "spanClose";
readonly outcome: EventOutcome;
}
interface DiagnosticChannelEvent {
readonly type: "diagnosticChannel";
readonly channel: string;
readonly message: any;
}
interface Exception {
readonly type: "exception";
readonly name: string;
readonly message: string;
readonly stack?: string;
}
interface Log {
readonly type: "log";
readonly level: "debug" | "error" | "info" | "log" | "warn";
readonly message: object;
}
interface DroppedEventsDiagnostic {
readonly diagnosticsType: "droppedEvents";
readonly count: number;
}
interface StreamDiagnostic {
readonly type: 'streamDiagnostic';
// To add new diagnostic types, define a new interface and add it to this union type.
readonly diagnostic: DroppedEventsDiagnostic;
}
// This marks the worker handler return information.
// This is separate from Outcome because the worker invocation can live for a long time after
// returning. For example - Websockets that return an http upgrade response but then continue
// streaming information or SSE http connections.
interface Return {
readonly type: "return";
readonly info?: FetchResponseInfo;
}
interface Attribute {
readonly name: string;
readonly value: string | string[] | boolean | boolean[] | number | number[] | bigint | bigint[];
}
interface Attributes {
readonly type: "attributes";
readonly info: Attribute[];
}
type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | StreamDiagnostic | Return | Attributes;
// Context in which this trace event lives.
interface SpanContext {
// Single id for the entire top-level invocation
// This should be a new traceId for the first worker stage invoked in the eyeball request and then
// same-account service-bindings should reuse the same traceId but cross-account service-bindings
// should use a new traceId.
readonly traceId: string;
// spanId in which this event is handled
// for Onset and SpanOpen events this would be the parent span id
// for Outcome and SpanClose these this would be the span id of the opening Onset and SpanOpen events
// For Hibernate and Mark this would be the span under which they were emitted.
// spanId is not set ONLY if:
// 1. This is an Onset event
// 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation)
readonly spanId?: string;
// W3C trace flags from an upstream traceparent. Absent when no upstream
// sampling decision was made.
readonly traceFlags?: number;
}
interface TailEvent<Event extends EventType> {
// invocation id of the currently invoked worker stage.
// invocation id will always be unique to every Onset event and will be the same until the Outcome event.
readonly invocationId: string;
// Inherited spanContext for this event.
readonly spanContext: SpanContext;
readonly timestamp: Date;
readonly sequence: number;
readonly event: Event;
}
type TailEventHandler<Event extends EventType = EventType> = (event: TailEvent<Event>) => void | Promise<void>;
type TailEventHandlerObject = {
outcome?: TailEventHandler<Outcome>;
spanOpen?: TailEventHandler<SpanOpen>;
spanClose?: TailEventHandler<SpanClose>;
diagnosticChannel?: TailEventHandler<DiagnosticChannelEvent>;
exception?: TailEventHandler<Exception>;
log?: TailEventHandler<Log>;
return?: TailEventHandler<Return>;
attributes?: TailEventHandler<Attributes>;
};
type TailEventHandlerType = TailEventHandler | TailEventHandlerObject;
}
// Copyright (c) 2022-2023 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
/**
* Data types supported for holding vector metadata.
*/
type VectorizeVectorMetadataValue = string | number | boolean | string[];
/**
* Additional information to associate with a vector.
*/
type VectorizeVectorMetadata = VectorizeVectorMetadataValue | Record<string, VectorizeVectorMetadataValue>;
type VectorFloatArray = Float32Array | Float64Array;
interface VectorizeError {
code?: number;
error: string;
}
/**
* Comparison logic/operation to use for metadata filtering.
*
* This list is expected to grow as support for more operations are released.
*/
type VectorizeVectorMetadataFilterOp = '$eq' | '$ne' | '$lt' | '$lte' | '$gt' | '$gte';
type VectorizeVectorMetadataFilterCollectionOp = '$in' | '$nin';
/**
* Filter criteria for vector metadata used to limit the retrieved query result set.
*/
type VectorizeVectorMetadataFilter = {
[field: string]: Exclude<VectorizeVectorMetadataValue, string[]> | null | {
[Op in VectorizeVectorMetadataFilterOp]?: Exclude<VectorizeVectorMetadataValue, string[]> | null;
} | {
[Op in VectorizeVectorMetadataFilterCollectionOp]?: Exclude<VectorizeVectorMetadataValue, string[]>[];
};
};
/**
* Supported distance metrics for an index.
* Distance metrics determine how other "similar" vectors are determined.
*/
type VectorizeDistanceMetric = "euclidean" | "cosine" | "dot-product";
/**
* Metadata return levels for a Vectorize query.
*
* Default to "none".
*
* @property all Full metadata for the vector return set, including all fields (including those un-indexed) without truncation. This is a more expensive retrieval, as it requires additional fetching & reading of un-indexed data.
* @property indexed Return all metadata fields configured for indexing in the vector return set. This level of retrieval is "free" in that no additional overhead is incurred returning this data. However, note that indexed metadata is subject to truncation (especially for larger strings).
* @property none No indexed metadata will be returned.
*/
type VectorizeMetadataRetrievalLevel = "all" | "indexed" | "none";
interface VectorizeQueryOptions {
topK?: number;
namespace?: string;
returnValues?: boolean;
returnMetadata?: boolean | VectorizeMetadataRetrievalLevel;
filter?: VectorizeVectorMetadataFilter;
}
/**
* Information about the configuration of an index.
*/
type VectorizeIndexConfig = {
dimensions: number;
metric: VectorizeDistanceMetric;
} | {
preset: string; // keep this generic, as we'll be adding more presets in the future and this is only in a read capacity
};
/**
* Metadata about an existing index.
*
* This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.
* See {@link VectorizeIndexInfo} for its post-beta equivalent.
*/
interface VectorizeIndexDetails {
/** The unique ID of the index */
readonly id: string;
/** The name of the index. */
name: string;
/** (optional) A human readable description for the index. */
description?: string;
/** The index configuration, including the dimension size and distance metric. */
config: VectorizeIndexConfig;
/** The number of records containing vectors within the index. */
vectorsCount: number;
}
/**
* Metadata about an existing index.
*/
interface VectorizeIndexInfo {
/** The number of records containing vectors within the index. */
vectorCount: number;
/** Number of dimensions the index has been configured for. */
dimensions: number;
/** ISO 8601 datetime of the last processed mutation on in the index. All changes before this mutation will be reflected in the index state. */
processedUpToDatetime: number;
/** UUIDv4 of the last mutation processed by the index. All changes before this mutation will be reflected in the index state. */
processedUpToMutation: number;
}
/**
* Represents a single vector value set along with its associated metadata.
*/
interface VectorizeVector {
/** The ID for the vector. This can be user-defined, and must be unique. It should uniquely identify the object, and is best set based on the ID of what the vector represents. */
id: string;
/** The vector values */
values: VectorFloatArray | number[];
/** The namespace this vector belongs to. */
namespace?: string;
/** Metadata associated with the vector. Includes the values of other fields and potentially additional details. */
metadata?: Record<string, VectorizeVectorMetadata>;
}
/**
* Represents a matched vector for a query along with its score and (if specified) the matching vector information.
*/
type VectorizeMatch = Pick<Partial<VectorizeVector>, "values"> & Omit<VectorizeVector, "values"> & {
/** The score or rank for similarity, when returned as a result */
score: number;
};
/**
* A set of matching {@link VectorizeMatch} for a particular query.
*/
interface VectorizeMatches {
matches: VectorizeMatch[];
count: number;
}
/**
* Results of an operation that performed a mutation on a set of vectors.
* Here, `ids` is a list of vectors that were successfully processed.
*
* This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.
* See {@link VectorizeAsyncMutation} for its post-beta equivalent.
*/
interface VectorizeVectorMutation {
/* List of ids of vectors that were successfully processed. */
ids: string[];
/* Total count of the number of processed vectors. */
count: number;
}
/**
* Result type indicating a mutation on the Vectorize Index.
* Actual mutations are processed async where the `mutationId` is the unique identifier for the operation.
*/
interface VectorizeAsyncMutation {
/** The unique identifier for the async mutation operation containing the changeset. */
mutationId: string;
}
/**
* A Vectorize Vector Search Index for querying vectors/embeddings.
*
* This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.
* See {@link Vectorize} for its new implementation.
*/
declare abstract class VectorizeIndex {
/**
* Get information about the currently bound index.
* @returns A promise that resolves with information about the current index.
*/
public describe(): Promise<VectorizeIndexDetails>;
/**
* Use the provided vector to perform a similarity search across the index.
* @param vector Input vector that will be used to drive the similarity search.
* @param options Configuration options to massage the returned data.
* @returns A promise that resolves with matched and scored vectors.
*/
public query(vector: VectorFloatArray | number[], options?: VectorizeQueryOptions): Promise<VectorizeMatches>;
/**
* Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown.
* @param vectors List of vectors that will be inserted.
* @returns A promise that resolves with the ids & count of records that were successfully processed.
*/
public insert(vectors: VectorizeVector[]): Promise<VectorizeVectorMutation>;
/**
* Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values.
* @param vectors List of vectors that will be upserted.
* @returns A promise that resolves with the ids & count of records that were successfully processed.
*/
public upsert(vectors: VectorizeVector[]): Promise<VectorizeVectorMutation>;
/**
* Delete a list of vectors with a matching id.
* @param ids List of vector ids that should be deleted.
* @returns A promise that resolves with the ids & count of records that were successfully processed (and thus deleted).
*/
public deleteByIds(ids: string[]): Promise<VectorizeVectorMutation>;
/**
* Get a list of vectors with a matching id.
* @param ids List of vector ids that should be returned.
* @returns A promise that resolves with the raw unscored vectors matching the id set.
*/
public getByIds(ids: string[]): Promise<VectorizeVector[]>;
}
/**
* A Vectorize Vector Search Index for querying vectors/embeddings.
*
* Mutations in this version are async, returning a mutation id.
*/
declare abstract class Vectorize {
/**
* Get information about the currently bound index.
* @returns A promise that resolves with information about the current index.
*/
public describe(): Promise<VectorizeIndexInfo>;
/**
* Use the provided vector to perform a similarity search across the index.
* @param vector Input vector that will be used to drive the similarity search.
* @param options Configuration options to massage the returned data.
* @returns A promise that resolves with matched and scored vectors.
*/
public query(vector: VectorFloatArray | number[], options?: VectorizeQueryOptions): Promise<VectorizeMatches>;
/**
* Use the provided vector-id to perform a similarity search across the index.
* @param vectorId Id for a vector in the index against which the index should be queried.
* @param options Configuration options to massage the returned data.
* @returns A promise that resolves with matched and scored vectors.
*/
public queryById(vectorId: string, options?: VectorizeQueryOptions): Promise<VectorizeMatches>;
/**
* Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown.
* @param vectors List of vectors that will be inserted.
* @returns A promise that resolves with a unique identifier of a mutation containing the insert changeset.
*/
public insert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>;
/**
* Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values.
* @param vectors List of vectors that will be upserted.
* @returns A promise that resolves with a unique identifier of a mutation containing the upsert changeset.
*/
public upsert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>;
/**
* Delete a list of vectors with a matching id.
* @param ids List of vector ids that should be deleted.
* @returns A promise that resolves with a unique identifier of a mutation containing the delete changeset.
*/
public deleteByIds(ids: string[]): Promise<VectorizeAsyncMutation>;
/**
* Get a list of vectors with a matching id.
* @param ids List of vector ids that should be returned.
* @returns A promise that resolves with the raw unscored vectors matching the id set.
*/
public getByIds(ids: string[]): Promise<VectorizeVector[]>;
}
/**
* The interface for "version_metadata" binding
* providing metadata about the Worker Version using this binding.
*/
type WorkerVersionMetadata = {
/** The ID of the Worker Version using this binding */
id: string;
/** The tag of the Worker Version using this binding */
tag: string;
/** The timestamp of when the Worker Version was uploaded */
timestamp: string;
};
// ============ Web Search Request Types ============
/**
* Options for a Web Search query.
*/
type WebSearchSearchOptions = {
/** The search query. */
query: string;
/**
* Maximum number of results to return. Defaults to 10, capped at 20.
* The actual count may be lower if fewer matches exist.
*/
limit?: number;
};
// ============ Web Search Response Types ============
/**
* A single Web Search result.
*
* Web Search is discovery-only -- results carry catalog metadata about a page
* but never the page body. To read a result's content the caller invokes the
* global `fetch()` API against the result's `url`, at which point the
* destination's own access controls apply (including Cloudflare Pay-per-Crawl).
*/
type WebSearchResult = {
/** Canonical URL. */
url: string;
/** Page title. */
title: string;
/** Page-level description. May be absent. */
description?: string;
/**
* Last-modified date for the page, when known. Naive (no timezone)
* ISO-8601 datetime, e.g. `"2025-11-30T04:39:48"`.
*/
lastModifiedDate?: string;
/**
* Page meta image URL (typically the `og:image`). May be absent.
*/
imageUrl?: string;
/** Optional favicon URL for UI hints. */
faviconUrl?: string;
};
/**
* Per-response metadata for a Web Search query. Carries operational
* fields useful for support and debugging.
*/
type WebSearchResponseMetadata = {
/** The query that was executed. */
query: string;
/** Opaque request identifier used for support and debugging. */
requestId: string;
/** End-to-end latency for this search request, in milliseconds. */
latencyMs: number;
};
/**
* Response from a Web Search query.
*/
type WebSearchSearchResponse = {
items: WebSearchResult[];
metadata: WebSearchResponseMetadata;
};
// ============ Web Search Binding Class ============
/**
* Cloudflare Web Search binding.
*
* Discovery-only primitive for agents and Workers. Returns URLs and catalog
* metadata for a query; never returns page content or excerpts. To read a
* result's body, fetch the URL with the global `fetch()` API.
*
* Declared in wrangler with a single object (there is exactly one corpus, the
* public web, so there is no name, namespace, or instance to specify):
*
* ```jsonc
* { "web_search": { "binding": "WEBSEARCH" } }
* ```
*
* @example
* ```ts
* const { items, metadata } = await env.WEBSEARCH.search({
* query: "Cloudflare Workers",
* });
*
* const top = items[0];
* console.log(top.url, top.title, metadata.latencyMs);
*
* // Read content yourself; pay-per-crawl and other publisher
* // controls apply at the fetch site, not at search time.
* const page = await fetch(top.url);
* ```
*/
declare abstract class WebSearch {
/**
* Run a Web Search query.
* @param options Search options. Only `query` is required.
* @returns The matching results plus per-response metadata.
*/
search(options: WebSearchSearchOptions): Promise<WebSearchSearchResponse>;
}
interface DynamicDispatchLimits {
/**
* Limit CPU time in milliseconds.
*/
cpuMs?: number;
/**
* Limit number of subrequests.
*/
subRequests?: number;
}
interface DynamicDispatchOptions {
/**
* Limit resources of invoked Worker script.
*/
limits?: DynamicDispatchLimits;
/**
* Arguments for outbound Worker script, if configured.
*/
outbound?: {
[key: string]: any;
};
}
interface DispatchNamespace {
/**
* @param name Name of the Worker script.
* @param args Arguments to Worker script.
* @param options Options for Dynamic Dispatch invocation.
* @returns A Fetcher object that allows you to send requests to the Worker script.
* @throws If the Worker script does not exist in this dispatch namespace, an error will be thrown.
*/
get(name: string, args?: {
[key: string]: any;
}, options?: DynamicDispatchOptions): Fetcher;
}
declare module 'cloudflare:workflows' {
/**
* NonRetryableError allows for a user to throw a fatal error
* that makes a Workflow instance fail immediately without triggering a retry
*/
export class NonRetryableError extends Error {
public constructor(message: string, name?: string);
}
}
declare abstract class Workflow<PARAMS = unknown> {
/**
* Get a handle to an existing instance of the Workflow.
* @param id Id for the instance of this Workflow
* @returns A promise that resolves with a handle for the Instance
*/
public get(id: string): Promise<WorkflowInstance>;
/**
* Create a new instance and return a handle to it. If a provided id exists, an error will be thrown.
* @param options Options when creating an instance including id and params
* @returns A promise that resolves with a handle for the Instance
*/
public create(options?: WorkflowInstanceCreateOptions<PARAMS>): Promise<WorkflowInstance>;
/**
* Create a batch of instances and return handle for all of them. If a provided id exists, an error will be thrown.
* `createBatch` is limited at 100 instances at a time or when the RPC limit for the batch (1MiB) is reached.
* @param batch List of Options when creating an instance including name and params
* @returns A promise that resolves with a list of handles for the created instances.
*/
public createBatch(batch: WorkflowInstanceCreateOptions<PARAMS>[]): Promise<WorkflowInstance[]>;
}
type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
type WorkflowRetentionDuration = WorkflowSleepDuration;
interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
/**
* An id for your Workflow instance. Must be unique within the Workflow.
*/
id?: string;
/**
* The event payload the Workflow instance is triggered with
*/
params?: PARAMS;
/**
* The retention policy for Workflow instance.
* Defaults to the maximum retention period available for the owner's account.
*/
retention?: {
successRetention?: WorkflowRetentionDuration;
errorRetention?: WorkflowRetentionDuration;
};
}
type InstanceStatus = {
status: 'queued' // means that instance is waiting to be started (see concurrency limits)
| 'running' | 'paused' | 'errored' | 'terminated' // user terminated the instance while it was running
| 'complete' | 'waiting' // instance is hibernating and waiting for sleep or event to finish
| 'waitingForPause' // instance is finishing the current work to pause
| 'unknown';
error?: {
name: string;
message: string;
};
output?: unknown;
};
interface WorkflowError {
code?: number;
message: string;
}
interface WorkflowInstanceRestartOptions {
/**
* Restart from a specific step. If omitted, the instance restarts from the beginning.
* The step must exist in the instance's execution history.
*/
from?: {
/**
* The step name as defined in your workflow code.
*/
name: string;
/**
* 1-indexed occurrence of this step name. Use when the same step name appears multiple times (e.g. in a loop).
* @default 1
*/
count?: number;
/**
* Step type filter. Use when different step types share the same name.
*/
type?: 'do' | 'sleep' | 'waitForEvent';
};
}
declare abstract class WorkflowInstance {
public id: string;
/**
* Pause the instance.
*/
public pause(): Promise<void>;
/**
* Resume the instance. If it is already running, an error will be thrown.
*/
public resume(): Promise<void>;
/**
* Terminate the instance. If it is errored, terminated or complete, an error will be thrown.
*/
public terminate(): Promise<void>;
/**
* Restart the instance. Optionally restart from a specific step, preserving
* cached results for all steps before it.
* @param options Options for the restart, including an optional step to restart from.
*/
public restart(options?: WorkflowInstanceRestartOptions): Promise<void>;
/**
* Returns the current status of the instance.
*/
public status(): Promise<InstanceStatus>;
/**
* Send an event to this instance.
*/
public sendEvent({ type, payload, }: {
type: string;
payload: unknown;
}): Promise<void>;
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "ai",
"main": "src/ai.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
}
],
"upload_source_maps": true,
"observability": {
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
}
}
+23
View File
@@ -0,0 +1,23 @@
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
-- tables in this shared database), a report is genuinely columnar, so it gets a
-- normal relational table. Owned by the `api` worker; generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One row per submitted report; nothing updates or dedupes them, so the table is
-- an append-only log of what players sent. `reporter_player_id` comes from the
-- caller's bearer token, everything else from the form body.
CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
+21
View File
@@ -0,0 +1,21 @@
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
-- reports are what players submit, warnings are what a moderator hands down. Also
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
-- token (the endpoint is gated on the `moderator` role); everything else comes
-- from the form body. `display_reason` is what the warned player is shown,
-- `moderator_note` is internal.
CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
+25
View File
@@ -0,0 +1,25 @@
-- Player-event storage (scheduled events: a room, a window of time, and the
-- settings the event runs under). Like the image/invention/rooms/accounts tables
-- in this shared database, an event is a single JSON blob in the `data` column,
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
-- browse query filters finished events in SQL on that.
CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
@@ -0,0 +1,23 @@
-- Player-event RSVPs: one row per player per event, recording how they answered
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
-- one is genuinely columnar — like the relationship/report tables — so it's a
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
-- every response. A decline is recorded rather than deleted, so the client can show
-- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace).
--
-- An event's creator gets a Going row at create time — that's why a fresh event's
-- AttendeeCount is 1.
CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
@@ -0,0 +1,21 @@
-- Break the two visibility flags out of the invention JSON blob into queryable
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
-- the per-room list ask for "published and not hidden" — so they move together.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
--
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
-- would need rewriting existing rows), which is what we want anyway: the value stays
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
-- replaces did. This is a rename, not a behaviour change.
--
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
-- invention is published and not hidden), so an index on them would be read past rather
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
ALTER TABLE invention
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
ALTER TABLE invention
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
+19
View File
@@ -0,0 +1,19 @@
-- Turn a report into a ban. A report row already names the player it is against
-- (`reported_player_id`), so a moderator acting on one flips `banned` on that same row
-- rather than duplicating it into a second table — the ban then carries the report that
-- justified it (category, details, room, who filed it) with no join.
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `ban_expires` is an ISO-8601 UTC timestamp like `created_at`, and NULL means the ban
-- never expires. Kept as its own column rather than "banned until" alone so a lifted ban
-- (banned = 0) is distinguishable from an expired one, and so the row remains a report
-- once the ban is over. Rows stay append-only in every other respect.
--
-- Partial index: bans are rare next to reports, so indexing only the banned rows keeps
-- the lookup (done on every matchmake and every token grant) reading a handful of pages
-- instead of every report ever filed against that player. idx_report_reported stays —
-- it serves the "all reports against this player" moderation read, which is unfiltered.
ALTER TABLE report ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
ALTER TABLE report ADD COLUMN ban_expires TEXT;
CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1;
+25
View File
@@ -0,0 +1,25 @@
-- Player-event tags: the categories an event is filed under (`workshops`, `meetup`, …),
-- one row per tag per event. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- A separate table rather than a field on the event blob, for a reason that isn't
-- storage taste: the stored blob IS the event DTO every read serves verbatim, and the
-- event reads do NOT carry tags — they surface only behind
-- `GET /api/playerevents/v1/{id}?includeDetails=True`. Putting them in the blob would
-- leak a `Tags` key into every other read.
--
-- `tag` is stored lowercased and is the search key: `?query=%23workshops` (a `#`-prefixed
-- term) filters on this table, while a bare term still matches the name/description.
-- `type` is the client's tag-category int, echoed back as sent — its enum isn't reversed
-- yet, and nothing here interprets it.
--
-- The primary key is (event_id, tag): an event can't carry the same tag twice, and a tag
-- edit REPLACES the event's set rather than accumulating.
CREATE TABLE IF NOT EXISTS event_tag (
event_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag);
+20
View File
@@ -0,0 +1,20 @@
-- Reporting a player EVENT (`POST /api/playerevents/v1/report`) reuses the report
-- table rather than getting one of its own: it is the same submission with the same
-- fields (category, free-text details, the reporter from the token) and the same
-- moderation life — a moderator acting on it sets `banned` on the row exactly as they
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in
-- sync.
--
-- `event_id` names the reported event; NULL on every ordinary player report, which is
-- what tells the two kinds apart. The row's other columns are still filled in from the
-- event: `reported_player_id` is its CREATOR (the person a moderator would act
-- against — the column is NOT NULL, and "who is answerable for this event" is the only
-- honest answer), and `room_id` the room it runs in, read from the event table so the
-- client doesn't have to send either.
--
-- Partial index: event reports are a small minority of rows, so indexing only the ones
-- that name an event keeps "reports against this event" off a full scan without paying
-- for the NULLs.
ALTER TABLE report ADD COLUMN event_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
+18 -4
View File
@@ -2,14 +2,17 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
import { moderationRoutes } from './routes/moderation'
import { playerRoutes } from './routes/players'
import { progressionRoutes } from './routes/progression'
import { roomRoutes } from './routes/rooms'
import { socialRoutes } from './routes/social'
@@ -39,6 +42,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -48,10 +59,13 @@ const app = new Hono<App>({ strict: false })
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', roomRoutes)
.route('/', imageRoutes)
.route('/', accountRoutes)
.route('/', playerRoutes)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
@@ -68,9 +82,9 @@ app.get(
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'reputation and the assorted sinks the client hits while loading. Relationships,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'route.',
'player events, reputation and the assorted sinks the client hits while loading.',
'Relationships, inventions, images and player events are D1-backed; several',
'endpoints are still stubs, noted per route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
+208
View File
@@ -0,0 +1,208 @@
/**
* Who a ban reaches — the ban itself, plus the accounts that share an identity with a
* banned one. This is the ban-EVASION half of moderation: a ban lives on a `report` row
* (see reports-db) and applies to one account, but a player whose account is banned can
* make another in seconds, so the block has to follow the things that are harder to
* change than an account: the platform identity they log in with, and the network they
* play from.
*
* Three arms, in descending order of how much they prove:
* - ACCOUNT — the caller's own account is banned. Certain.
* - PLATFORM — the caller shares a `platform_account` link (a Steam or Meta identity
* they PROVED to us; see the auth worker's platform-db) with a banned account. Sharp:
* linking only ever happens off a verified proof, so this really is the same person,
* modulo somebody handing over their Steam account.
* - IP — the caller shares a `signupIp`/`lastLoginIp` with a banned account. COARSE,
* and the one that will produce false positives: households, NAT, campus and mobile
* carrier networks put many unrelated players behind one address, so this arm bans a
* banned player's whole household along with them. It is the operator's call whether
* that trade is worth it — hence `BAN_EVASION_MATCH` (see `banEvasionMatch`), which
* narrows or disables the linked arms without touching the direct one.
*
* The direct arm can never be turned off. That is the point of the split: an operator
* dialling back evasion matching still enforces every ban they handed down.
*
* Reads three tables owned by three workers — `report` (api), `account` (auth, via the
* blob) and `platform_account` (auth) — which is why this is its own module rather than
* part of reports-db: it is the POLICY over those tables, not any one table's storage.
* It only ever reads them.
*
* The whole resolution is ONE statement. The alternative — fetch my ips, fetch my links,
* then query bans — is three round trips on a path that runs on every matchmake and every
* token grant. Driving from the (few) banned reports and looking each one's account up by
* its indexed id keeps the work proportional to the number of BANS, not to the number of
* accounts.
*/
import type { ReportRow } from './reports-db'
/** Which arm matched — what the block is actually resting on. */
export type BanVia = 'account' | 'platform' | 'ip'
/** A ban that reaches the caller, and how it reached them. */
export interface BanMatch {
/** The report row carrying the ban (its `reported_player_id` is who was banned). */
ban: ReportRow
via: BanVia
/**
* The banned account. Equal to the caller on a direct ban; on a linked arm it's the
* OTHER account they were matched to — the one worth naming in the operator's log.
*/
bannedAccountId: number
}
/** Which linked arms are enabled. The direct (account) arm is not optional. */
export interface BanMatchArms {
ip: boolean
platform: boolean
}
/** Both linked arms on — what an operator who sets nothing gets. */
export const DEFAULT_BAN_MATCH_ARMS: BanMatchArms = { ip: true, platform: true }
/**
* Read the `BAN_EVASION_MATCH` operator knob: a comma-separated list of the linked arms
* to enforce, out of `ip` and `platform`. Unset (the default) means BOTH — a ban follows
* the player. `off` (or `none`, or an empty list) leaves only the direct arm, so a ban
* applies to exactly the account it was handed to.
*
* Set it to `platform` on a server whose players share networks — student halls, one
* household, a country behind CGNAT — where the IP arm would lock out bystanders. The
* platform arm has no such failure mode: it matches a proven identity.
*
* Unrecognised names are ignored rather than fatal: this is read on a request path, and a
* typo must not take matchmaking or login down with it. `off` wins over anything else in
* the list, so `off,ip` is off.
*/
export function banEvasionMatch(value: string | undefined): BanMatchArms {
if (value === undefined) return DEFAULT_BAN_MATCH_ARMS
const names = value
.split(',')
.map((n) => n.trim().toLowerCase())
.filter((n) => n !== '')
if (names.length === 0 || names.includes('off') || names.includes('none')) {
return { ip: false, platform: false }
}
return { ip: names.includes('ip'), platform: names.includes('platform') }
}
/**
* The identity a request carries, for a caller who has no account yet — a `create_account`
* grant, which must be refused BEFORE it mints anything, or a banned player's next account
* exists (and has burned a signup) before the ban catches up with it.
*/
export interface BanIdentity {
/** The client IP the request came from, if the edge reported one. */
ip?: string | null
/** A VERIFIED platform identity. An unproven one must never be passed here. */
platform?: number | null
platformId?: string | null
}
/** Row shape of the resolution query — a report plus which arm matched it. */
type BanMatchRow = ReportRow & {
via_account: number
via_ip: number
via_platform: number
}
/**
* Every ban in force, tested against the caller's account and against the identity they
* present. `ips` and `ids` gather what the caller is known by: the account's stored IPs
* and platform links (when there is an account) plus the IP/identity this request itself
* carries (when there isn't one yet, or when it differs from what's stored).
*
* A NULL `?1` means "no account yet" — the `me` CTE is then empty and the account arm
* cannot match, leaving the two linked arms to answer for a signup.
*/
const RESOLVE_BAN_SQL = `
WITH me AS (
SELECT
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
FROM account WHERE account_id = ?1
),
ips AS (
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
UNION SELECT ?3 WHERE ?3 IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
UNION SELECT ?4, ?5 WHERE ?5 IS NOT NULL
)
SELECT * FROM (
SELECT r.*,
(r.reported_player_id = ?1) AS via_account,
(?6 = 1 AND EXISTS (
SELECT 1 FROM account a, ips
WHERE a.account_id = r.reported_player_id
AND a.account_id <> COALESCE(?1, -1)
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
)) AS via_ip,
(?7 = 1 AND EXISTS (
SELECT 1 FROM platform_account p, ids
WHERE p.account_id = r.reported_player_id
AND p.account_id <> COALESCE(?1, -1)
AND p.platform = ids.platform
AND p.platform_id = ids.platform_id
)) AS via_platform
FROM report r
WHERE r.banned = 1 AND (r.ban_expires IS NULL OR r.ban_expires > ?2)
)
WHERE via_account = 1 OR via_ip = 1 OR via_platform = 1
ORDER BY via_account DESC, via_platform DESC, ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
/**
* The ban blocking this caller, or null when nothing does.
*
* Pass the `accountId` when there is one (every login after the first, and every
* matchmake) and the request's own `identity` when it adds something the account doesn't
* already carry — on a `create_account` grant there is no account at all, and that is
* exactly the request a ban evader makes.
*
* The strongest match is the one returned: a direct ban ahead of a platform match ahead
* of an IP one, then the longest-lasting ban of those. So the log line names the evidence
* an operator would want to see first, and a player whose own account is banned is never
* told it was their network.
*/
export async function resolveBan(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<BanMatch | null> {
const arms = options.arms ?? DEFAULT_BAN_MATCH_ARMS
const identity = options.identity ?? {}
const row = await db
.prepare(RESOLVE_BAN_SQL)
.bind(
accountId,
(options.now ?? new Date()).toISOString(),
identity.ip || null,
identity.platform ?? 0,
identity.platformId || null,
arms.ip ? 1 : 0,
arms.platform ? 1 : 0
)
.first<BanMatchRow>()
if (!row) return null
// `via_ip` is only stripped off the row here — it's the arm left when neither of the
// other two matched, so nothing reads it.
const { via_account, via_ip: _via_ip, via_platform, ...ban } = row
const via: BanVia = via_account === 1 ? 'account' : via_platform === 1 ? 'platform' : 'ip'
return { ban: ban as ReportRow, via, bannedAccountId: ban.reported_player_id }
}
/** Whether anything blocks this caller — the boolean form of `resolveBan`. */
export async function isPlayerBlocked(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
+7
View File
@@ -21,6 +21,13 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here.
IMAGES: R2Bucket
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// Per-player settings bag (KV owned by the `playersettings` worker, which serves
// the same map at `/playersettings`). Key `player:<id>` → JSON `{ key: value }`;
// read/written here for the player preferences the client calls by name.
RECFLARE_PLAYER_SETTINGS: KVNamespace
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+844
View File
@@ -0,0 +1,844 @@
/**
* Player-event storage on the shared `recflare` D1 database. Each event is a single
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/invention/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
* clash with the other workers' migrations on the shared database).
*
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
* field set and casing here are exactly what the client parses. Timestamps are
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
*
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
* genuinely columnar (like the relationship/report tables), so it's a normal
* relational table rather than a JSON blob.
*/
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
* seed rows).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
`CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
`CREATE TABLE IF NOT EXISTS event_tag (
event_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
]
/**
* How a player answered an event invitation — the `Type` on
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
*
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
* declining is recorded rather than deleted so the client can show the player their own
* answer (and so changing your mind is an update, not an insert).
*/
export const EVENT_RESPONSE = {
going: 0,
interested: 1,
cantGo: 2,
} as const
/** The response types, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
/** Whether a number is one of the three response types. */
export function isEventResponseType(value: number): boolean {
return EVENT_RESPONSE_VALUES.includes(value)
}
/**
* One player's answer to one event.
*
* `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a
* rowid table and the implicit id is free. It's what the RSVP list serves as
* `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the
* composite key (same rowid), and nothing ever deletes an RSVP row.
*/
export interface EventAttendeeRow {
id: number
event_id: number
player_id: number
status: number
responded_at: string
}
/**
* One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase
* projection of an `event_attendee` row.
*
* `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY
* recorded, not of the player's first one: changing your mind updates the row in place
* (one row per player per event), and the client shows the answer that stands.
*/
export interface PlayerEventResponse {
PlayerEventResponseId: number
PlayerEventId: number
PlayerId: number
CreatedAt: string
Type: number
}
/** Project an RSVP row into the response the RSVP list serves. */
export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
return {
PlayerEventResponseId: row.id,
PlayerEventId: row.event_id,
PlayerId: row.player_id,
CreatedAt: row.responded_at,
Type: row.status,
}
}
/**
* One tag on an event — the categories the browse screen's filter chips name
* (`workshops`, `meetup`, …). `tag` is stored and matched lowercased; `type` is the
* client's tag-category int, echoed back as sent (its enum isn't reversed yet).
*
* Tags live in their own table, NOT on the event blob: the blob is the DTO every read
* serves verbatim, and tags surface only behind `includeDetails=True`.
*/
export interface EventTag {
tag: string
type: number
}
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
* the settings the event runs under. Served verbatim by every read endpoint.
*
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
* without pinning a subroom, needn't belong to a club, and has no banner until one is
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
* client sends them — their enums aren't reversed yet, so nothing here interprets
* them beyond the defaults below.
*/
export interface PlayerEvent {
PlayerEventId: number
CreatorPlayerId: number
ImageName: string | null
RoomId: number
SubRoomId: number | null
ClubId: number | null
Name: string
Description: string
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
StartTime: string
EndTime: string
AttendeeCount: number
State: number
Accessibility: number
IsMultiInstance: boolean
SupportMultiInstanceRoomChat: boolean
DefaultBroadcastPermissions: number
CanRequestBroadcastPermissions: number
}
interface EventRow {
data: string
}
/**
* The envelope the create/update writes answer with — the event nested under a status,
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
*
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
* that rides along with the write, and we store no event tags (see the tag-filter
* chips, which are static). The field stays present because the client's parser
* expects it.
*/
export interface PlayerEventResult {
Result: number
TagModifyResult: null
PlayerEvent: PlayerEvent
}
/** Wrap a stored event in the write envelope. */
export function toEventResult(event: PlayerEvent): PlayerEventResult {
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
}
/**
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
* them:
*
* - it is camelCase, where the record and every read endpoint are PascalCase;
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
* empty/null), and drops `State`;
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
* record stores them bare. That asymmetry is the reference server's: its notification
* frames carry the padded form and its event reads don't.
*/
export interface PlayerEventNotification {
tags: Array<{ tag: string; type: number }>
playerEventId: number
creatorPlayerId: number
roomId: number
subRoomId: number | null
clubId: number | null
name: string
description: string
imageName: string
startTime: string
endTime: string
attendeeCount: number
accessibility: number
isMultiInstance: boolean
supportMultiInstanceRoomChat: boolean
defaultBroadcastPermissions: number
canRequestBroadcastPermissions: number
broadcastingRoomInstanceId: number | null
}
/**
* The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like
* the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which the feed does not carry;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
* broadcasts an event yet, so it is always null).
*
* That's the shape observed on this endpoint; the by-id / bulk / search reads serve the
* stored record verbatim and keep `State`.
*/
export interface PlayerEventListing extends Omit<PlayerEvent, 'State'> {
BroadcastingRoomInstanceId: number | null
}
/** Project a stored event into the browse feed's listing. */
export function toEventListing(event: PlayerEvent): PlayerEventListing {
const { State: _State, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
}
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
function toTickPrecision(iso: string): string {
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
if (match === null) return iso
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
}
/**
* Project a stored event into its notification frame. `imageName` becomes an empty
* string rather than null when the event has no banner: the frame carries `""`, and a
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
*
* `tags` are passed in rather than read from the event: they live in their own table,
* and the callers that have them already looked them up.
*/
export function toEventNotification(
event: PlayerEvent,
tags: EventTag[] = []
): PlayerEventNotification {
return {
tags,
playerEventId: event.PlayerEventId,
creatorPlayerId: event.CreatorPlayerId,
roomId: event.RoomId,
subRoomId: event.SubRoomId,
clubId: event.ClubId,
name: event.Name,
description: event.Description,
imageName: event.ImageName ?? '',
startTime: toTickPrecision(event.StartTime),
endTime: toTickPrecision(event.EndTime),
attendeeCount: event.AttendeeCount,
accessibility: event.Accessibility,
isMultiInstance: event.IsMultiInstance,
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
broadcastingRoomInstanceId: null,
}
}
/**
* Normalize a timestamp to the form the client sends and reads back —
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
* milliseconds, which the samples never carry, so they're trimmed.
*/
function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/** An event's tags, alphabetical so a list read is stable. */
export async function getEventTags(db: D1Database, eventId: number): Promise<EventTag[]> {
const { results } = await db
.prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag')
.bind(eventId)
.all<EventTag>()
return results
}
/**
* Replace an event's tags with the given set — the tag edit that rides along with a
* create or update. A replace, not a merge: the client posts the whole set it wants,
* so an untagging is a post with the tag left out.
*/
export async function setEventTags(
db: D1Database,
eventId: number,
tags: EventTag[]
): Promise<void> {
const statements = [db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId)]
for (const { tag, type } of tags) {
statements.push(
db
.prepare(
`INSERT INTO event_tag (event_id, tag, type) VALUES (?1, ?2, ?3)
ON CONFLICT (event_id, tag) DO UPDATE SET type = ?3`
)
.bind(eventId, tag, type)
)
}
await db.batch(statements)
}
/**
* Fields a create or update supplies, camelCased. Every one is optional: create
* defaults what's missing, and update leaves anything absent at its stored value —
* which is why the nullable ids are `number | null` rather than merely absent, so a
* posted `"ClubId": null` can genuinely clear a club.
*/
export interface EventInput {
/** The whole tag set to store; absent leaves the event's tags alone. */
tags?: EventTag[]
imageName?: string | null
roomId?: number
subRoomId?: number | null
clubId?: number | null
name?: string
description?: string
startTime?: string
endTime?: string
state?: number
accessibility?: number
isMultiInstance?: boolean
supportMultiInstanceRoomChat?: boolean
defaultBroadcastPermissions?: number
canRequestBroadcastPermissions?: number
}
/** Read a value as an integer, or undefined when absent / not a number. */
function asInt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string') {
const n = Number.parseInt(value, 10)
if (!Number.isNaN(n)) return n
}
return undefined
}
/**
* Parse a posted event body into an {@link EventInput}.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
*/
/**
* Why a parsed event body can't be stored, or `null` when it's fine.
*
* Length only. An event name is a title, not an identifier — "Building a Better Room
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
* names carry would be wrong here. Absent fields are skipped: an update posts only what
* it changes, and create defaults a missing name rather than refusing it.
*
* The name is measured AFTER trimming, matching what create/update actually store.
*/
export function eventInputRejection(input: EventInput): string | null {
const name = input.name?.trim()
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
}
if (
input.description !== undefined &&
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
) {
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
}
return null
}
/**
* Read the `Tags` a create/update body carries, or undefined when it carries none (an
* update that says nothing about tags leaves them alone; `[]` genuinely clears them).
*
* Both forms in circulation are accepted — a bare string (`"workshops"`) and the
* `{ tag, type }` object the notification frame carries — since the browse chips are
* plain names while the client's own event model pairs each with a category int. Tags
* are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops`
* are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped.
*/
function parseEventTags(raw: unknown): EventTag[] | undefined {
if (!Array.isArray(raw)) return undefined
const byTag = new Map<string, EventTag>()
for (const entry of raw) {
const source = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<
string,
unknown
>
const name = typeof entry === 'string' ? entry : (source.tag ?? source.Tag)
if (typeof name !== 'string') continue
const tag = name.trim().replace(/^#/, '').toLowerCase()
if (tag === '') continue
byTag.set(tag, { tag, type: asInt(source.type ?? source.Type) ?? 0 })
}
return [...byTag.values()]
}
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
string,
unknown
>
const has = (key: string): boolean => Object.hasOwn(obj, key)
// A nullable id: absent leaves it alone, an explicit null clears it.
const nullableInt = (key: string): number | null | undefined => {
if (!has(key)) return undefined
return obj[key] === null ? null : asInt(obj[key])
}
const time = (key: string): string | undefined => {
const raw = obj[key]
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
const bool = (key: string): boolean | undefined => {
const raw = obj[key]
if (typeof raw === 'boolean') return raw
if (raw === 'true') return true
if (raw === 'false') return false
return undefined
}
// The banner name: same absent/null distinction as the nullable ids.
const nullableString = (key: string): string | null | undefined => {
if (!has(key)) return undefined
if (obj[key] === null) return null
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
}
return {
tags: parseEventTags(obj.Tags ?? obj.tags),
imageName: nullableString('ImageName'),
roomId: asInt(obj.RoomId),
subRoomId: nullableInt('SubRoomId'),
clubId: nullableInt('ClubId'),
name: typeof obj.Name === 'string' ? obj.Name : undefined,
description: typeof obj.Description === 'string' ? obj.Description : undefined,
startTime: time('StartTime'),
endTime: time('EndTime'),
state: asInt(obj.State),
accessibility: asInt(obj.Accessibility),
isMultiInstance: bool('IsMultiInstance'),
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
}
}
/** How long an event runs when the body names a start but no end. */
const DEFAULT_DURATION_MS = 60 * 60 * 1000
/**
* Insert a new event, returning the stored record.
*
* Lenient about what the body carries, like the other writes here: an event with no
* name or no time window is defaulted rather than rejected, because a rejection the
* client can't render is worse than a placeholder the creator can edit. `State` starts
* at 0 (scheduled). The creator comes from the bearer token, never the body.
*
* The creator is recorded as Going in `event_attendee`, which is what makes
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
* needs a row there for the number to stay right once other players respond.
*/
export async function createEvent(
db: D1Database,
creatorPlayerId: number,
input: EventInput
): Promise<PlayerEvent> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
.first<{ next: number }>()
const now = Date.now()
const startTime = input.startTime ?? eventTime(now)
const event: PlayerEvent = {
PlayerEventId: row?.next ?? 1,
CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0,
SubRoomId: input.subRoomId ?? null,
ClubId: input.clubId ?? null,
Name: input.name?.trim() || 'Untitled Event',
Description: input.description ?? '',
StartTime: startTime,
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
AttendeeCount: 1,
State: input.state ?? 0,
Accessibility: input.accessibility ?? 1,
IsMultiInstance: input.isMultiInstance ?? false,
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
}
await db.batch([
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
])
// Tags ride along with the write but live in their own table — they are not part of
// the stored blob, since that blob is the DTO every read serves verbatim.
if (input.tags !== undefined) await setEventTags(db, event.PlayerEventId, input.tags)
return event
}
/**
* Record a player's answer to an event, replacing whatever they said before — one row
* per player per event, so changing your mind is an update rather than a second RSVP.
* The event's `AttendeeCount` is recomputed from the table afterwards.
*
* Returns the updated event, or null when there's no such event. Anyone who can see an
* event may respond to it, the creator included (they're already Going from create, and
* nothing stops them declining their own event).
*/
export async function setEventResponse(
db: D1Database,
eventId: number,
playerId: number,
status: number
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
await db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
)
.bind(eventId, playerId, status, eventTime(Date.now()))
.run()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return updated
}
/**
* Add invited players to an event as Going — the bulk invite. Returns the updated
* event (with its recounted `AttendeeCount`) and the rows actually created, or null
* when there's no such event.
*
* An invite only ever INSERTS: a player who already has a row keeps the answer they
* gave, so being invited can't flip a decline back to Going, and re-inviting the same
* player is a no-op rather than a reset. Since the rows land as Going, the invited
* count toward `AttendeeCount` from the moment they're invited — see the route.
*
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
* inserts nothing and returns nothing. That's what the route notifies on — a player
* whose existing answer was left alone gets no frame, because nothing changed for them.
*
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
* still returns the event.
*/
export async function inviteToEvent(
db: D1Database,
eventId: number,
playerIds: number[]
): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
if (playerIds.length === 0) return { event, added: [] }
const at = eventTime(Date.now())
const inserts = await db.batch<EventAttendeeRow>(
playerIds.map((playerId) =>
db
.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO NOTHING
RETURNING rowid AS id, *`
)
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
)
)
const added = inserts.flatMap((r) => r.results)
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
return { event: updated, added }
}
/** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
const row = await db
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
.bind(eventId, EVENT_RESPONSE.going)
.first<{ going: number }>()
return row?.going ?? 0
}
/** One player's answer to one event, or null when they haven't responded. */
export async function getEventResponse(
db: D1Database,
eventId: number,
playerId: number
): Promise<EventAttendeeRow | null> {
return db
.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
.bind(eventId, playerId)
.first<EventAttendeeRow>()
}
/**
* Everyone who answered an event, in the order they responded — the guest list behind
* `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's
* own Going row shares its second with a fast first RSVP) break on the player id, so
* the order is stable.
*/
export async function getEventAttendees(
db: D1Database,
eventId: number
): Promise<EventAttendeeRow[]> {
const { results } = await db
.prepare(
`SELECT rowid AS id, * FROM event_attendee
WHERE event_id = ?1 ORDER BY responded_at, player_id`
)
.bind(eventId)
.all<EventAttendeeRow>()
return results
}
/** Overwrite an event's stored blob in place. */
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
await db
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(event), event.PlayerEventId)
.run()
}
/**
* Apply an edit to an event. Only the fields the body carried change; everything else
* keeps its stored value, so a partial post can't blank out the rest of the event.
* The id, the creator and the attendee count are not editable — ownership doesn't
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
* there's no such row.
*/
export async function updateEvent(
db: D1Database,
eventId: number,
input: EventInput
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
const updated: PlayerEvent = {
...event,
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
RoomId: input.roomId ?? event.RoomId,
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
Name: input.name?.trim() || event.Name,
Description: input.description ?? event.Description,
StartTime: input.startTime ?? event.StartTime,
EndTime: input.endTime ?? event.EndTime,
State: input.state ?? event.State,
Accessibility: input.accessibility ?? event.Accessibility,
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
SupportMultiInstanceRoomChat:
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions:
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions:
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
}
await writeEvent(db, updated)
// A body that says nothing about tags leaves them alone, like every other field
// here; an explicit `[]` clears them.
if (input.tags !== undefined) await setEventTags(db, eventId, input.tags)
return updated
}
/** One event by id, or null when there's no such row. */
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
const row = await db
.prepare('SELECT data FROM event WHERE id = ?1')
.bind(eventId)
.first<EventRow>()
return row ? (JSON.parse(row.data) as PlayerEvent) : null
}
/**
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
* (the client renders them in the order it requested), skipping ids with no row rather
* than leaving a hole. Duplicated ids resolve to the same event.
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
.bind(...ids)
.all<EventRow>()
const byId = new Map<number, PlayerEvent>()
for (const r of results) {
const event = JSON.parse(r.data) as PlayerEvent
byId.set(event.PlayerEventId, event)
}
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
}
/**
* The events a player created — their "my events" list, soonest first. Uses the
* creator_player_id index; the per-player set is small, so ordering is done in memory.
*/
export async function getEventsByCreator(
db: D1Database,
creatorPlayerId: number
): Promise<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
.bind(creatorPlayerId)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
* rather than every event.
*/
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
if (clubIds.length === 0) return []
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
.bind(...clubIds)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events happening right now — started and not yet finished. Backs the "happening
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
* columns, so the whole filter stays in SQL.
*/
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
const at = eventTime(now)
const { results } = await db
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
.bind(at)
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/** Soonest start first; ties broken by id so paging is stable. */
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Event search — the browse query on the player-events screen. Term by term, an empty
* query browsing everything upcoming; paginated via skip/take, soonest first.
*
* A term is matched one of two ways, and the `#` decides which:
*
* - `#workshops` is a TAG term — it matches only an event tagged `workshops`, and never
* the word appearing in a name or description. That's what the browse screen's filter
* chips send.
* - `workshops` is a TEXT term, matched case-insensitively against the name and the
* description, as before.
*
* Every term has to match, and the two kinds combine: `#workshops trigonometry` is the
* workshops-tagged events whose text also mentions trigonometry.
*
* Events that have already finished are excluded: this backs a browse screen, where a
* name match on something that ended last month is noise. The per-event history a
* creator wants comes from `getEventsByCreator`, which keeps them.
*/
export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number
): Promise<PlayerEvent[]> {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
// is dropped rather than treated as a tag nothing can carry.
const tags = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
const textTerms = terms.filter((t) => !t.startsWith('#'))
// end_time is a generated column of an ISO-8601 UTC string, so it compares
// lexicographically — that filter stays in SQL, and so does the tag one: an event
// has to carry EVERY tag asked for, which is the count of matching tag rows.
const wanted = tags.filter(Boolean)
const sql =
wanted.length === 0
? 'SELECT data FROM event WHERE end_time >= ?1'
: `SELECT data FROM event WHERE end_time >= ?1 AND (
SELECT COUNT(DISTINCT tag) FROM event_tag
WHERE event_tag.event_id = event.id
AND tag IN (${wanted.map((_, i) => `?${i + 2}`).join(', ')})
) = ${wanted.length}`
const { results } = await db
.prepare(sql)
.bind(eventTime(Date.now()), ...wanted)
.all<EventRow>()
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
for (const term of textTerms) {
events = events.filter(
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
)
}
return events.sort(bySoonest).slice(skip, skip + take)
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
* `null` when the request carries no valid token, which callers treat as a 401; an
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
*/
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
-414
View File
@@ -1,414 +0,0 @@
/**
* 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)
}
+187 -36
View File
@@ -1,8 +1,8 @@
/**
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/rooms/accounts tables use.
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
* the same JSON-blob pattern the image/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
* applied under its own `migrations_table`). The invention's data file itself is
@@ -12,19 +12,29 @@
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
* the nested `CurrentVersion` that carries the blob name and per-version costs —
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
* of a JSON `true` is 1, so the column is 1/0.
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
@@ -130,6 +140,37 @@ function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
}
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
function toBase64(bytes: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
}
/**
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
* upload time, so this is normally a HEAD with no body transfer; a blob stored
* before that (or by anything else) is downloaded and digested instead.
*
* Null when the blob isn't in the bucket — a metadata-only save names a file that
* was never uploaded, and a hash of nothing would be worse than the absent hash the
* field already allows for.
*/
export async function inventionBlobHash(
bucket: R2Bucket,
blobName: string
): Promise<string | null> {
const key = `invention/${inventionBlobName(blobName)}`
const head = await bucket.head(key)
if (head === null) return null
const recorded = head.checksums.sha256
if (recorded !== undefined) return toBase64(recorded)
const object = await bucket.get(key)
return object === null
? null
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
}
/**
* Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no
@@ -163,6 +204,7 @@ export interface NewInvention {
*/
export async function createInvention(
db: D1Database,
bucket: R2Bucket,
input: NewInvention
): Promise<SavedInvention> {
// Sequential id: one past the current max (the table starts empty).
@@ -171,6 +213,7 @@ export async function createInvention(
.first<{ next: number }>()
const inventionId = row?.next ?? 1
const now = new Date().toISOString()
const blobName = inventionBlobName(input.inventionDataFilename)
const invention: SavedInvention = {
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
@@ -183,8 +226,8 @@ export async function createInvention(
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
VersionNumber: 1,
BlobName: inventionBlobName(input.inventionDataFilename),
BlobHash: null,
BlobName: blobName,
BlobHash: await inventionBlobHash(bucket, blobName),
InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0,
ChipsCost: input.chipsCost ?? 0,
@@ -232,6 +275,74 @@ export async function getInventionsByCreator(
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
}
/**
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
* everything they BOUGHT. Ownership of a bought invention lives in the
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
* disjoint in practice and merged by id anyway.
*
* Bought inventions are returned whatever their state: unpublished or hidden since the
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
])
const bought = await getInventionsByIds(db, ownedIds)
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
}
/**
* Whether a player owns EVERY invention in a list — the `v1/fulllineageowner` check,
* which the client runs when saving an invention BUILT OUT OF other inventions: it is
* asking whether this player may use each piece. An invention is the player's if they
* created it (`CreatorPlayerId`) or acquired it (a row in `inventory_invention`); an id
* with no invention row is not owned, so a deleted or made-up id makes the whole answer
* false.
*
* Ownership is the whole test — price and `GeneralPermission` deliberately don't enter
* into it. A free invention still has to be picked up before it can be used, and econ's
* buyInvention writes the same inventory row for a 0-token acquisition as for a paid
* one, so "acquired" already covers "free". Reading permission here as a second way to
* qualify would let a player build on an invention they never took.
*
* The lineage is whatever the CLIENT asks about: it sends the invention plus every
* invention nested inside it as repeated `id`s, so this checks exactly the ids given
* and does not walk `ReferencedInventions` itself. Walking it here would answer a
* different question than the one asked — the client knows which pieces the thing it
* is holding is actually made of, and stale references on an old record don't.
*
* An empty list is owned: no invention in it is unowned. The client never asks that,
* but false would read as "you don't own something" with nothing to name.
*/
export async function ownsAllInventions(
db: D1Database,
playerId: number,
inventionIds: number[]
): Promise<boolean> {
if (inventionIds.length === 0) return true
// The client repeats an id when the same invention is nested more than once.
const unique = [...new Set(inventionIds)]
const [inventions, ownedIds] = await Promise.all([
getInventionsByIds(db, unique),
getOwnedInventionIds(db, playerId),
])
const bought = new Set(ownedIds)
const creators = new Map(inventions.map((i) => [i.InventionId, i.CreatorPlayerId]))
return unique.every((id) => creators.get(id) === playerId || (creators.has(id) && bought.has(id)))
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
@@ -271,50 +382,73 @@ export async function searchInventions(
* ones via the indexed `is_featured` column.
*/
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
// All three are generated columns off the JSON blob, so the filter stays in SQL.
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0
WHERE is_published = 1
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
/** Length of the "today" window — a trailing day, not the calendar one. */
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
function startOfWindow(): string {
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
* The "top today" feed — the inventions other players picked up in the last 24 hours,
* most first.
*
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
* purchase time, grouped by invention, rather than from the lifetime counters on the
* invention itself: those never reset, so "top today" used to mean "top ever" and the
* shelf only changed when something overtook a total built up over months.
*
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
* itself at midnight UTC and slowly refill through the small hours — it always covers a
* full day's worth of activity. It is still genuinely a window: an invention nobody has
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
* nothing while nothing is curated.
*
* An acquired invention that has since been unpublished or hidden drops out: this is a
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
* that filtering, so a hidden invention doesn't leave a hole in a page.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
if (counts.length === 0) return []
// getInventionsByIds answers in the order it is asked, so the ranking survives the
// load; ids with no invention row left (deleted) simply drop out.
const ranked = await getInventionsByIds(
db,
counts.map((c) => c.inventionId)
)
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
}
/**
* The featured feed — published inventions flagged `IsFeatured`, newest first.
* Selected on the indexed `is_featured` column rather than by parsing every public
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
* than handing the client an empty shelf; once inventions are curated it serves them.
* invention.
*
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
* standing in the top feed. It used to fall back, from when no invention could be
* featured at all, but a fallback makes the shelf lie — the client labels these as
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
* has picked anything.
*/
export async function getFeaturedInventions(
db: D1Database,
@@ -322,7 +456,6 @@ export async function getFeaturedInventions(
take: number
): Promise<SavedInvention[]> {
const featured = await publicInventions(db, true)
if (featured.length === 0) return getTopInventions(db, skip, take)
return featured
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
@@ -546,8 +679,8 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0`
AND is_published = 1
AND hide_from_player = 0`
)
.bind(roomId)
.all<InventionRow>()
@@ -568,20 +701,38 @@ export async function getInventionsByRoom(
*/
export async function getInventionVersion(
db: D1Database,
bucket: R2Bucket,
inventionId: number,
versionNumber: number
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
if (invention.CurrentVersionNumber !== versionNumber) return null
// A version saved before its blob finished uploading (or before we hashed on
// save at all) carries no hash. Hash it now and keep the result, so the other
// invention endpoints serve it too and this stays a one-time cost per blob.
// ModifiedAt is deliberately left alone: reading a version is not an edit.
if (invention.CurrentVersion.BlobHash === null) {
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
if (hash !== null) {
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
await storeInvention(db, invention)
}
}
return invention.CurrentVersion
}
/** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
}
/** Write a record back as it stands — for changes that aren't edits (see above). */
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId)
.bind(JSON.stringify(invention), invention.InventionId)
.run()
}
+366 -19
View File
@@ -97,6 +97,16 @@ export const BareString = z.string()
/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })
/**
* The `{ success, error }` envelope the report / warning writes and the message send
* answer with — `error` is an empty string on success, never null, and the rejected
* branches use the same shape so there is only one thing to parse.
*/
export const SuccessErrorEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty string when the call succeeded'),
})
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
@@ -135,7 +145,13 @@ export const ApiConfigV2 = JsonObject.describe(
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
)
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
/**
* `GET /api/versioncheck/islandedversions` — builds islanded onto their own matchmaking
* pool. Always empty here.
*/
export const IslandedVersions = z.array(z.string())
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we serve. */
export const VersionCheck = z.object({
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
UpdateNotificationStage: z.int(),
@@ -160,9 +176,49 @@ export const RelationshipDto = z.object({
Muted: z.int().describe('0/1 — the caller‘s own flag'),
})
/**
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
* taken from the bearer token.
*/
export const SendMessageRequest = z.object({
ToPlayerId: z.string().describe('Account id of the recipient'),
Type: z
.string()
.optional()
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/**
* `POST /api/messages/v1/sendMultiple` JSON body — the same message fanned out to
* several recipients. Unlike the form-encoded single send, this one is real JSON, so
* `Type` arrives as a number and `ToPlayerIds` as an array of numbers. The sender is
* still taken from the bearer token, not the body.
*/
export const SendMultipleMessagesRequest = z.object({
ToPlayerIds: z.array(z.int()).describe('Account ids of the recipients'),
Type: z
.int()
.optional()
.describe('The Message-model type, e.g. `20`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
/**
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
* A trimmed account card, not a relationship: no relationship type or flags.
*/
export const MutualFriendDto = z.object({
AccountId: z.int(),
Username: z.string(),
DisplayName: z.string(),
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})
// ---- Progression -----------------------------------------------------------
/**
@@ -198,6 +254,20 @@ export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})
// ---- Players ---------------------------------------------------------------
/**
* `PlayerPhotoTaggingSetting` — who may tag the player in a photo, as the enum ORDINAL.
* The reference serves the number (it registers no `JsonStringEnumConverter`), so this
* is a bare integer body, not a name and not an envelope.
*/
export const PhotoTaggingSetting = z.int().describe('0 = Anyone, 1 = Friends, 2 = NoOne')
/** The `{ Setting }` JSON body `PUT /api/players/v1/playerPhotoTaggingSetting` takes. */
export const SetPhotoTaggingSettingRequest = z.object({
Setting: PhotoTaggingSetting,
})
// ---- Inventions ------------------------------------------------------------
/** One version of an invention — carries the blob name the client downloads. */
@@ -206,7 +276,10 @@ export const InventionVersionDto = z.object({
ReplicationId: z.string(),
VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
BlobHash: z.string().nullable(),
BlobHash: z
.string()
.nullable()
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
@@ -282,8 +355,14 @@ export const InventionPersonalDetails = z.object({
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
InventionId: z.int(),
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
AutoTags: z
.array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
@@ -303,8 +382,14 @@ export const SaveInventionRequest = z.object({
inventionDataFilename: z
.string()
.describe('The blob uploaded through the storage worker; the one required field'),
name: z.string().optional().describe('Defaults to “Untitled”'),
description: z.string().optional(),
name: z
.string()
.optional()
.describe('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(),
instantiationCost: z.int().optional(),
lightsCost: z.int().optional(),
@@ -346,12 +431,103 @@ export const GenerateGiftRequest = z.object({
Xp: z.string().optional(),
})
/**
* `POST /api/customAvatarItems/v1/bulk` form body. A repeated form field, not a JSON
* array: the reference binds `[FromForm] List<string>`, so the client posts
* `customAvatarItemIds=a&customAvatarItemIds=b`.
*/
export const BulkCustomAvatarItemsRequest = z.object({
customAvatarItemIds: z
.array(z.string())
.describe('The ids to resolve; repeat the field once per id'),
})
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
TotalResults: z.int(),
})
/**
* One custom-item save — the rebuilt version of a legacy avatar item. This is the
* official shape, recorded for documentation: nothing stores custom items yet, so we
* never actually emit one of these.
*/
export const CustomAvatarItemSave = z.object({
customAvatarItemSaveId: z.int().describe('The save’s id'),
customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
unityAssetId: z.string().describe('Guid of the built Unity asset'),
createdAt: z.string().describe('ISO 8601 timestamp'),
thumbnailFileName: z.string(),
additionalConfiguration: z.string(),
unityAsset: z.string(),
unityAssetHash: z.string(),
})
/**
* The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
* item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
* the value shape is documented rather than served.
*/
export const LegacyAvatarItemSaves = z.object({
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
})
/**
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
* account form, where every field that would carry an outfit is null/empty and
* `DataVersion` is 9.
*/
export const OutfitsMeResponse = z.object({
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.string().nullable(),
HairColor: z.string().nullable(),
}),
Selections: JsonArray,
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
CustomizationSettings: z
.string()
.nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
ThumbnailFileName: z.string().nullable(),
Name: z.string().nullable(),
Accessibility: z.int(),
Slot: z.int().describe('0 — the outfit being worn'),
})
/**
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
* structured form, and the client sends both. `Selections` arrives empty — the actual
* selections are inside those strings.
*/
export const OutfitsMeRequest = z.object({
DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'),
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.string().nullable(),
HairColor: z.string().nullable(),
}),
CustomizationSettings: z
.string()
.nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
Selections: JsonArray.describe('Empty in observed saves'),
Slot: z.int(),
Name: z.string().nullable(),
Accessibility: z.int(),
ThumbnailFileName: z.string().nullable(),
})
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
@@ -370,10 +546,142 @@ export const KeepsakeConfig = z.object({
SocialXpBoostEnabled: z.boolean(),
})
/**
* `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set
* rather than the bare list the stubs around it serve. Empty until a catalog exists.
*/
export const KeepsakeCategories = z.object({
Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'),
TotalResults: z.int().describe('How many results `Results` carries'),
})
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
* echoed as the client sends them; their enums aren't reversed yet.
*/
export const PlayerEventDto = z.object({
PlayerEventId: z.int(),
CreatorPlayerId: z.int(),
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
RoomId: z.int(),
SubRoomId: z.int().nullable().describe('Null when the event doesn’t pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
IsMultiInstance: z.boolean(),
SupportMultiInstanceRoomChat: z.boolean(),
DefaultBroadcastPermissions: z.int(),
CanRequestBroadcastPermissions: z.int(),
})
/**
* `GET /api/playerevents/v1/:eventId?includeDetails=True` — the record plus the one
* field the flag adds: the LOWERCASE `tags`, in an otherwise PascalCase record. Always
* empty, since no event tags are stored; the key is absent altogether when the flag
* isn't passed. The entry shape is the one the notification projection declares.
*/
export const PlayerEventDetailsDto = PlayerEventDto.extend({
tags: z
.array(z.object({ tag: z.string(), type: z.int() }))
.optional()
.describe('Present only with `includeDetails=True`, and always empty'),
})
/**
* `GET /api/playerevents/v1` — the browse feed's listing. The same record minus
* `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an
* event yet). That's the shape observed on this endpoint; the other reads serve the
* stored record verbatim, so don't unify the two.
*/
export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({
BroadcastingRoomInstanceId: z
.int()
.nullable()
.describe('Always null — no event broadcasts to a room instance yet'),
})
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z
.null()
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
PlayerEvent: PlayerEventDto,
})
/**
* The JSON body of an event create / update. Every field is optional: create defaults
* what's missing, update leaves anything absent at its stored value. The fields may be
* posted at the top level or nested under `PlayerEvent` — the client posts back the
* same envelope it read — and both forms are accepted. `PlayerEventId`,
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
*/
export const PlayerEventRequest = PlayerEventDto.partial().extend({
PlayerEvent: z
.unknown()
.optional()
.describe('The event’s fields, if nested rather than posted at the top level'),
})
/**
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
* the guest list serves it.
*/
export const PlayerEventResponseDto = z.object({
PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'),
PlayerEventId: z.int(),
PlayerId: z.int(),
CreatedAt: z
.string()
.describe(
'When the answer that stands was given — a changed answer updates the row, so this ' +
'moves with it rather than recording the player’s first response'
),
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
PlayerEventId: z.int(),
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
})
/**
* `POST /api/playerevents/v1/report` JSON body — a report against an event. JSON, note,
* where the player report next to it is form-encoded. The reporter is NOT in the body:
* it's the bearer token's player.
*/
export const PlayerEventReportRequest = z.object({
PlayerEventId: z.int().describe('The event being reported'),
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI, e.g. `101`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
PlayerEventId: z.int(),
InvitedPlayerIds: z
.array(z.int())
.describe('Ids to invite; duplicates and the caller are ignored'),
})
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
Created: JsonArray,
Responses: JsonArray,
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
Responses: JsonArray.describe(
'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
'entry shape has not been observed yet'
),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
@@ -382,22 +690,19 @@ export const PlayerEventsPage = z.object({
Events: JsonArray,
})
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
export const SubscriptionResponse = z.object({
subscription: z.null(),
platformAccountSubscribedPlayerId: z.null(),
})
// ---- Moderation ------------------------------------------------------------
/**
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
* which is a real category; `Message` is null, not an empty string — the client
* distinguishes "no message" from a blank one.
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet), mirroring the reference server's stub
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
* which is a real category, and `Message` is null — the client distinguishes "no
* message" from a blank one, so we send null where the reference sends an empty string.
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
* they carry their C# defaults (false / null).
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
Duration: z.int(),
GameSessionId: z.int(),
IsBan: z.boolean(),
@@ -408,6 +713,48 @@ export const ModerationBlockDetails = z.object({
TimeoutStartedAt: z.string().nullable(),
})
/**
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
* reporter is NOT in the body — it's taken from the bearer token.
*/
export const CreateReportRequest = z.object({
PlayerIdReported: z.string().describe('Account id of the player being reported'),
ReportCategory: z
.string()
.optional()
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
HeightReporter: z
.string()
.optional()
.describe('Reporter’s player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
RoomInstanceType: z
.string()
.optional()
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
* moderator is NOT in the body — it's taken from the bearer token.
*/
export const CreateWarningRequest = z.object({
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
ReportCategory: z
.string()
.optional()
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
DisplayReason: z
.string()
.optional()
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
-385
View File
@@ -1,385 +0,0 @@
/**
* 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)
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Player-report storage on the shared `recflare` D1 database.
*
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
* accounts / image / invention), a report is genuinely columnar, so it gets a
* normal relational table. Rows are append-only in the sense that nothing rewrites
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share
* the database).
*
* A reported player EVENT lands here too, rather than in a table of its own: same
* fields, same moderation life. Such a row carries `event_id`, and its
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
*/
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT,
event_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
]
/** A stored report row (snake_case columns, one row per submission). */
export interface ReportRow {
id: number
reporter_player_id: number
reported_player_id: number
report_category: number
details: string | null
/** Player height in metres, as the client measured it at report time. */
height_reporter: number | null
height_reported: number | null
room_id: number | null
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
room_instance_type: string | null
created_at: string
/** 1 when a moderator turned this report into a ban of `reported_player_id`. */
banned: number
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
ban_expires: string | null
/**
* The player event this report is against, or NULL for an ordinary player report —
* which is what tells the two kinds apart. See `POST /api/playerevents/v1/report`:
* `reported_player_id` and `room_id` are filled in from the event itself.
*/
event_id: number | null
}
/**
* A report as submitted — everything but the reporter (which comes from the bearer
* token) and the timestamp. Only the reported player is required; the client omits
* fields it has no value for (a report raised outside a room carries no `RoomId`),
* so the rest are optional and stored as NULL when absent.
*/
export interface NewReport {
reporterPlayerId: number
reportedPlayerId: number
reportCategory?: number
details?: string | null
heightReporter?: number | null
heightReported?: number | null
roomId?: number | null
roomInstanceType?: string | null
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
eventId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
const row = await db
.prepare(
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
RETURNING *`
)
.bind(
input.reporterPlayerId,
input.reportedPlayerId,
input.reportCategory ?? 0,
input.details ?? null,
input.heightReporter ?? null,
input.heightReported ?? null,
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString(),
input.eventId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every report filed against a player, newest first. Backs a future moderation view. */
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<ReportRow>()
return results
}
/**
* The ban currently in force against a player, or null when they aren't banned.
*
* "In force" is narrower than `banned = 1`: a row whose `ban_expires` has passed is a
* ban that has SERVED ITS TIME, and the player is let back in without anyone having to
* go and clear the flag — the row stays as the record that it happened. A permanent ban
* carries no expiry at all (NULL), which is why that arm is checked separately rather
* than by comparing against some far-future date.
*
* When several bans are in force, the longest-lasting one wins: permanent first (NULL
* sorts ahead because `ban_expires IS NOT NULL` is 0 for it), then the latest expiry. So
* a fresh short ban can never shorten a standing one.
*/
export async function getActiveBan(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<ReportRow | null> {
return db
.prepare(
`SELECT * FROM report
WHERE reported_player_id = ?1 AND banned = 1
AND (ban_expires IS NULL OR ban_expires > ?2)
ORDER BY ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
)
.bind(playerId, now.toISOString())
.first<ReportRow>()
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
*/
export async function isPlayerBanned(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<boolean> {
return (await getActiveBan(db, playerId, now)) !== null
}
/**
* Turn a report into a ban of the player it was filed against — the moderator action the
* `banned` column exists for. `banExpires` is an ISO-8601 UTC instant, or null for a
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* Returns the updated row, or null when there is no report with that id — so the caller
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
* changes count, hence RETURNING).
*/
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null } = {}
): Promise<ReportRow | null> {
const banned = options.banned ?? true
return db
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
.first<ReportRow>()
}
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { json, JsonArray, stringParam } from '../openapi'
import type { App } from '../context'
// ---- Account ---------------------------------------------------------------
// The identity service's account-scoped reads. Nothing here is backed by storage — the
// client calls it while loading the account, and an empty list is a complete answer for a
// server that links no external channels to an account.
export const accountRoutes = new Hono<App>({ strict: false }).get(
'/iam/me/channels/:type',
describeRoute({
tags: ['Account'],
summary: 'The caller’s channels of a type',
description:
'The channels of the given `{type}` linked to the caller’s account. This server ' +
'links none, so the list is always empty — a real answer rather than a placeholder ' +
'for one, since the client renders "nothing linked" from it. `{type}` is accepted ' +
'but not inspected, and the route is not auth-gated: the answer is the same for ' +
'every caller and every type.',
parameters: [stringParam('type', 'The channel type. Accepted but not inspected.')],
responses: { 200: json(JsonArray, 'Always an empty list') },
}),
(c) => c.json([])
)
+359 -53
View File
@@ -1,18 +1,28 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
setOutfit,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getMyInventions,
getTopInventions,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
@@ -24,6 +34,7 @@ import {
import {
AUTHED,
BareBoolean,
BulkCustomAvatarItemsRequest,
CustomAvatarItemsPage,
ErrorResponse,
form,
@@ -39,6 +50,9 @@ import {
json,
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
SaveInventionRequest,
SetTagsRequest,
@@ -76,6 +90,20 @@ async function creatorsInvention(
return { invention }
}
/**
* The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each
* value may itself be a comma-separated list; anything non-numeric is dropped.
*/
function inventionIdQuery(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id)) ?? []
)
}
// ---- Avatar gifts ----------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
// gift-box consume live in the `econ` worker, which the client calls on the econ host
@@ -197,6 +225,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json([])
)
// A batch lookup of custom avatar items by id. The reference filters a static catalog
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
// ships empty too), so every id misses and the array is empty.
//
// Auth-gated, and the token is checked before anything else, as the reference does.
.post(
'/api/customAvatarItems/v1/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Custom avatar items in bulk',
description:
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
'answers this route with the filtered array alone.\n\n' +
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
'client reads the items it got back rather than the ids it asked for. Nothing ' +
'stores custom items here, so every id misses and this is always `[]` — which is ' +
'why the posted ids are not parsed.',
security: AUTHED,
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
responses: {
200: json(JsonArray, 'The matching items — always empty here'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get(
@@ -213,6 +277,141 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json({ Results: [], TotalResults: 0 })
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
// can render the custom version instead. Nothing stores custom items yet, so nothing
// has a save — an empty list means "use the legacy items as-is".
.post(
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
describeRoute({
tags: ['Avatar'],
summary: 'Custom-item saves for legacy avatar items',
description:
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
'request body is ignored.\n\n' +
'The value shape is the official one, recorded here for documentation; we never ' +
'emit one until custom items are stored.',
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
}),
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
)
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
// worker's saved-outfit slots live in); a player who has never saved gets the
// brand-new-account envelope instead.
.get(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'The caller’s outfit',
description:
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
'handed back exactly as it was saved, since the payload’s heavy fields are the ' +
'client’s own JSON-in-a-string documents.\n\n' +
'A player who has never saved gets the brand-new-account envelope: all-null ' +
'`LegacyData`, no `Selections`, `DataVersion` 9.',
security: AUTHED,
responses: {
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
if (outfit !== null) return c.json(outfit)
return c.json({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
})
}
)
// Saving an outfit through the same bare path — into the slot the body names, which
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
// it has to parse back. Answers the saved outfit, which is what the client re-renders
// from.
.put(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'Save the caller’s outfit',
description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
'strings by the client’s own serializer, so nothing here parses or re-encodes them.',
security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: {
200: json(OutfitsMeRequest, 'The outfit as stored'),
400: json(ErrorResponse, 'Unparseable body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
// The client sends `Slot`; a body without one saves the worn outfit.
const outfit = {
...body,
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
}
await setOutfit(c.env.DB, id, outfit)
return c.json(outfit)
}
)
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
// belong in this list (and in what shape) has not been pinned down, so it answers []
// rather than guessing.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar'],
summary: 'The caller’s saved outfits',
description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
'list should carry, and in what shape, is not pinned down yet.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention.
.get(
@@ -270,12 +469,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
}),
async (c) => {
const ids = c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id))
if (ids === undefined || ids.length === 0) return c.json([])
const ids = inventionIdQuery(c)
if (ids.length === 0) return c.json([])
const playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
@@ -287,6 +482,40 @@ 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,
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get(
@@ -330,18 +559,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public.
// Only the current version exists (nothing writes version history yet), so any
// other version number 404s rather than naming a blob that isn't there.
// RRInventionVersion, which carries the blob name the client downloads and the
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
// version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get(
'/api/inventions/v1/version',
describeRoute({
tags: ['Inventions'],
summary: 'One version of an invention',
description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
'Only the current version exists — nothing writes version history yet — so any ' +
'other version number 404s rather than naming a blob that is not there.',
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'),
@@ -358,39 +590,53 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
const version = await getInventionVersion(
c.env.DB,
c.env.CDN_ASSETS,
inventionId,
versionNumber
)
return version === null ? c.notFound() : c.json(version)
}
)
// Edit an invention's metadata. A GET that writes — that's what the client sends
// (`?inventionId=1&description=my+description`), with the fields to change as
// query params. Absent params keep their stored value; `permission` sets what
// other players may do with it (a name like `useonly` or the raw number). An
// empty `description` clears it, but an empty `name`/`imageName` is ignored
// rather than blanking the invention. Publishing and pricing are separate
// endpoints. Auth-gated, creator only; answers the save envelope.
.get(
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
// GET that writes in some places and as a bodyless POST in others (the permission
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
// neither reads a body. Absent params keep their stored value; `permission` sets
// what other players may do with it. An empty `description` clears it, but an empty
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
.on(
['GET', 'POST'],
'/api/inventions/v1/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an invention’s metadata',
description:
'A GET that writes — that is what the client sends, with the fields to change as ' +
'query params. Absent params keep their stored value. An empty `description` ' +
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
'invention. Publishing and pricing are separate endpoints.',
'GET or POST — the client sends both, and the fields to change ride as query ' +
'params either way; no body is read. Absent params keep their stored value. An ' +
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
'than blanking the invention. A supplied name/description must satisfy the same ' +
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('name', 'New name; empty is ignored'),
stringQuery('description', 'New description; present-but-empty clears it'),
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
stringQuery(
'permission',
'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' +
'`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' +
'or the raw number is accepted'
),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -408,10 +654,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
name,
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
description,
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
@@ -515,13 +774,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' +
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: {
200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body'),
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -538,11 +799,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const strings = (v: unknown): 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(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
autoTags,
customTags
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
}
@@ -573,17 +852,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
'daily counters, so “today” is a label, not a window.',
'Published inventions ranked by how many players acquired them in the last 24 ' +
'hours, counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a window: an invention nobody has picked up ' +
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
'empty list. It trails the clock rather than resetting at midnight.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
@@ -594,16 +877,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
.get(
'/api/inventions/v1/featured',
describeRoute({
tags: ['Inventions'],
summary: 'The featured feed',
description:
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
'curated — so this is never empty just because no one has picked favourites.',
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
'Serves an empty list while nothing is flagged rather than standing in the top ' +
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
}),
@@ -640,16 +924,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
// afterwards: unpublished or hidden since, the buyer paid for it.
// Auth-gated; returns a bare array (empty when the player has neither).
.get(
'/api/inventions/v2/mine',
describeRoute({
tags: ['Inventions'],
summary: 'The caller’s own inventions',
description:
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
'see. Not paginated.',
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The caller’s inventions'),
@@ -659,7 +947,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getInventionsByCreator(c.env.DB, id))
return c.json(await getMyInventions(c.env.DB, id))
}
)
@@ -678,14 +966,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Records an invention’s metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected.\n\n' +
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
'characters (description).\n\n' +
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -704,11 +997,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
const invention = await createInvention(c.env.DB, {
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
+39 -4
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION } from '@repo/domain'
import { isSupportedGameVersion } from '@repo/domain'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
@@ -10,6 +10,7 @@ import {
ApiConfigV2,
AzureSpeechConfig,
BacktraceConfig,
IslandedVersions,
json,
JsonObject,
VersionCheck,
@@ -100,18 +101,33 @@ export const configRoutes = new Hono<App>({ strict: false })
summary: 'Client version check',
description:
'Whether the client build is current. Compares the client’s `?v=` build against ' +
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
'client is on a different build.',
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
'client is on one of them, 1 when it is on some other build.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
// Islanding splits players onto version-specific matchmaking pools. We serve every
// supported build from one pool, so the list is empty — the client reads it as
// "nobody is islanded" and matchmakes normally.
.get(
'/api/versioncheck/islandedversions',
describeRoute({
tags: ['Config'],
summary: 'Islanded client builds',
description:
'The builds that are islanded off into their own matchmaking pool. This server ' +
'never islands a build, so the list is always empty.',
responses: { 200: json(IslandedVersions, 'Always an empty list') },
}),
(c) => c.json([])
)
.get(
'/api/gameconfigs/v1/all',
describeRoute({
@@ -123,6 +139,25 @@ export const configRoutes = new Hono<App>({ strict: false })
(c) => c.json(gameConfigsV1All)
)
// The property bag the client would attach to its Statsig user. Analytics are off here
// (see the placeholder keys `/api/config/v1/amplitude` serves), so there is nothing to
// segment on and the bag is empty — the client reads that as "no overrides" and carries
// on, which is why this answers `{}` rather than 404ing.
.get(
'/statsigUserProperties',
describeRoute({
tags: ['Config'],
summary: 'Statsig user properties',
description:
'The custom properties the client would attach to its Statsig user for experiment ' +
'targeting. This server runs no experiments and collects no analytics, so the bag ' +
'is always empty — the client reads `{}` as “no overrides”. Not auth-gated: there ' +
'is nothing per-account to leak in an empty object.',
responses: { 200: json(JsonObject, 'An empty object') },
}),
(c) => c.json({})
)
// Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get(
+678
View File
@@ -0,0 +1,678 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
eventInputRejection,
getEventAttendees,
getEventById,
getEventResponse,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventTags,
getLiveEvents,
inviteToEvent,
isEventResponseType,
parseEventBody,
searchEvents,
setEventResponse,
toEventListing,
toEventNotification,
toEventResponse,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventBulkInviteRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventListingDto,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResponseDto,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventTag, PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
* creator — what makes the event appear on their own screen without a refetch.
*
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
* must not fail the create. Note the frame carries the camelCase
* {@link toEventNotification} projection, not the PascalCase record the response does.
*/
async function notifyEventCreated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event, tags) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added —
* what puts the event on their screen without a refetch, since an invite writes their
* response row for them.
*
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about.
*
* The frame carries BOTH nested objects the client's decoder expects. That is not
* optional — several of its handlers dereference one level down with no null guard, so
* omitting one surfaces as a NullReferenceException in the client rather than a missing
* field (see notification-payloads.ts). The event goes in the same camelCase
* {@link toEventNotification} projection the `PlayerEventCreated` frame uses, and the
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
* decoder accepts either casing, so the two need not agree.
*
* Hub failures are logged and swallowed, and one player's failure doesn't stop the
* rest: the invites are already stored by the time this runs.
*/
async function notifyInvited(
c: Context<App>,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const PlayerEvent = { ...toEventNotification(event) }
for (const row of added) {
const payload = {
PlayerEvent,
PlayerEventResponse: { ...toEventResponse(row) },
} satisfies PlayerEventResponsePayload
try {
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: row.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/**
* Player events — scheduled events players and clubs host in a room.
*
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
* blob IS the DTO, so every read here serves it verbatim; only the create/update
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
*
* Watch the response shapes: the two club feeds deliberately differ (bare array for
* the multi-club form, paged envelope for the single-club one) and the client chokes
* if they're unified.
*/
export const eventRoutes = new Hono<App>({ strict: false })
// The player-events browse feed — everything upcoming or running, soonest first. Same
// query `/search` runs with no text, but its own projection: this feed drops `State`
// and carries a `BroadcastingRoomInstanceId`, so it goes through `toEventListing`.
.get(
'/api/playerevents/v1',
describeRoute({
tags: ['Events'],
summary: 'The player-events browse feed',
description:
'The default feed on the player-events screen: every event that has not finished ' +
'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' +
'array.\n\n' +
'Each entry is the browse LISTING, not the stored record the by-id, bulk and ' +
'search reads serve: it drops `State` and carries ' +
'`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' +
'That is the shape observed on this endpoint; keep the two projections apart.',
parameters: pageParams(50),
responses: { 200: json(PlayerEventListingDto.array(), 'The events that have not ended') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
const events = await searchEvents(c.env.DB, '', skip, take)
return c.json(events.map(toEventListing))
}
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The 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) })
}
)
+18 -104
View File
@@ -6,25 +6,21 @@ import communityBoard from '../../static/community-board.json'
import {
BareString,
idParam,
intQuery,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeCategories,
KeepsakeConfig,
PlayerEventsAll,
PlayerEventsPage,
SanitizeRequest,
stringParam,
SubscriptionResponse,
TagFilters,
} from '../openapi'
import type { App } from '../context'
// Text sanitization, keepsakes, objectives/events/rewards, and the misc
// analytics/subscription sinks the client hits during load.
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
// sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean.
@@ -102,15 +98,25 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.body(null, 204)
)
// A counted result set, NOT the bare list the stubs around it serve: the client parses
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
// page; the reference returns `results.Length`.
.get(
'/api/keepsakes/categories',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake categories',
description: 'No keepsake catalog yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
description:
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
"The client parses this one as an object and fails on an array (\"expected '{', " +
"actual '['\"), taking the keepsake load down with it. `TotalResults` counts " +
'`Results` itself — there is no paging here.',
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
}),
(c) => c.json([])
(c) => c.json({ Results: [], TotalResults: 0 })
)
// ---- Objectives / events / rewards ---------------------------------------
@@ -128,86 +134,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
.get(
'/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([])
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
@@ -232,17 +160,3 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(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 })
)
+21 -3
View File
@@ -1,7 +1,6 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, unauthorized } from '../http'
import {
createImage,
deleteImage,
@@ -13,8 +12,12 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '../images-db'
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
CheeredEntry,
@@ -312,6 +315,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
// table — and the callers that rotate one photo at a time (the website's hero) don't
// want a long feed anyway.
.get(
'/api/images/v1/slideshow',
describeRoute({
@@ -323,10 +330,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.',
parameters: [
intQuery(
'take',
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
),
],
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}),
async (c) => {
const Images = await getSlideshowImages(c.env.DB)
// Junk, zero and negative takes fall back to the default rather than 400ing or
// serving an empty stage — the caller is a homepage, and no photos reads as the
// server being down.
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
const Images = await getSlideshowImages(c.env.DB, take)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
}
+217 -10
View File
@@ -1,34 +1,90 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
json,
JsonArray,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
* their admin surfaces on: a warning is a moderation action, but staff hold both.
*/
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
/**
* Read one field of a submitted form. The client posts these form-encoded, but the
* same names also arrive as a query string on some builds, so both are accepted.
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
const asFloat = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseFloat(v)
return Number.isNaN(n) ? null : n
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
// an empty string — the client distinguishes "no message" from a blank one.
.get(
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not the empty string that stub sends — the client distinguishes
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
// DTO but left unset there, so they go out with their C# defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
['GET', 'POST'],
'/api/PlayerReporting/v1/moderationBlockDetails',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
'this is always the “not blocked” answer. Two details matter to the client: ' +
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
'`Message` is null rather than an empty string — the client distinguishes “no ' +
'message” from a blank one.',
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `Message` is null rather than the empty string that stub ' +
'sends — the client distinguishes “no message” from a blank one. ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
(c) =>
@@ -56,6 +112,43 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
) // TODO: hydrate from JSON/vtkreasons.json
// The client asking whether IT should run its referee moderation — the in-client
// review flow a player with referee standing gets shown. Deliberately `false` for
// everyone: this is an archival server, and the referee program is one of the live
// moderation systems it does not run. Answering true would put the client into a flow
// with no cases behind it. A POST despite being a pure read, which is how the client
// asks.
.post(
'/api/PlayerReporting/v1/referee',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is a referee',
description:
'A bare JSON `false` — no envelope. The game client asks this to decide whether to ' +
'run its referee moderation flow. Always false: the referee program is switched ' +
'off here rather than unimplemented, since this server is archival.',
responses: { 200: json(BareBoolean, 'Always `false` — the program is off') },
}),
(c) => c.json(false)
)
// The referee's own case files — the reviews assigned to them. Empty for the same
// reason the flag above is false: the program is off, so no case is ever assigned. A
// caller reaching this at all has gone past that flag, so the empty list is a second
// line of defence rather than the normal path. A GET, unlike its POSTing neighbours
// in this flow.
.get(
'/api/referee/files',
describeRoute({
tags: ['Moderation'],
summary: 'Referee case files',
description:
'The moderation cases assigned to the caller as a referee. Always empty — the ' +
'referee program is switched off here (see `/api/PlayerReporting/v1/referee`), so ' +
'nothing is ever assigned.',
responses: { 200: json(JsonArray, 'An empty list — no cases are ever assigned') },
}),
(c) => c.json([])
)
.post(
'/api/PlayerReporting/v1/hile',
describeRoute({
@@ -69,6 +162,120 @@ export const moderationRoutes = new Hono<App>({ strict: 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`,
// `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
+158
View File
@@ -0,0 +1,158 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
// The `playersettings` worker owns this KV map; its defaults are imported as a value
// (a plain array, no runtime dependencies) so a first write from here seeds the same
// settings that worker's first read would have.
import { DEFAULT_SETTINGS } from '../../../playersettings/src/default-settings'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
json,
jsonBody,
PhotoTaggingSetting,
SetPhotoTaggingSettingRequest,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Player-account preferences. These live in the same per-player KV bag the
* `playersettings` worker serves (`player:{id}` → `{ key: value }`), just under a
* dedicated route the client calls by name — the reference keeps the photo-tagging
* setting on the player record, but a settings key is the same thing without a table.
*/
/** The settings key the photo-tagging preference is stored under. */
const PHOTO_TAGGING_KEY = 'PlayerPhotoTaggingSetting'
/**
* `PlayerPhotoTaggingSetting` — who may tag this player in a photo. Serialized as the
* ordinal, not the name: the reference server leaves `JsonStringEnumConverter` off, so
* the client's decoder is reading a number.
*/
const PHOTO_TAGGING_VALUES = ['Anyone', 'Friends', 'NoOne'] as const
/** `Anyone` — what a player who has never set one reads back as. */
const PHOTO_TAGGING_DEFAULT = 0
/** The caller's KV key in the shared player-settings bag. */
function settingsKey(id: number): string {
return `player:${id}`
}
/**
* Coerce a posted setting to its ordinal. Accepts the number the client sends and the
* enum NAME as well, so a client that spells it out still lands on the right value.
* `null` when the body carries nothing recognizable — the caller answers `false`.
*/
function parsePhotoTaggingSetting(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isInteger(raw)) {
return raw >= 0 && raw < PHOTO_TAGGING_VALUES.length ? raw : null
}
if (typeof raw !== 'string' || raw === '') return null
const asNumber = Number.parseInt(raw, 10)
if (!Number.isNaN(asNumber)) {
return asNumber >= 0 && asNumber < PHOTO_TAGGING_VALUES.length ? asNumber : null
}
const named = PHOTO_TAGGING_VALUES.findIndex((v) => v.toLowerCase() === raw.toLowerCase())
return named === -1 ? null : named
}
/**
* The `Setting` field out of a PUT body: JSON (what the client posts, `{ "Setting": 1 }`),
* or a form-urlencoded `Setting` for hand-rolled callers. Either casing is accepted.
*/
async function readSetting(c: Context<App>): Promise<number | null> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await c.req.json<unknown>().catch(() => null)
if (body === null || typeof body !== 'object') return null
const rec = body as Record<string, unknown>
return parsePhotoTaggingSetting(rec.Setting ?? rec.setting)
}
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return parsePhotoTaggingSetting(form.Setting ?? form.setting)
}
// ---- Players ---------------------------------------------------------------
export const playerRoutes = new Hono<App>({ strict: false })
.get(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Players'],
summary: 'Who may tag the caller in photos',
description:
'The caller’s `PlayerPhotoTaggingSetting` as the enum ORDINAL — `0` Anyone, `1` ' +
'Friends, `2` NoOne — read from the `PlayerPhotoTaggingSetting` key of the shared ' +
'player-settings bag the `playersettings` worker serves. A player who has never set ' +
'one reads back `0` (Anyone), which is the reference’s default; nothing is written ' +
'on a read.',
security: AUTHED,
responses: {
200: json(PhotoTaggingSetting, 'The setting, as its ordinal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
settingsKey(id),
'json'
)
const parsed = parsePhotoTaggingSetting(stored?.[PHOTO_TAGGING_KEY])
return c.json(parsed ?? PHOTO_TAGGING_DEFAULT)
}
)
.put(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Players'],
summary: 'Set who may tag the caller in photos',
description:
'Writes `{ "Setting": 0 | 1 | 2 }` to the caller’s `PlayerPhotoTaggingSetting` key ' +
'and answers a bare `true`, as the reference does (it answers `false` when there was ' +
'nothing to update — here, when the body carries no recognizable setting). The enum ' +
'NAME is accepted alongside the ordinal. The write MERGES into the player’s settings ' +
'bag, so it leaves every other key alone; a player with no bag yet is seeded with the ' +
'`playersettings` defaults first, so this write can’t cost them that seeding.',
security: AUTHED,
requestBody: jsonBody(SetPhotoTaggingSettingRequest, 'The setting to store'),
responses: {
200: json(BareBoolean, 'True when the setting was written'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const setting = await readSetting(c)
if (setting === null) return c.json(false)
const kvKey = settingsKey(id)
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const base =
existing && Object.keys(existing).length > 0
? existing
: Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(
kvKey,
JSON.stringify({ ...base, [PHOTO_TAGGING_KEY]: String(setting) })
)
return c.json(true)
}
)
+47 -6
View File
@@ -1,6 +1,12 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { parseFormIds, queryIds } from '../http'
import {
BulkIdsRequest,
@@ -13,8 +19,35 @@ import {
ReputationDto,
} from '../openapi'
import type { Context } from 'hono'
import type { Progression } from '@repo/domain'
import type { App } from '../context'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push the caller's own progression back at them over the socket, mirroring the reference's
* `HubSendProgressionUpdate` on this same read. Pushing from a GET looks odd, but it is how
* a client that just connected gets its level bar right: the frame is what the client acts
* on, the response body is only what it asked for. Best-effort — a hub failure leaves the
* body correct.
*/
async function pushProgression(c: Context<App>, progression: Progression): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
progression.PlayerId,
NotificationType.PlayerProgressionLevelUpdate,
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
)
} catch (err) {
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
accountId: progression.PlayerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Default reputation for an account — the fallback used with no DB. Nobody has
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
@@ -69,13 +102,20 @@ export const progressionRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Progression'],
summary: 'A player’s level and XP',
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
description:
'The level and XP banked in `progression` (game rewards pay into it from the `econ` ' +
'worker); `XP` is the progress into the current level, not a lifetime total. A ' +
'player who has earned none has no row and reads back as level 1 with 0 XP. Also ' +
'pushes the same values as a `PlayerProgressionLevelUpdate` frame, as the reference ' +
'does — that is what moves the client’s bar.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The player’s progression') },
}),
(c) => {
async (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
const progression = await getProgression(c.env.DB, id)
await pushProgression(c, progression)
return c.json(progression)
}
)
.post(
@@ -160,12 +200,13 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'],
summary: 'Progressions in bulk (GET form)',
description:
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
'default level-1 progression per requested id, in request order.',
'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
'ids that have earned nothing.',
parameters: BULK_ID_QUERY,
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
}),
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
async (c) => c.json(await getProgressions(c.env.DB, queryIds(c)))
)
.post(
'/api/v1/progression/bulk',
+240 -13
View File
@@ -1,41 +1,83 @@
import { Hono } from 'hono'
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'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, unauthorized } from '../http'
import {
AckResponse,
AUTHED,
ErrorResponse,
form,
intQuery,
json,
JsonArray,
jsonBody,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SendMultipleMessagesRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
acceptFriendRequest,
addFriend,
getRelationshipsForPlayer,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
} from '../relationships-db'
import type { Context } from 'hono'
import type { App } from '../context'
import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
} from '../relationships-db'
} from '@repo/domain'
import type { App } from '../context'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
const RELATIONSHIP_CHANGED = 1
/**
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
*/
type Message = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* 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
@@ -50,7 +92,7 @@ async function notifyRelationship(
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
RELATIONSHIP_CHANGED,
NotificationType.RelationshipChanged,
{ ...rel }
)
} catch (err) {
@@ -199,6 +241,191 @@ 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
// 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
+2281 -80
View File
@@ -2,16 +2,51 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import {
addXp,
applyLevelUps,
createImage,
GAME_VERSION,
getImageByName,
grantInvention,
IMAGE_SCHEMA_DDL,
INVENTORY_INVENTION_SCHEMA_DDL,
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
SUPPORTED_GAME_VERSIONS,
} from '@repo/domain'
import '../../api.app'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import {
banFromReport,
createReport,
getActiveBan,
getReportsAgainst,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
@@ -44,14 +79,9 @@ const TEST_ROOMS = [
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -74,13 +104,32 @@ beforeAll(async () => {
.run()
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of IMAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links (owned by the auth worker) — the sharp arm of the
// ban-evasion resolution matches on them.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -94,10 +143,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -110,6 +162,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
@@ -144,11 +202,29 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/versioncheck/v4 reports current for every supported build', async () => {
for (const version of SUPPORTED_GAME_VERSIONS) {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
}
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/versioncheck/v4 flags a client that sends no build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/versioncheck/islandedversions is empty', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
@@ -188,17 +264,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
// No player-event storage → no tags in use → no chips. Trending is null.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
PinnedFilters: [],
PopularFilters: [],
TrendingFilters: null,
})
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
@@ -208,43 +273,45 @@ describe('public endpoints', () => {
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
// The client deserializes this as a bare array — an envelope here fails with
// "expected:'[', actual:'{'". No player-event storage yet → empty.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The client POSTs this with no body, despite it being a pure read; the route answers
// GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
async (method) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method }
)
expect(res.status).toBe(200)
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
}
)
// 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 () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(200)
// ReportCategory -1 = no category (0 is a real one), and Message is null.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
test('POST /api/PlayerReporting/v1/referee says the caller is not one', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/referee`, {
method: 'POST',
})
expect(res.status).toBe(200)
// A bare boolean, not an envelope or a list.
expect(await res.json()).toBe(false)
})
test('GET /api/referee/files has no cases', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/referee/files`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// Unauthenticated by design — the client posts this before it has an account, so
@@ -290,6 +357,89 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('progression reads back the XP game rewards banked, levelled up', async () => {
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
// 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5.
expect(await addXp(env.DB, 4242, 25)).toEqual({
progression: { PlayerId: 4242, Level: 3, XP: 5 },
levelsGained: 2,
})
// The next 25 lands on 5: 10 to reach level 4, then 20 to reach 5, leaving nothing.
await addXp(env.DB, 4242, 25)
const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`)
expect(await single.json()).toEqual({ PlayerId: 4242, Level: 5, XP: 0 })
// A player who has earned nothing has no row, and still gets a record — the bulk form
// renders a card per id, so a missing one must not shorten the list.
const bulk = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243`
)
expect(await bulk.json()).toEqual([
{ PlayerId: 4242, Level: 5, XP: 0 },
{ PlayerId: 4243, Level: 1, XP: 0 },
])
})
test('the level ladder the server uses is the one the client is served', async () => {
// The client draws its bar against `LevelProgressionMaps` from this config; the server
// levels by LEVEL_REQUIRED_XP. If they drift, the bar fills to a different mark than
// the level-up fires at.
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
expect(res.status).toBe(200)
const config = (await res.json()) as {
LevelProgressionMaps: Array<{ Level: number; RequiredXp: number; GiftRarity: number }>
}
expect(config.LevelProgressionMaps.map((m) => m.RequiredXp)).toEqual([...LEVEL_REQUIRED_XP])
// The config's own `GiftRarity` is deliberately NOT asserted against `LEVEL_REWARDS`:
// it is a coarse per-band tier (flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap)
// and we grant from the published per-level table instead, which disagrees in places —
// level 15 is 2-Star there and 20 here. Only the XP costs have to match.
expect(config.LevelProgressionMaps.map((m) => m.GiftRarity)).toHaveLength(LEVEL_REWARDS.length)
// Indexed by level, so entry N is what a level-N player spends to reach N+1.
expect(config.LevelProgressionMaps.map((m) => m.Level)).toEqual(
LEVEL_REQUIRED_XP.map((_, level) => level)
)
})
test('the level rewards match the published reward table', async () => {
// Rec Room's published level-reward table, spot-checked at the points where it turns:
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
// 5★ = 50). These are the levels an off-by-one in the table would move.
expect(LEVEL_REWARDS[0]).toBe(0) // nobody reaches level 0
expect([1, 3, 5, 6, 7, 9].map((level) => LEVEL_REWARDS[level])).toEqual([
-1, -1, -1, -1, -1, -1,
])
expect([2, 4, 8, 10, 21].map((level) => LEVEL_REWARDS[level])).toEqual([10, 10, 10, 10, 10])
expect([22, 30].map((level) => LEVEL_REWARDS[level])).toEqual([20, 20])
expect([31, 35, 40, 49].map((level) => LEVEL_REWARDS[level])).toEqual([30, 30, 30, 30])
expect(LEVEL_REWARDS[50]).toBe(50) // the only 5-Star in the progression
expect(LEVEL_REWARDS).toHaveLength(51)
})
test('the ladder matches the published XP curve', async () => {
// Rec Room's own level-curve chart, read at its gridlines: cumulative XP to finish each
// level. The per-level costs are easy to edit one at a time and hard to eyeball as a
// curve, so the milestones are what actually pin the shape.
const cumulative = LEVEL_REQUIRED_XP.reduce<number[]>((totals, cost, level) => {
totals[level] = level === 0 ? 0 : (totals[level - 1] ?? 0) + cost
return totals
}, [])
expect(cumulative[10]).toBe(170)
expect(cumulative[20]).toBe(620)
expect(cumulative[30]).toBe(1770)
expect(cumulative[40]).toBe(5370)
expect(cumulative[50]).toBe(16170)
})
test('levelling stops at the top of the ladder', async () => {
// Nothing above MAX_LEVEL to buy, so a huge grant banks XP and stays put.
expect(applyLevelUps(MAX_LEVEL, 100_000)).toEqual({ level: MAX_LEVEL, xp: 100_000 })
// …and a grant that doesn't cover the current level's cost just accrues.
expect(applyLevelUps(1, 9)).toEqual({ level: 1, xp: 9 })
})
test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST',
@@ -338,6 +488,166 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve. Nothing stores custom items, so every id
// misses, and a miss is an absent entry rather than an error.
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer()),
},
// Repeated form field, as `[FromForm] List<string>` binds it.
body: new URLSearchParams([
['customAvatarItemIds', 'a'],
['customAvatarItemIds', 'b'],
]),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// The ids are never parsed (nothing could match), so a missing body is still a 200
// rather than the 400 a body-reading handler would produce.
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/customAvatarItems/v1/bulk is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
})
expect(res.status).toBe(401)
})
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }),
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
})
test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
expect(anon.status).toBe(401)
// Account 77 never saves an outfit, so it keeps getting the new-account envelope.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
})
})
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
// The client's own payload, trimmed to one selection: the point is that the heavy
// JSON-in-a-string fields survive the round trip as strings, unparsed.
const outfit = {
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2:
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: 0,
Name: null,
Accessibility: 1,
ThumbnailFileName: null,
}
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(outfit)
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
// strings, not re-encoded objects.
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await read.json()).toEqual(outfit)
// Re-saving overwrites slot 0 rather than adding a second row.
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(changed),
})
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await reread.json()).toEqual(changed)
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
).first<{ n: number }>()
expect(rows?.n).toBe(1)
// A save naming another slot does not touch what the caller is wearing.
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
})
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401)
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('PUT /outfits/me 400s on an unparseable body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: 'not json',
})
expect(res.status).toBe(400)
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
expect(res.status).toBe(200)
@@ -352,12 +662,20 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
})
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => {
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
expect(room.status).toBe(204)
// A result set, not a list: the client parses this one as an object and an array
// fails it outright ("expected '{', actual '['").
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
expect(cats.status).toBe(200)
expect(await cats.json()).toEqual([])
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /statsigUserProperties returns an empty object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/statsigUserProperties`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
test('GET /voice/config returns an object', async () => {
@@ -425,7 +743,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
@@ -457,6 +775,49 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -488,6 +849,51 @@ describe('public endpoints', () => {
})
})
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
@@ -567,6 +973,39 @@ describe('public endpoints', () => {
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
@@ -714,6 +1153,52 @@ describe('public endpoints', () => {
expect(await batch('')).toEqual([])
})
test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => {
const save = async (sub: string, name: string): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const owns = async (query: string, sub: string): Promise<unknown> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fulllineageowner?${query}`,
{ headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return await res.json()
}
// 7301 makes two; 7302 makes one and buys one of 7301's.
const own = await save('7301', 'Lineage Root')
const nested = await save('7301', 'Lineage Nested')
const others = await save('7302', 'Someone Elses')
await grantInvention(env.DB, 7302, nested.InventionId)
// The creator owns their own lineage; one invention that isn't theirs sinks it.
expect(await owns(`id=${own.InventionId}&id=${nested.InventionId}`, '7301')).toBe(true)
expect(
await owns(`id=${own.InventionId}&id=${nested.InventionId}&id=${others.InventionId}`, '7301')
).toBe(false)
// Bought counts as owned, and comma-separated ids parse like the batch endpoint.
expect(await owns(`id=${nested.InventionId},${others.InventionId}`, '7302')).toBe(true)
expect(await owns(`id=${own.InventionId}`, '7302')).toBe(false)
// An id with no invention behind it is not owned, whoever asks.
expect(await owns(`id=${own.InventionId}&id=999999`, '7301')).toBe(false)
// No ids at all: nothing in an empty lineage is unowned.
expect(await owns('', '7301')).toBe(true)
})
test('GET /api/inventions/v1/fulllineageowner 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fulllineageowner?id=1`)
expect(res.status).toBe(401)
})
test('GET /api/inventions/v1/room lists a room’s published inventions', async () => {
// Two inventions created in room 76, one of them still a draft.
const create = async (name: string, room: number): Promise<SavedInvention> => {
@@ -766,6 +1251,12 @@ describe('public endpoints', () => {
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -777,7 +1268,8 @@ describe('public endpoints', () => {
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads.
// The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
@@ -786,6 +1278,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42,
})
@@ -808,6 +1301,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -849,6 +1380,16 @@ describe('public endpoints', () => {
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
@@ -869,6 +1410,42 @@ describe('public endpoints', () => {
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v1/update takes the permission picker’s query params', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('3232')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Posted Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const post = async (query: string, sub = '3232'): Promise<Response> =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
{ method: 'POST', headers: await bearer(sub) }
)
// The picker posts the permission by CamelCase name, with no body at all.
const permission = async (name: string): Promise<number> => {
const res = await post(`permission=${name}`)
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention.GeneralPermission
}
expect(await permission('UseOnly')).toBe(20)
expect(await permission('EditAndSave')).toBe(40)
expect(await permission('Publish')).toBe(60)
// Setting the permission is not publishing — that stays v3/publish's job.
const still = await post('permission=Publish')
expect(((await still.json()) as InventionSaveResult).Invention.IsPublished).toBe(false)
// Same gate as the GET: creator only, and a token is required.
expect((await post('permission=UseOnly', '9999')).status).toBe(403)
const anonPost = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&permission=Publish`,
{ method: 'POST' }
)
expect(anonPost.status).toBe(401)
})
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -960,12 +1537,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
@@ -1003,19 +1583,42 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
// the feed really does forget, rather than accumulating every acquisition ever.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
// Featured: only the flagged, visible inventions — newest first.
// Top: most acquisitions in the window first. Download counts no longer rank anything —
// 202 has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
expect(await ids(featuredPage)).toEqual([202])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
@@ -1033,6 +1636,95 @@ describe('public endpoints', () => {
})
})
describe('account', () => {
test.each(['email', 'phone', 'anything'])(
'GET /iam/me/channels/%s is an empty list',
async (type) => {
const res = await exports.default.fetch(`${ORIGIN}/iam/me/channels/${type}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
})
describe('photo tagging setting', () => {
const PATH = `${ORIGIN}/api/players/v1/playerPhotoTaggingSetting`
const read = async (sub: string) => exports.default.fetch(PATH, { headers: await bearer(sub) })
const write = async (sub: string, body: unknown) =>
exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
body: JSON.stringify(body),
})
test('both verbs 401 without a bearer token', async () => {
expect((await exports.default.fetch(PATH)).status).toBe(401)
const put = await exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Setting: 1 }),
})
expect(put.status).toBe(401)
})
test('a player who has never set one reads Anyone (0)', async () => {
const res = await read('9001')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('PUT stores the setting and answers true', async () => {
const put = await write('9002', { Setting: 2 })
expect(put.status).toBe(200)
expect(await put.json()).toBe(true)
expect(await (await read('9002')).json()).toBe(2)
})
test('the enum name is accepted alongside the ordinal', async () => {
expect(await (await write('9003', { Setting: 'Friends' })).json()).toBe(true)
expect(await (await read('9003')).json()).toBe(1)
})
test('an unrecognized setting is a false, and changes nothing', async () => {
await write('9004', { Setting: 2 })
expect(await (await write('9004', { Setting: 7 })).json()).toBe(false)
expect(await (await write('9004', {})).json()).toBe(false)
expect(await (await read('9004')).json()).toBe(2)
})
test('the write merges — the player’s other settings survive', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:9005',
JSON.stringify({ TUTORIAL_COMPLETE_MASK: '11' })
)
await write('9005', { Setting: 1 })
expect(
await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>('player:9005', 'json')
).toEqual({ TUTORIAL_COMPLETE_MASK: '11', PlayerPhotoTaggingSetting: '1' })
})
test('a first write seeds the playersettings defaults alongside it', async () => {
await write('9006', { Setting: 2 })
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:9006',
'json'
)
expect(stored).toMatchObject({ 'Recroom.OOBE': '77', PlayerPhotoTaggingSetting: '2' })
})
test('a form-urlencoded PUT is accepted too', async () => {
const res = await exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('9007')) },
body: new URLSearchParams({ Setting: '1' }),
})
expect(await res.json()).toBe(true)
expect(await (await read('9007')).json()).toBe(1)
})
})
describe('auth-gated endpoints', () => {
test('401 without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
@@ -1047,6 +1739,257 @@ describe('auth-gated endpoints', () => {
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/PlayerReporting/v3/create records the report', async () => {
const res = await submit(
{
PlayerIdReported: '205',
ReportCategory: '100',
Details: 'ya know',
HeightReporter: '1.64',
HeightReported: '1.65',
RoomId: '58',
RoomInstanceType: 'Public',
},
await bearer()
)
expect(res.status).toBe(200)
// `error` is an empty string, not null — the real service's envelope.
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getReportsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The reporter is the token's subject, not a body field.
reporter_player_id: 42,
reported_player_id: 205,
report_category: 100,
details: 'ya know',
height_reporter: 1.64,
height_reported: 1.65,
room_id: 58,
room_instance_type: 'Public',
})
expect(row?.created_at).toBeTruthy()
})
// Everything but the reported player is optional — a report raised outside a room
// carries no RoomId, and 0 means "no room" rather than room zero.
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
expect(res.status).toBe(200)
const [row] = await getReportsAgainst(env.DB, 206)
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 206,
report_category: 0,
details: null,
height_reporter: null,
height_reported: null,
room_id: null,
room_instance_type: null,
})
})
// Append-only: a second report against the same player is a second row.
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
const rows = await getReportsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
})
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
const res = await submit({ PlayerIdReported: '205' })
expect(res.status).toBe(401)
})
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
const res = await submit({ Details: 'ya know' }, await bearer())
expect(res.status).toBe(400)
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
// A report is filed unbanned; a moderator converting it into a ban is what the
// `banned` / `ban_expires` columns are for. `match` and `auth` read exactly this.
test('a report is filed unbanned', async () => {
await submit({ PlayerIdReported: '210' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 210)
expect(row).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 210)).toBe(false)
})
test('banFromReport bans the reported player, permanently by default', async () => {
await submit({ PlayerIdReported: '211', Details: 'the evidence' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 211)
const banned = await banFromReport(env.DB, row!.id)
expect(banned).toMatchObject({ banned: 1, ban_expires: null })
// The report the ban was made from is still attached to it — the point of
// banning on the row rather than in a table of its own.
expect(banned?.details).toBe('the evidence')
expect(await isPlayerBanned(env.DB, 211)).toBe(true)
// It bans the REPORTED player, not the reporter who filed it.
expect(await isPlayerBanned(env.DB, 42)).toBe(false)
})
// A timed ban lifts itself: nothing clears the flag, the expiry just passes.
test('a ban with a past expiry is no longer in force', async () => {
await submit({ PlayerIdReported: '212' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 212)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 212)).toBe(false)
// Still on the row, as the record that it happened.
expect((await getReportsAgainst(env.DB, 212))[0]).toMatchObject({ banned: 1 })
// And in force while it lasted.
expect(await isPlayerBanned(env.DB, 212, new Date('2019-06-01T00:00:00.000Z'))).toBe(true)
})
test('a ban with a future expiry is in force', async () => {
await submit({ PlayerIdReported: '213' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 213)
const expires = new Date(Date.now() + 86_400_000).toISOString()
await banFromReport(env.DB, row!.id, { banExpires: expires })
expect(await isPlayerBanned(env.DB, 213)).toBe(true)
expect((await getActiveBan(env.DB, 213))?.ban_expires).toBe(expires)
})
// Two bans in force: the longest-lasting one is the one reported, so a fresh short
// ban can't shorten a standing permanent one.
test('getActiveBan prefers the permanent ban', async () => {
await submit({ PlayerIdReported: '214', Details: 'timed' }, await bearer())
await submit({ PlayerIdReported: '214', Details: 'permanent' }, await bearer())
const rows = await getReportsAgainst(env.DB, 214)
const timed = rows.find((r) => r.details === 'timed')!
const permanent = rows.find((r) => r.details === 'permanent')!
await banFromReport(env.DB, timed.id, {
banExpires: new Date(Date.now() + 3_600_000).toISOString(),
})
await banFromReport(env.DB, permanent.id)
expect(await getActiveBan(env.DB, 214)).toMatchObject({ details: 'permanent' })
})
test('banFromReport with banned:false lifts the ban and clears the expiry', async () => {
await submit({ PlayerIdReported: '215' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 215)
await banFromReport(env.DB, row!.id, { banExpires: '2999-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 215)).toBe(true)
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 215)).toBe(false)
})
// No such report — the caller can tell that from having banned nobody.
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
@@ -1154,6 +2097,29 @@ describe('images', () => {
})
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
// 120 public ShareCamera photos — more than both the default and the cap.
for (let i = 0; i < 120; i++) {
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
}
const feed = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
expect(res.status).toBe(200)
return ((await res.json()) as { Images: unknown[] }).Images.length
}
expect(await feed('')).toBe(10)
expect(await feed('?take=25')).toBe(25)
expect(await feed('?take=500')).toBe(100)
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
expect(await feed('?take=0')).toBe(10)
expect(await feed('?take=-5')).toBe(10)
expect(await feed('?take=lots')).toBe(10)
})
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
// Its own player id: 700's photos are asserted on exactly in the player-list test.
@@ -1867,6 +2833,1019 @@ 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', () => {
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
@@ -1883,7 +3862,8 @@ describe('openapi', () => {
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
// `.on(['GET','POST'], …)` routes (the relationship mutations, invention update)
// contribute both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
@@ -1920,6 +3900,7 @@ describe('openapi', () => {
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
'GET /api/inventions/v1/room',
'GET /api/inventions/v1/tagfilters',
@@ -1937,14 +3918,22 @@ describe('openapi', () => {
'GET /api/messages/v2/get',
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/referee/files',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
@@ -1959,22 +3948,40 @@ describe('openapi', () => {
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/islandedversions',
'GET /api/versioncheck/v4',
'GET /iam/me/channels/{type}',
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /statsigUserProperties',
'GET /voice/config',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
@@ -1989,6 +3996,8 @@ describe('openapi', () => {
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
'PUT /api/players/v1/playerPhotoTaggingSetting',
'PUT /outfits/me',
])
// Every operation carries a summary — an undescribed one renders as a bare path.
@@ -2020,3 +4029,195 @@ describe('openapi', () => {
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})
// A ban follows the player, not just the account row it was written on: an evader makes
// a new account in seconds, so the block also reaches accounts sharing a PROVEN platform
// identity or an IP with a banned one. See bans-db.ts — and note the IP arm is the coarse
// one, which is why `BAN_EVASION_MATCH` can narrow or disable both linked arms.
describe('ban evasion', () => {
/** Seed an account with the IPs it signed up / last logged in from. */
const account = async (id: number, ips: { signupIp?: string; lastLoginIp?: string } = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Evader${id}`, ...ips }))
.run()
}
/** Link a proven platform identity to an account, as a verified login does. */
const link = async (id: number, platform: number, platformId: string) => {
await env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(id, platform, platformId, new Date().toISOString())
.run()
}
/** File a report against `playerId` and convert it into a ban. */
const ban = async (playerId: number, banExpires: string | null = null) => {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
await banFromReport(env.DB, row.id, { banExpires })
}
test('a banned account is matched directly', async () => {
await account(7001)
await ban(7001)
expect(await resolveBan(env.DB, 7001)).toMatchObject({ via: 'account', bannedAccountId: 7001 })
})
test('an unrelated account is not matched', async () => {
await account(7002, { signupIp: '198.51.100.9' })
await link(7002, 0, 'steam-clean')
expect(await resolveBan(env.DB, 7002)).toBeNull()
})
test('an account sharing a signup IP with a banned account is matched', async () => {
await account(7010, { signupIp: '203.0.113.7' })
await ban(7010)
await account(7011, { signupIp: '203.0.113.7' })
const match = await resolveBan(env.DB, 7011)
expect(match).toMatchObject({ via: 'ip', bannedAccountId: 7010 })
})
// The IPs are compared as SETS: the new account's last-login IP against the banned
// account's signup IP counts, which is the shape evasion actually takes (sign up
// somewhere else, come back to the same connection).
test('a last-login IP matching a banned signup IP is matched', async () => {
await account(7012, { signupIp: '203.0.113.20' })
await ban(7012)
await account(7013, { signupIp: '198.51.100.1', lastLoginIp: '203.0.113.20' })
expect(await resolveBan(env.DB, 7013)).toMatchObject({ via: 'ip', bannedAccountId: 7012 })
})
test('an account sharing a platform identity with a banned account is matched', async () => {
await account(7020)
await link(7020, 0, 'steam-76561')
await ban(7020)
await account(7021)
await link(7021, 0, 'steam-76561')
expect(await resolveBan(env.DB, 7021)).toMatchObject({ via: 'platform', bannedAccountId: 7020 })
})
// The same id on a DIFFERENT platform is a different person — ids are namespaced per
// platform, so the arm matches the pair, not the bare id.
test('the same platform id on another platform is not matched', async () => {
await account(7022)
await link(7022, 0, 'id-collision')
await ban(7022)
await account(7023)
await link(7023, 1, 'id-collision')
expect(await resolveBan(env.DB, 7023)).toBeNull()
})
// Two accounts that merely both lack an IP have nothing in common — "unknown" must
// never match "unknown", or every IP-less account would be banned by the first one.
test('accounts with no IP at all are not matched to each other', async () => {
await account(7030)
await ban(7030)
await account(7031)
expect(await resolveBan(env.DB, 7031)).toBeNull()
// Nor does an empty-string IP, which is what a login outside the CF edge stores.
await account(7032, { signupIp: '', lastLoginIp: '' })
expect(await resolveBan(env.DB, 7032)).toBeNull()
})
test('an expired ban reaches nobody, linked or not', async () => {
await account(7040, { signupIp: '203.0.113.40' })
await link(7040, 0, 'steam-expired')
await ban(7040, '2020-01-01T00:00:00.000Z')
await account(7041, { signupIp: '203.0.113.40' })
await link(7041, 0, 'steam-expired')
expect(await resolveBan(env.DB, 7040)).toBeNull()
expect(await resolveBan(env.DB, 7041)).toBeNull()
})
// The strongest evidence is reported: a player whose own account is banned is told
// that, not that their network was.
test('a direct ban outranks a linked one', async () => {
await account(7050, { signupIp: '203.0.113.50' })
await ban(7050)
await account(7051, { signupIp: '203.0.113.50' })
await ban(7051)
expect(await resolveBan(env.DB, 7051)).toMatchObject({ via: 'account', bannedAccountId: 7051 })
})
test('a platform match outranks an IP one', async () => {
await account(7060, { signupIp: '203.0.113.60' })
await ban(7060)
await account(7061)
await link(7061, 0, 'steam-both')
await ban(7061)
// 7062 shares an IP with 7060 and a platform identity with 7061.
await account(7062, { signupIp: '203.0.113.60' })
await link(7062, 0, 'steam-both')
expect(await resolveBan(env.DB, 7062)).toMatchObject({ via: 'platform', bannedAccountId: 7061 })
})
// A signup has no account yet — the identity the request carries is all there is to
// go on, and refusing it there is what stops the next account being created at all.
test('an identity with no account is matched on its IP and platform id', async () => {
await account(7070, { signupIp: '203.0.113.70' })
await link(7070, 0, 'steam-signup')
await ban(7070)
expect(await resolveBan(env.DB, null, { identity: { ip: '203.0.113.70' } })).toMatchObject({
via: 'ip',
bannedAccountId: 7070,
})
expect(
await resolveBan(env.DB, null, { identity: { platform: 0, platformId: 'steam-signup' } })
).toMatchObject({ via: 'platform', bannedAccountId: 7070 })
// An identity that matches nothing is not blocked.
expect(
await resolveBan(env.DB, null, {
identity: { ip: '198.51.100.200', platform: 0, platformId: 'steam-unknown' },
})
).toBeNull()
// And an identity carrying nothing at all can't be matched to anyone.
expect(await resolveBan(env.DB, null, { identity: {} })).toBeNull()
})
// The arms an operator can turn off — and the one they cannot.
test('BAN_EVASION_MATCH arms narrow the linked matching only', async () => {
await account(7080, { signupIp: '203.0.113.80' })
await link(7080, 0, 'steam-arms')
await ban(7080)
await account(7081, { signupIp: '203.0.113.80' }) // shares the IP only
await account(7082)
await link(7082, 0, 'steam-arms') // shares the identity only
const arms = (value: string | undefined) => ({ arms: banEvasionMatch(value) })
// Default: both arms reach.
expect(await resolveBan(env.DB, 7081, arms(undefined))).toMatchObject({ via: 'ip' })
expect(await resolveBan(env.DB, 7082, arms(undefined))).toMatchObject({ via: 'platform' })
// Platform only: the household bystander is let through, the evader isn't.
expect(await resolveBan(env.DB, 7081, arms('platform'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('platform'))).toMatchObject({ via: 'platform' })
// Off: neither linked arm reaches...
expect(await resolveBan(env.DB, 7081, arms('off'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('off'))).toBeNull()
// ...but the ban itself still applies to the account it was handed to.
expect(await resolveBan(env.DB, 7080, arms('off'))).toMatchObject({ via: 'account' })
})
test('banEvasionMatch reads the knob', () => {
expect(banEvasionMatch(undefined)).toEqual({ ip: true, platform: true })
expect(banEvasionMatch('ip,platform')).toEqual({ ip: true, platform: true })
expect(banEvasionMatch(' PLATFORM ')).toEqual({ ip: false, platform: true })
expect(banEvasionMatch('ip')).toEqual({ ip: true, platform: false })
expect(banEvasionMatch('off')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('none')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('')).toEqual({ ip: false, platform: false })
// `off` wins over anything else in the list, and a typo is ignored rather than
// fatal — this is read on the matchmake path.
expect(banEvasionMatch('off,ip')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ipv6')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ip,typo')).toEqual({ ip: true, platform: false })
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Moderator-issued player warnings on the shared `recflare` D1 database.
*
* The counterpart to the `report` table (see reports-db.ts): a report is what a
* player submits, a warning is what a moderator hands down. Same shape of storage —
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
* rows yet.
*
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
]
/** A stored warning row (snake_case columns, one row per warning issued). */
export interface WarningRow {
id: number
/** The moderator who issued it, from their bearer token. */
moderator_player_id: number
warned_player_id: number
report_category: number
/** What the warned player is shown, e.g. `Sexual gestures`. */
display_reason: string | null
/** Internal note — never surfaced to the warned player. */
moderator_note: string | null
created_at: string
}
/**
* A warning as issued — everything but the moderator (which comes from the bearer
* token) and the timestamp. Only the warned player is required; the rest are
* optional and stored as NULL when absent.
*/
export interface NewWarning {
moderatorPlayerId: number
warnedPlayerId: number
reportCategory?: number
displayReason?: string | null
moderatorNote?: string | null
}
/** Record an issued warning, returning the stored row (with its assigned id). */
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
const row = await db
.prepare(
`INSERT INTO warning (
moderator_player_id, warned_player_id, report_category,
display_reason, moderator_note, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.moderatorPlayerId,
input.warnedPlayerId,
input.reportCategory ?? 0,
input.displayReason ?? null,
input.moderatorNote ?? null,
new Date().toISOString()
)
.first<WarningRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every warning issued against a player, newest first. Backs a future moderation view. */
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
const { results } = await db
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<WarningRow>()
return results
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://github.com/djdevin/recflare"
"MoreInfoUrl": "https://recflare.net"
},
"InstagramImages": [
{
File diff suppressed because one or more lines are too long
+16 -1
View File
@@ -19,11 +19,26 @@
}
],
// Image bucket shared with the `img` worker (which serves objects back by key).
// Saved-image uploads are written here.
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
// invention's uploaded data blob for its `BlobHash`.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Per-player settings bag, shared with the `playersettings` worker (which owns it
// and serves the same map at `/playersettings`). Same binding name and same "local"
// id placeholder as that worker's config, so both point at one namespace.
"kv_namespaces": [
{
"binding": "RECFLARE_PLAYER_SETTINGS",
"id": "local"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
+72 -20
View File
@@ -42,35 +42,79 @@ route without documenting it fails rather than silently shipping an incomplete s
without matchmaking. A posted `password` becomes the login credential.
- **`cached_login`** — logs into an already-linked account using platform ownership as
the credential; no password. The posted `account_id` must be linked to exactly the
identity the Steam ticket proves.
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
all, which is what closes id/username-only takeover.
all, which is what closes id/username-only takeover. When it also carries a verifying
`platform_auth`, that identity is **linked** to the account (see below).
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
### Steam is the only verifiable platform
### Verifiable platforms: Steam and Meta
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
ticket and checks Steam's signature against Steam's system public key. No publisher
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_
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
ever written to an account's `platformId`.
Only an identity we can _prove_ is ever bound to an account, so any grant that
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
asserts a platform) must be a platform we can verify. Two are:
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
Steam's signature against Steam's system public key. Verified **offline**: no
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
client-supplied `platform_id`.
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
to a user id, so here the posted `platform_id` is an _input_ to the check and a
spoofed one fails. This means an outbound request on every Meta login, and no Meta
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client.
Everything else is refused. Whichever platform, the identity that gets bound or linked
is the verified one, never the raw `platform_id` field.
### One account, many platform identities
An account can be reached from several platform identities — a player's PC and their
headset both open the same account, with no password after the first time. The links
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
(platform, platform id, account).
That table is the **one source of truth** for both halves of a cached login: the picker
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
`cached_login` grant asks it whether the account it was handed is linked to the identity
just proven. They used to be two separate checks over the account blob's single
`platformId`, which could disagree — the client would be offered an account that then
answered "no linked account" forever.
A second device is linked by **logging in with a password there**: the client posts its
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
verified identity is ever linked, since a link is a password-free way into the account.
A proof that doesn't verify never fails the login — it just leaves that device without a
cached login.
The account blob keeps `platform`/`platformId` as the account's **primary** identity
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
of one identity per account invites exactly the picker/grant disagreement above. Look
identities up in `platform_account`.
## Signup caps
`create_account` is capped on two independent arms, per verified platform id and per
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
is coarse and will produce false positives behind NAT, shared campus and mobile
`create_account` is capped on two independent arms, per verified platform identity and
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
arm is coarse and will produce false positives behind NAT, shared campus and mobile
networks. Both default to 3.
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
limit could otherwise have accounts created for it with a password and link its way into
all of them. Hitting it never fails a password login — the account just doesn't get a
cached login on that device.
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
@@ -78,11 +122,12 @@ small private server, or when a shared network is being locked out.
## Bindings
| Binding | Type | Notes |
| -------------------- | ------------- | ------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
| Binding | Type | Notes |
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
table, so they stay independent of the `rooms` worker's migrations on the same
@@ -109,12 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
# sign-ins then answer 500 until it holds the real value.
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
```
Rotating the store value invalidates all existing tokens (clients re-authenticate).
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
The Meta secret is read per request, so updating it takes effect without a redeploy.
@@ -0,0 +1,35 @@
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
-- PC and a headset gets a cached login on both. The account blob's single
-- `platformId`/`platform` pair could only hold one, so logging in on the second
-- device meant a password every time.
--
-- Links move into their own table, which becomes the one source of truth for both
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
-- stay as the account's *primary* identity — the first one linked — for the account
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
-- because nothing ever defaulted that field: an account can carry a platformId with no
-- platform recorded, and back when Steam was the only verifiable platform an unset one
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
-- tests run.
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> '';
@@ -0,0 +1,23 @@
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
-- one source of truth for the login picker and the `cached_login` grant. The column's
-- last reader was 0007's own backfill, which has already run.
--
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
-- open?" — it only ever holds the account's primary identity, so an account reachable
-- from a PC and a headset appears here under one of them. That is exactly the split
-- that used to have the picker offer an account the grant then refused.
--
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
-- generated column and its index only — a virtual column stores nothing, so no account
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
--
-- Safe to run before or after the deploy that ships it: no worker queries this column,
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
-- run again; the exported copy selects the blob instead so tests keep working.)
DROP INDEX IF EXISTS idx_accounts_platform_id;
ALTER TABLE account DROP COLUMN platform_id;
+544 -130
View File
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
@@ -19,11 +18,16 @@ import {
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
@@ -38,21 +42,61 @@ import {
TokenRequest,
TokenResponse,
} from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** 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 = {
platform: PlatformType.Oculus,
platformId: '1',
platformId: SIDELOAD_PLATFORM_ID,
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
@@ -151,63 +195,201 @@ async function authedId(c: Context<App>): Promise<number | null> {
}
/**
* The elevated role names for an account's token `role` claim, derived from its
* role flags. Base roles (gameClient) are added by generateToken — these are only
* the operator-granted extras. Order is stable so tokens are deterministic.
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
* tokens are deterministic.
*/
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
if (!account) return []
const roles: string[] = []
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
): string[] {
const roles = ['screenshare']
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
return roles
}
/**
* The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM,
* read out of the same claims dictionary as `role` — it never belongs in `scope`. The
* client knows exactly two values, both chat restrictions, and both ride on a junior
* account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which
* drops the claim rather than sending a blank one.
*/
function accountPrivileges(account: Pick<Account, 'isJunior'> | null): string[] {
return account?.isJunior ? ['BanVChat', 'BanRmChat'] : []
}
/**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no
* platform recorded — and Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
* platform recorded — and until Meta verification landed Steam was the only identity
* we could prove, so an unset one *is* Steam. Every account bound since records its
* platform explicitly; this default only covers those older rows.
*/
function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* Whether an account is the one linked to a given platform identity — the single
* check behind both the cached-login picker and the `cached_login` grant. It lives in
* one place on purpose: if the picker offers an account the grant then rejects, the
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity (the SteamID64 from a verified
* platform_auth ticket), never the client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
platform: number,
platformId: string
): boolean {
if (!account.platformId || platformId === '') return false
return account.platformId === platformId && accountPlatform(account) === platform
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the platform_auth ticket) is the credential for a cached login — no prompt.
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
*
* The platform and id come from the LINK, not from the account: an account linked to
* both a Steam and a Meta identity appears in both pickers, and each has to report the
* identity that picker was asked about — that's what the client posts back, and what
* the grant then checks the link against.
*/
function toCachedLogin(account: Account) {
function toCachedLogin(account: Account, link: PlatformLink) {
return {
platform: accountPlatform(account),
platformId: account.platformId ?? '',
platform: link.platform,
platformId: link.platformId,
accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false,
}
}
/**
* Project a set of links into picker entries, dropping any whose account no longer
* exists. One batched account read rather than one per link.
*
* Order follows the links (oldest first), so the picker is stable between launches.
*/
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
if (links.length === 0) return []
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
const byId = new Map(accounts.map((a) => [a.accountId, a]))
return links.flatMap((link) => {
const account = byId.get(link.accountId)
return account ? [toCachedLogin(account, link)] : []
})
}
/**
* Link the platform identity a password login proved to the account it logged into,
* so the next launch on that device is a cached login. Called only with a VERIFIED
* identity — a link is a password-free way into the account.
*
* Already linked is the common case (every subsequent login on that device) and costs
* one read and nothing else.
*
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
* an identity could otherwise sit at the limit, have accounts created for it with a
* password, and link its way into all of them. Reaching it does NOT fail the login —
* the password was valid — it just leaves the account without a cached login, so the
* player types their password each time rather than being locked out.
*
* The first identity linked also becomes the account's primary (the blob's
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
* claims report. Later platforms link without disturbing it.
*/
async function linkLoginIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
maxAccountsPerIdentity: number
): Promise<void> {
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
if (
maxAccountsPerIdentity > 0 &&
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
) {
logger.info('platform link refused: account limit reached for this platform identity', {
accountId,
platform,
platformId,
})
return
}
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
logger.info('linked platform identity to account', { accountId, platform, platformId })
const account = await getAccount(db, accountId)
if (account && !account.platformId) {
await updateAccount(db, accountId, { platform, platformId })
}
}
/**
* What a login's `platform_auth` proved, if anything. Failures are split because the
* callers act on them differently: a grant that authenticates BY platform identity has
* to refuse, while a password grant — which has already proven who it is — carries on
* and just doesn't link.
*
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
* and is the one case that warrants a 5xx.
*/
type PlatformProof =
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
| { status: 'none' }
| { status: 'verified'; platform: number; platformId: string }
| { status: 'unsupported' }
| { status: 'unconfigured' }
| { status: 'rejected'; reason: string }
/**
* Verify a login's `platform_auth` and return the identity it proves.
*
* The two verifiable platforms prove the id in opposite directions, which is why they
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
* so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
* validated *against* the posted `platform_id`, so that field is an input, and a
* spoofed one fails validation rather than being ignored. Either way the id that comes
* back is proven, never the raw client-supplied field, and only a proven id is ever
* written to an account or linked to one.
*/
async function verifyPlatformProof(
env: App['Bindings'],
platform: number,
platformAuth: string,
postedPlatformId: string
): Promise<PlatformProof> {
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
// password grant, which is the path such a client actually takes. Linking it would
// hand every sideloaded headset a password-free login into that account, since they
// all report this same id.
//
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
// password grant carries its own credential and only *links* on a verified proof), it
// just never gets a cached login, so they type their password each launch. That is
// the intended shape of the sideload flow.
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
}
if (platform === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
}
if (platform === PlatformType.Oculus) {
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
// holding an empty/placeholder value) — the same misconfiguration from the player's
// side, so it takes the same branch.
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') return { status: 'unconfigured' }
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
return {
status: 'verified',
platform: PlatformType.Oculus,
platformId: verified.identity.userId,
}
}
return { status: 'unsupported' }
}
const app = new Hono<App>()
.use(
'*',
@@ -219,6 +401,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -244,39 +434,53 @@ const app = new Hono<App>()
// id, so the client can offer them on the login screen (and post one back as a
// cached_login grant). No linked account → [], and the client falls back to a
// fresh login / create_account.
.get(
//
// GET or POST: the 2023 build asks with a GET, the 2025 build (20250424.01) POSTs
// the same path with a form body — `deviceId`, `platformAuth` (a JSON blob holding
// the platform's session ticket and app id) and `time`. The body is READ BY NOTHING
// here; both methods answer the same list off the path params, so a newer client
// gets its picker. Verifying that ticket is the eventual point of the POST.
.on(
['GET', 'POST'],
'/cachedlogin/forplatformid/:platform/:id',
describeRoute({
tags: ['Cached login'],
summary: 'Accounts linked to a platform id',
description: [
'Accounts the client may offer on its login screen for this platform identity.',
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
'is always redeemable. An unknown id yields `[]` (not a 404) 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',
'canned, non-redeemable entry with `requirePassword: true`.',
'Accounts the client may offer on its login screen for this platform identity —',
'the links this identity has, so an entry here is always redeemable by a',
'`cached_login` grant (both read the same table). An account linked to several',
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
'and the client falls back to a fresh login or create_account.',
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
'with `requirePassword: true`, sending the build to username/password login.',
'Older clients GET this; the 20250424.01 build POSTs it with a',
'`deviceId` / `platformAuth` / `time` form body attesting the platform session.',
'That body is accepted and ignored — both methods answer identically from the',
'path params.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value disables the link filter.',
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
schema: { type: 'string' },
},
{
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam.',
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
schema: { type: 'string' },
},
],
responses: {
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
),
},
}),
@@ -284,21 +488,27 @@ const app = new Hono<App>()
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
// the real path would always yield []. Hand back one canned entry instead, so the
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
// genuine cached login there is no platform ticket behind this, so the client must
// prompt. Delete this branch once Oculus platform auth lands.
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
accounts
.filter(
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
)
.map(toCachedLogin)
)
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
// produce a real Meta identity or a nonce to prove one with — it asks about the
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
// login screen with nothing to do. Hand back one canned entry to push it onto the
// username/password login instead, which is the only flow such a build can finish.
// `requirePassword` is true for exactly that reason: there's no platform proof here,
// and the `cached_login` grant would (correctly) refuse this entry.
//
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
// real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers.
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN])
}
// Listed straight from the link table, which is also what the `cached_login`
// grant authorizes against — so the picker can't offer an account the grant
// then refuses.
const links = Number.isNaN(platformInt)
? await getLinksForPlatformId(c.env.DB, id)
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
return c.json(await toCachedLogins(c.env.DB, links))
}
)
@@ -312,8 +522,8 @@ const app = new Hono<App>()
description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the',
'response cannot be mapped back to a specific input id — the client uses each',
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
@@ -324,7 +534,8 @@ const app = new Hono<App>()
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
// No platform accompanies these ids, so they match on any platform.
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
}
return c.json(out)
}
@@ -345,12 +556,13 @@ const app = new Hono<App>()
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be Steam and `platform_auth` must verify.',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
'`account_id` must be linked to exactly the identity that ticket proves. An account',
'with no stored platform identity cannot be cached-logged-into.',
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
'link for that identity cannot be cached-logged-into.',
'',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
@@ -358,16 +570,48 @@ const app = new Hono<App>()
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
'which is what closes id/username-only takeover.',
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
'that verifies, that identity is LINKED to the account — this is how a player who',
'signed up on one platform gets a cached login on a second device. The login is',
'never failed over the link: an unverifiable proof (or one over the per-identity',
'cap) just leaves the account without a cached login there.',
'',
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
'`platform_id` and is the only value ever written to an account. Password and refresh',
'grants carry their own credential and are not gated this way.',
'**Platform identity.** An account can be reached from several platform identities;',
'the links are the one thing both the picker and `cached_login` consult, and only a',
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
'when it is unset. The first identity linked also becomes the account’s primary',
'(what the account DTO and a refreshed token report); later ones only link.',
'',
'The one platform id that is never verified and never linked is `1` on platform `1`',
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
'build reports it, so it identifies nobody. A password login that carries it still',
'succeeds; it simply links nothing, and the player types their password each launch.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. A junior also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null.',
'',
'The refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -378,13 +622,18 @@ const app = new Hono<App>()
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or a banned account',
].join(' ')
),
500: json(
OAuthError,
'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(' ')
),
},
}),
@@ -413,49 +662,110 @@ const app = new Hono<App>()
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
// running rather than this server's GAME_VERSION — `match` reads it back off the
// token when it writes presence. Unverified like the device fields, and only ever
// echoed, never trusted for a decision (the version CHECK is `api`'s
// `/api/versioncheck/v4`, against its own list).
//
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
// undefined and generateToken falls back to GAME_VERSION. An empty string is
// treated as absent for the same reason: presence must never carry an empty
// version, which breaks the client's handling of it.
const version = typeof body.ver === 'string' && body.ver !== '' ? body.ver : undefined
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
// verifyPlatformProof). Only a verified identity is ever bound or linked.
//
// Two grants are GATED on it — they have no other credential, so an unverifiable
// platform is fatal:
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
// (see below), which is how a player who created an account on Steam gets a cached
// login on their headset. If it doesn't verify, the login still succeeds — it just
// links nothing, because a link is a password-free way into the account and must
// never rest on an unproven id.
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== PlatformType.Steam) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
},
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
const gatedOnPlatform =
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
// The password grant only spends a verification when the client actually offered
// one; the rest of the time there is nothing to link.
const proof: PlatformProof =
gatedOnPlatform || (platformAsserted && platformAuth !== '')
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
: { status: 'none' }
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(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam and Meta can be verified',
},
400
)
}
if (proof.status === 'unconfigured') {
// An operator misconfiguration, not the client's fault: without the app secret
// every Meta player is locked out, so it answers 500 the way an unset
// JWT_SECRET does below rather than blaming the credential. (We never fall
// back to trusting the posted id — that would let anyone log into any
// Meta-linked account by naming its user id.)
logger.error('refusing a Meta login: META_APP_SECRET is empty')
return c.json(
{
error: 'server_error',
error_description: 'Meta platform verification is not configured',
},
500
)
}
// The reason is for the operator; the client is told only that it was rejected.
// A wrong app secret and a stale nonce look identical from the client side.
return c.json(
{ error: 'invalid_grant', error_description: '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:
// - 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.
@@ -470,6 +780,35 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A banned player's next move is a new account, so the ban is checked BEFORE
// one is minted — against the only identity a signup has, the IP it came from
// and the platform identity it just proved. Refusing after the fact (as the
// shared check below would) still refuses the token, but leaves the account
// row behind and burns a slot off both signup caps, so the evader gets to keep
// making them.
//
// Nothing here can match the account arm (there is no account yet), so this is
// purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open —
// which is the honest default position: a server that won't accept the IP arm's
// false positives is choosing to let evaders re-register.
const blocked = await resolveBan(c.env.DB, null, {
identity: {
ip: clientIp,
platform: verifiedPlatform,
platformId: verifiedPlatformId,
},
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (blocked) {
logger.info('signup refused: player banned', {
via: blocked.via,
bannedAccountId: blocked.bannedAccountId,
ip: clientIp,
platformId: verifiedPlatformId,
})
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
@@ -482,10 +821,16 @@ const app = new Hono<App>()
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
verifiedPlatformId !== null &&
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
return c.json(
{
error: 'invalid_grant',
@@ -509,14 +854,15 @@ const app = new Hono<App>()
)
}
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
// binds nothing. The account blob keeps this first identity as its PRIMARY one
// (for the account DTO and the refresh grant's claims); the link written just
// below is what a later cached login is actually authorized against.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
@@ -524,6 +870,14 @@ const app = new Hono<App>()
lastLoginIp: clientIp || undefined,
})
accountId = String(account.accountId)
if (verifiedPlatformId !== null) {
await linkPlatformIdentity(
c.env.DB,
account.accountId,
verifiedPlatform ?? 0,
verifiedPlatformId
)
}
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
@@ -547,18 +901,25 @@ const app = new Hono<App>()
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
// table says that account is linked to exactly this platform identity — this is
// the check that keeps anyone but that platform user out of the account
// (platform ownership is the credential; no password needed). An account with no
// link for the presented identity must use a password.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
// The picker lists straight from the same table, so it can only offer accounts
// this check accepts.
//
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
// the ticket, or the Meta user id the nonce validated against — never the raw
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
return c.json(
{
error: 'invalid_grant',
@@ -600,10 +961,61 @@ const app = new Hono<App>()
)
}
accountId = String(resolvedId)
// The password proved the account; the platform proof (when the client sent one
// and it verified) proves the device's platform identity. Linking the two is
// what gives a player who signed up on Steam a cached login on their headset —
// they type their password once there, and never again.
if (verifiedPlatformId !== null) {
await linkLoginIdentity(
c.env.DB,
resolvedId,
verifiedPlatform ?? 0,
verifiedPlatformId,
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
)
}
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
//
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
//
// The request's own IP and proven identity are passed alongside the account, so a
// ban also reaches an old, clean account logged into from the banned player's
// device or network — the stored ips alone would only catch that on the SECOND
// login. create_account was already refused before it minted anything (above);
// this still runs for it, so a signup that raced one is refused too.
const ban = await resolveBan(c.env.DB, Number(accountId), {
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
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
// 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
@@ -633,7 +1045,9 @@ const app = new Hono<App>()
platformId,
platform,
jwtSecret,
accountRoles(roleAccount)
accountRoles(roleAccount),
accountPrivileges(roleAccount),
version
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
+19
View File
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
// only to authenticate US to Meta's graph API when validating a login nonce (see
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
// offline, Meta logins are impossible without it, so an empty value fails those
// logins with a 500 rather than silently trusting the client's platform_id.
META_APP_SECRET: SecretsStoreSecret
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
@@ -19,6 +26,18 @@ export type Env = SharedHonoEnv & {
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
* that share a proven platform identity or an IP with the banned one, and refuses a
* signup from either, which is what stops an evader simply making a new account.
*
* The `ip` arm is coarse (households, NAT, campus and carrier networks share one
* address), so `platform` alone is the setting for a server whose players share
* networks. Whatever this says, a ban always applies to the account it was handed to.
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
}
/** Variables can be extended */
+172
View File
@@ -0,0 +1,172 @@
/**
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
*
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
* nonce that means nothing on its own. The only way to know it is genuine is to ask
* Meta — which is why this path makes an outbound request on every Meta login and
* cannot work at all without the app secret.
*
* A Meta login posts
*
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
* platform_id = <the Meta user id>
*
* and validation is what BINDS those two together: `user_nonce_validate` answers
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
* an *input* here rather than something read out of a ticket, and a spoofed one fails
* — a nonce Meta issued to user A does not validate as user B. The id is therefore
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
* account. (It's an app-scoped id: it identifies the player within this app only.)
*
* The `AppId` comes from the payload rather than config because it must be the app the
* nonce was issued for — a different one simply fails, since the access token below
* pairs it with our secret. `Source` is informational and ignored.
*
* Shape and retry policy follow the reference Go server's utils/oculus.go.
*/
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
/**
* Graph error codes worth retrying — 1 (unknown) and 2 (service temporarily
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
* real answer and retrying it just delays a login that is going to fail anyway.
*/
const TRANSIENT_ERROR_CODES = new Set([1, 2])
/**
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
* the client on a headset loading screen.
*/
const MAX_ATTEMPTS = 3
/** The trustworthy identity proven by a validated nonce. */
export interface VerifiedMetaIdentity {
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
userId: string
/** The Meta app the nonce was issued for. */
appId: string
}
/**
* The outcome of a verification. Failures carry a `reason` for the server log: the
* client is told only that its platform_auth was rejected (it can't act on more), but
* an operator debugging a headset that won't log in needs to know whether Meta said
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
*/
export type MetaVerification =
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
export interface MetaPlatformAuth {
nonce: string
appId: string
}
/**
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
* numeric — it is interpolated into the access token below, and this is what keeps a
* client-supplied string out of that credential.
*/
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
let parsed: { Nonce?: unknown; AppId?: unknown }
try {
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
} catch {
return null
}
const { Nonce: nonce, AppId: appId } = parsed
if (typeof nonce !== 'string' || nonce === '') return null
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
return { nonce, appId }
}
/** The graph response we care about; everything else in the body is ignored. */
interface NonceValidateResponse {
is_valid?: boolean
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
}
/** One validation round-trip. `retryable` says whether another attempt could differ. */
async function validateOnce(
form: URLSearchParams,
fetcher: typeof fetch
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
let res: Response
try {
res = await fetcher(NONCE_VALIDATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
})
} catch (err) {
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
}
let body: NonceValidateResponse
try {
body = (await res.json()) as NonceValidateResponse
} catch {
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
// verdict — treat it the way a dropped connection is treated.
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
}
if (body.error) {
const { code, message, is_transient } = body.error
return {
ok: false,
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
}
}
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
return { ok: true, retryable: false, reason: '' }
}
/**
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
* was issued to that user for that app.
*
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
* Meta login can be verified, so callers must treat an unset secret as a server
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
* run the retry and response handling without reaching the network.
*/
export async function verifyMetaNonce(
platformAuth: string,
userId: string,
appSecret: string,
fetcher?: typeof fetch
): Promise<MetaVerification> {
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
// The user id is what the nonce is checked against, so an absent or non-numeric one
// can't be verified — reject before spending a round-trip on it.
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
const auth = parseMetaPlatformAuth(platformAuth)
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
// *app*, which is why the secret never leaves the server.
const form = new URLSearchParams({
nonce: auth.nonce,
user_id: userId,
access_token: `OC|${auth.appId}|${appSecret}`,
})
// Resolved per call, not at module load, so a test's stubbed global is honoured.
const doFetch = fetcher ?? globalThis.fetch
let last = { ok: false, retryable: false, reason: 'not attempted' }
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
last = await validateOnce(form, doFetch)
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
if (!last.retryable || attempt === MAX_ATTEMPTS) break
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
}
return { ok: false, reason: last.reason }
}
+37 -9
View File
@@ -69,21 +69,33 @@ export const PlatformType = {
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/**
* A PlatformType by value. Only Steam can actually be verified — see the
* platform-auth notes on `POST /connect/token`.
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
* see the platform-auth notes on `POST /connect/token`.
*/
export const PlatformTypeSchema = z
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
.union([
z.literal(-1),
z
.int()
.min(0)
.max(Math.max(...Object.values(PlatformType))),
])
.describe(
Object.entries(PlatformType)
.map(([name, value]) => `${value} ${name}`)
.join(', ')
)
/** One entry on the client's login screen, from `toCachedLogin`. */
/**
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
* identity LINK, not an account. An account linked to two platforms yields one entry in
* each of their pickers, each reporting the identity that picker was asked about.
*/
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
platformId: z
.string()
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z
@@ -92,8 +104,9 @@ export const CachedLogin = z.object({
})
/**
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
* is true — nothing proves platform ownership, so the client has to prompt.
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
* but `requirePassword` is true — with no Meta SDK there is nothing to prove platform
* ownership with, so the client falls through to username/password.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
@@ -139,17 +152,32 @@ export const TokenRequest = z.object({
platform_id: z
.string()
.optional()
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
.describe(
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
'the id the nonce is validated against, so it must be the real (numeric) user id'
),
platform_auth: z
.string()
.optional()
.describe('Steam session ticket. Required for cached_login and platform create_account'),
.describe(
'Platform proof, required for cached_login and platform create_account, and used to ' +
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
.optional()
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
device_class: z.string().optional().describe('Integer string; defaults to 0'),
ver: z
.string()
.optional()
.describe(
'The client’s build, e.g. `20250718.01`. Stamped into the token’s `rn.ver` claim and ' +
'read back by `match` when it writes presence, so a player reports the build they ' +
'are running. Absent (or empty) falls back to the server’s GAME_VERSION'
),
})
/** `POST /account/me/changepassword` form body. */
+198
View File
@@ -0,0 +1,198 @@
/**
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0007). One row per (platform, platform id, account): the Steam
* user 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
}
+800 -44
View File
@@ -5,15 +5,28 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import {
GAME_VERSION,
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import { isLinkedToPlatformIdentity } from '../../auth.app'
import {
banFromReport,
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -31,14 +44,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -49,12 +76,9 @@ beforeAll(async () => {
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`
).run()
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await seedRoomWithSubRooms(env.DB, {
@@ -63,8 +87,27 @@ beforeAll(async () => {
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
* Ban an account the way a moderator would: file a report against it and convert that
* report into a ban. `banExpires` null is a permanent ban.
*/
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
await banFromReport(env.DB, row.id, { banExpires })
}
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
async function seedAccount(accountId: number, username: string): Promise<void> {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
.run()
}
/** Decode a JWT payload (no verification) for asserting claims. */
function decodePayload(token: string): Record<string, unknown> {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@@ -101,6 +144,51 @@ async function postToken(
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -122,16 +210,25 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
@@ -144,10 +241,11 @@ describe('auth worker routes', () => {
])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -155,11 +253,11 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -167,7 +265,7 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
@@ -191,6 +289,109 @@ describe('auth worker routes', () => {
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const linked = await cachedLogins(1, META_USER_ID)
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account is actually linked to.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
@@ -206,6 +407,7 @@ describe('auth worker routes', () => {
})
)
.run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
@@ -219,32 +421,122 @@ describe('auth worker routes', () => {
])
})
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
// Steam-linked account can carry a platformId with no platform. The picker offered
// such an account (it treats a missing platform as Steam) while the cached_login
// grant rejected it — "no linked account for this platform identity" forever.
// Both now run the same check.
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
test('POST /cachedlogin/forplatformid answers exactly what the GET answers', async () => {
const steamId = '76561197962463211'
const account = { platformId: steamId } // no `platform` field
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 31381,
username: 'SteamPlayer2025',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-13T04:21:34.768Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 31381, 0, steamId)
// The grant now accepts it — this is what was returning invalid_grant.
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
// The body as the live client sends it: device id, the platform session ticket, a
// timestamp. All ignored for now.
const body = new URLSearchParams({
deviceId: '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88',
platformAuth: JSON.stringify({ Ticket: '140000009C5F501B447424FF', AppId: '471710' }),
time: '2026-08-13T04:21:34.7684754Z',
}).toString()
const posted = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
expect(posted.status).toBe(200)
const expected = [
{
platform: 0,
platformId: steamId,
accountId: 31381,
lastLoginTime: '2026-08-13T04:21:34.768Z',
requirePassword: false,
},
]
expect(await posted.json()).toEqual(expected)
expect(await cachedLogins(0, steamId)).toEqual(expected)
})
// 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)
// A POST with no body at all still resolves — the client's body is never consulted.
test('POST /cachedlogin/forplatformid/1/1 still returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`, {
method: 'POST',
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject([{ accountId: 1, requirePassword: true }])
})
// And the picker offers exactly the accounts the grant accepts.
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// The point of the link table. The same account is reachable from the PC and from
// the headset, and each picker reports the identity IT was asked about — that's
// what the client posts back on the cached_login grant.
const steamId = '76561197962463777'
const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
const steamId = '76561197962463211'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
expect(offered.map((a) => a.accountId)).toContain(8)
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
// No link row yet: not offered.
const before = await cachedLogins(0, steamId)
expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -260,7 +552,7 @@ describe('auth worker routes', () => {
expires_in: number
}
expect(json.token_type).toBe('Bearer')
expect(json.expires_in).toBe(3600)
expect(json.expires_in).toBe(TOKEN_TTL_SECONDS)
// header.payload.signature
const parts = json.access_token.split('.')
expect(parts).toHaveLength(3)
@@ -277,12 +569,36 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient')
// A plain account carries only the base role — no elevated roles.
// screenshare is a feature gate, not a grant — every token carries it.
expect(payload.role).toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
// `rn.ver` is the CLIENT's build, from the `ver` it posts here — presence in `match`
// reads it back off the token, so this is what a player is reported as running.
test('POST /connect/token stamps the posted ver into rn.ver', async () => {
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}&ver=20250718.01`)
expect(payload['rn.ver']).toBe('20250718.01')
})
// A grant that names no build — a refresh, or a caller that isn't the game — falls
// back to the server's GAME_VERSION rather than stamping an empty claim, which would
// leave presence carrying an empty version.
test('POST /connect/token falls back to GAME_VERSION with no ver', async () => {
expect((await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`))['rn.ver']).toBe(
GAME_VERSION
)
expect((await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}&ver=`))['rn.ver']).toBe(
GAME_VERSION
)
})
test('POST /connect/token stamps developer/moderator roles into the token', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
@@ -299,6 +615,25 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 92,
username: 'JuniorPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isJunior: true,
})
)
.run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
@@ -581,6 +916,157 @@ describe('auth worker routes', () => {
expect(payload.platform_id).toBe('steam-123')
})
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
// The sideload placeholder identifies nobody — every sideloaded headset reports
// `1`, so a link on it would be a password-free way into this account from any of
// them. The password login still stands; Meta is never even asked, since there is
// nothing there to validate.
await seedPasswordAccount(7105, 'sideloader')
const login = await metaLogin(
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=1` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true // even with Meta answering yes to everything
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
// And so the picker never offers this account off the placeholder — only the
// canned stub entry is there.
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
@@ -709,6 +1195,7 @@ describe('auth worker routes', () => {
'GET /role/developer/{id}',
'GET /role/moderator/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
'POST /connect/token',
])
@@ -720,3 +1207,272 @@ 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
}
})
})
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest'
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
// The payload shape a real Meta login posts, captured from a live client. `Source`
// is informational and ignored; the AppId is Rec Room's Meta app.
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
const APP_ID = '1232175103309633'
const USER_ID = '27061366730207360'
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
const APP_SECRET = 'test-app-secret'
/**
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
*/
function stubFetch(bodies: unknown, status = 200) {
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
const calls: Array<{ url: string; form: URLSearchParams }> = []
const fetcher = (async (url: string, init?: { body?: string }) => {
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
const body = queue.length > 1 ? queue.shift() : queue[0]
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
return { fetcher, calls }
}
describe('meta-nonce', () => {
test('parses the platform_auth payload the client posts', () => {
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
})
test.each([
['not json', 'nonsense'],
['no nonce', JSON.stringify({ AppId: APP_ID })],
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
['no app id', JSON.stringify({ Nonce: NONCE })],
// The app id is interpolated into the graph access token, so a non-numeric one
// is refused rather than sent.
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
])('rejects a malformed payload (%s)', (_label, payload) => {
expect(parseMetaPlatformAuth(payload)).toBeNull()
})
test('validates the nonce against the posted user id and returns the identity', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
// The request Meta actually sees: the nonce is bound to THIS user id, and the
// app authenticates itself with `OC|<app id>|<secret>`.
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
expect(calls[0].form.get('nonce')).toBe(NONCE)
expect(calls[0].form.get('user_id')).toBe(USER_ID)
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
})
test('rejects a nonce Meta does not vouch for', async () => {
const { fetcher } = stubFetch({ is_valid: false })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
ok: false,
reason: 'nonce rejected',
})
})
// The whole point of validating against the posted id: a nonce genuinely issued to
// one user does not authenticate another. Meta answers is_valid:false for the
// mismatch, so nobody can log in by naming someone else's Meta user id.
test('a nonce presented for the wrong user id fails', async () => {
const { fetcher, calls } = stubFetch({ is_valid: false })
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
})
test.each([
['missing', ''],
['non-numeric', 'not-an-id'],
])('refuses a %s user id without calling Meta', async (_label, userId) => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(0)
})
test('refuses to attempt verification with no app secret', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
ok: false,
reason: 'no app secret configured',
})
expect(calls).toHaveLength(0)
})
test('surfaces a graph error with its code, for the server log', async () => {
const { fetcher } = stubFetch({
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
})
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({
ok: false,
reason: 'graph error 100: Invalid OAuth access token',
})
})
test('a non-retryable graph error is not retried', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(calls).toHaveLength(1)
})
test('retries a transient graph error and succeeds on a later attempt', async () => {
const { fetcher, calls } = stubFetch([
{ error: { code: 2, message: 'service temporarily unavailable' } },
{ is_valid: true },
])
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(true)
expect(calls).toHaveLength(2)
})
test('gives up after three attempts when Meta stays unavailable', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(3)
})
test('treats a network failure as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
throw new Error('connection reset')
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(attempts).toBe(3)
})
test('treats a non-JSON body (an edge error page) as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
return new Response('<html>502</html>', { status: 502 })
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
expect(attempts).toBe(3)
})
})
+12
View File
@@ -22,11 +22,23 @@
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
//
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
// logins are verified by asking Meta to validate the login nonce, which requires
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
// store or the deploy fails — an operator with no Meta app still has to create
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
}
],
"upload_source_maps": true,
+11 -1
View File
@@ -2,7 +2,17 @@
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
bucket, plus the one bundled config file the loading screen reads.
bucket, plus the JSON config files the client reads from `/config/`.
`/config/:name` serves `static/config/<name>.json` verbatim — `RRPlusConfig_v3` and
`SkuConfig_v1` today — with or without the `.json` in the path, since the game configs that
point at these files carry the extension and the client's older config calls don't. `{name}`
IS the filename: that directory is uploaded as Workers static assets and read through the
ASSETS binding, so publishing a config is dropping in a file, and `run_worker_first` keeps
the asset server from answering ahead of the Worker (nothing is reachable at its own asset
path). The files go out byte-for-byte, BOM included. `/config/LoadingScreenTipData` stays a
route of its own, bundled rather than an asset, because its file is named differently from
its path.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
+158 -41
View File
@@ -1,14 +1,21 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import {
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
writeContentRange,
} from '@repo/hono-helpers'
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
import {
assetResponses,
CONDITIONAL_HEADERS,
json,
JsonValue,
keyParam,
LoadingScreenTip,
ServiceStatus,
@@ -23,38 +30,41 @@ import type { App, Env } from './context'
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
*/
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
function parseRange(header: string | undefined): R2Range | undefined {
if (!header) return undefined
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
if (!m) return undefined
const start = m[1]
const end = m[2]
if (start === '' && end !== '') return { suffix: Number(end) } // last N bytes
if (start !== '') {
return end !== ''
? { offset: Number(start), length: Number(end) - Number(start) + 1 }
: { offset: Number(start) }
}
return undefined
}
/**
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
* honoring Range requests. 404s when the file is missing.
* Supports conditional GET and byte-range requests (206) — large-file
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
* reassembled file (e.g. EAC "Signatures don't match").
*
* This is why `cache.enabled` is false in wrangler.jsonc: Workers Caching strips `Range`
* before the worker is invoked and slices the 206 out of its own cache, which silently
* degrades to a whole-object 200 whenever the response is not cacheable. The range
* answer has to be ours to guarantee.
*/
async function serveAsset(c: Context<App>, key: string) {
if (key.includes('..')) return c.body(null, 400)
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
const range = parseRange(c.req.header('range'))
const object = await (c.env as Env).CDN_ASSETS.get(key, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
...(range ? { range } : {}),
})
// R2 parses the `Range` header itself when handed the request headers, so there is no
// grammar to reimplement here. It resolves every form (`bytes=a-b`, `bytes=a-`,
// `bytes=-n`) to a concrete offset/length, and anything it cannot parse or satisfy to
// the whole object — see the 206 branch, which is what turns that back into a 200.
// With no `Range` header present this is an ordinary whole-object read.
let object
try {
object = await (c.env as Env).CDN_ASSETS.get(key, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
range: c.req.raw.headers,
})
} catch (e) {
// Defensive: R2 documents InvalidRange (10039) for a range it can't satisfy, which
// is a 416 rather than the 500 the error handler would otherwise turn it into.
// Locally it never fires — workerd resolves an unsatisfiable range to the whole
// object instead of throwing — so this covers the service behaving as documented.
if (e instanceof Error && e.message.includes('(10039)')) return c.body(null, 416)
throw e
}
if (!object) return c.notFound()
const headers = new Headers()
@@ -67,26 +77,56 @@ async function serveAsset(c: Context<App>, key: string) {
// Precondition matched (If-None-Match) → R2 returns no body.
if (!('body' in object)) return new Response(null, { status: 304, headers })
// Range honored → 206 Partial Content with Content-Range.
if (object.range && c.req.header('range')) {
// R2 hands back the RESOLVED range, and the object it returns carries all three
// keys with the inapplicable ones set to undefined — so `'suffix' in r` is true
// even for an offset/length range and cannot discriminate between the two forms.
// (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}`)
// A `bytes=` request is ALWAYS answered 206 with a Content-Range naming the bytes
// actually enclosed — never a bare 200 carrying the whole object. That is the one
// answer a chunked downloader cannot survive: it asked for a slice, so it writes
// whatever comes back at that offset, and a whole-object body silently corrupts the
// reassembled file (EAC "Signatures don't match"). See writeContentRange().
if (writeContentRange(headers, c.req.raw.headers, object)) {
return new Response(object.body, { status: 206, headers })
}
return new Response(object.body, { headers })
}
/**
* What may reach the ASSETS binding as a config filename: one path segment, no slashes,
* and `..` rejected outright below. A traversal is then a 404 from this worker rather than
* a request the asset server has to be trusted to refuse.
*/
const CONFIG_NAME = /^[A-Za-z0-9._-]+$/
/**
* Serve a file from `static/config/` through the ASSETS binding, by its own filename —
* whatever is in that directory, not just the JSON (a config may be an opaque binary blob
* named by GUID). `null` when nothing is published under that name.
*
* A name carrying no extension also resolves against `<name>.json`, because the same file
* is asked for both ways: the game configs that point at these carry the extension
* (`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`) while the client's older config
* calls leave it off. The exact name is tried first, so an extension-less FILE always wins
* over the `.json` guess.
*
* The asset response is handed back whole rather than parsed and re-serialized: it already
* carries a content type and an etag (so `If-None-Match` gets its 304 for free), and these
* files go out BYTE-FOR-BYTE — `RRPlusConfig_v3.json` opens with a UTF-8 BOM, which is what
* the real CDN served and what the client's parser expects.
*/
async function serveConfig(c: Context<App>, name: string): Promise<Response | null> {
if (!CONFIG_NAME.test(name) || name.includes('..')) return null
const candidates = name.includes('.') ? [name] : [name, `${name}.json`]
for (const candidate of candidates) {
// Forwarding the original request keeps its conditional headers; only the URL is
// rewritten to the asset's path.
const res = await c.env.ASSETS.fetch(
new Request(new URL(`/config/${candidate}`, c.req.url), c.req.raw)
)
if (res.ok || res.status === 304) return res
}
return null
}
const app = new Hono<App>()
.use(
'*',
@@ -98,6 +138,15 @@ const app = new Hono<App>()
})(c, next)
)
// The website lets a room's owner download their own scene blobs (see the room page
// in `www`), which means a browser reading these bytes from another origin — without
// these headers it can fetch them but not touch the result. `origin: '*'` gives away
// nothing: every route here is already unauthenticated and public to anyone holding
// the key, and nothing on this worker reads a cookie or a token, so there is no
// ambient credential for `*` to expose. The keys are unguessable UUIDs, and that is
// unchanged by who may read a response they already had to name exactly.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -130,6 +179,49 @@ const app = new Hono<App>()
(c) => c.json(loadingScreenTipData)
)
// Everything else under `/config/`, served from `static/config/` by filename — JSON and
// opaque blobs alike. Declared AFTER the tip-data route above, which would otherwise be
// shadowed by this one: its file is named differently from its path, so it stays a
// route of its own.
.get(
'/config/:name',
describeRoute({
tags: ['Config'],
summary: 'Serve a config file',
description: [
'Serves a file out of `static/config/` verbatim — `RRPlusConfig_v3.json` (the Rec Room',
'Plus benefit lists), `SkuConfig_v1.json` (the Maker AI day-pass store copy) and a',
'GUID-named binary blob today. `{name}` IS the filename, so publishing a config is',
'dropping a file in that directory; nothing in the worker enumerates them, and not',
'everything there is JSON.',
'',
'A name with no extension also resolves against `<name>.json`, because the same file',
'is asked for both ways — the game configs that point at these carry the extension',
'(`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`), the client’s older config',
'calls leave it off. An extension-less file wins over the `.json` guess.',
'',
'These are byte-for-byte copies of what the real CDN served, BOM included, and are',
'not rewritten or re-serialized on the way out.',
].join(' '),
parameters: [
keyParam('name', 'The config’s filename. The `.json` may be left off.', false),
...CONDITIONAL_HEADERS.filter((h) => h.name === 'If-None-Match'),
],
responses: {
200: {
description: 'The config file, as stored',
content: {
'application/json': { schema: resolver(JsonValue) },
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
},
},
304: { description: '`If-None-Match` matched the file’s etag (no body)' },
404: { description: 'No config is published under that name' },
},
}),
async (c) => (await serveConfig(c, c.req.param('name'))) ?? c.notFound()
)
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// 404 when missing.
.get(
@@ -195,6 +287,28 @@ const app = new Hono<App>()
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// Generic client data by name. Anything the client uploads as FileType 2 lands
// under `data/` (a Holotar recording is the one seen in the wild) and the client
// fetches it back from this prefix. Date-foldered like the room and invention
// blobs, so the rest of the path is matched as-is.
.get(
'/data/:id{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a client data blob',
description: [
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
'observed. Like room and invention blobs the name is date-foldered by the upload,',
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
].join(' '),
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The data blob'),
}),
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -209,10 +323,11 @@ app.get(
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
'R2 bucket, plus the one bundled config file the loading screen reads.',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
'from `/config/`.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
@@ -224,7 +339,9 @@ app.get(
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
'which surfaces as an anti-cheat “Signatures don’t match” failure, not a download',
'error.',
'error. So a `bytes=` request is never answered with a whole-object 200: the 206',
'always carries a `Content-Range` stating which bytes the body holds, even where',
'that turns out to be all of them.',
].join('\n'),
},
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
+5
View File
@@ -9,6 +9,11 @@ export type Env = SharedHonoEnv & {
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`.
CDN_ASSETS: R2Bucket
// Static-asset fetcher for the JSON configs in `static/config/` (see wrangler.jsonc
// `assets`). Fetched by filename so `/config/:name` serves whatever is published;
// the binding is the only way in, since `run_worker_first` keeps the runtime from
// serving the files directly.
ASSETS: Fetcher
}
/** Variables can be extended */
+9 -1
View File
@@ -46,6 +46,7 @@ export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject
304: { description: '`If-None-Match` matched the stored etag (no body)' },
400: { description: 'The key contains `..` (no body)' },
404: { description: 'No such object in the bucket' },
416: { description: 'The `Range` header could not be satisfied (no body)' },
}
}
@@ -56,7 +57,7 @@ export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
in: 'header',
required: false,
description:
'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.',
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`), parsed by R2 itself. Any `bytes=` value is answered 206 with a `Content-Range` naming the bytes enclosed — never a bare 200 carrying the whole object, which a chunked downloader would write at the offset it asked for. A multi-range or unsatisfiable value yields the whole object, but says so in the `Content-Range`. A unit other than `bytes` is ignored (200).',
schema: { type: 'string', example: 'bytes=0-1023' },
},
{
@@ -85,6 +86,13 @@ export function keyParam(
// ---- Response schemas ------------------------------------------------------
/**
* An opaque JSON document — the config files under `static/config/` are served verbatim
* and nothing here interprets them, so modelling their fields would be noise that goes
* stale the moment a file is replaced.
*/
export const JsonValue = z.record(z.string(), z.unknown())
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('cdn'),
+138
View File
@@ -28,6 +28,75 @@ describe('cdn endpoints', () => {
expect(body[0]).toHaveProperty('Title')
})
// The config directory is served by filename through the ASSETS binding, so a file
// dropped into `static/config/` is reachable without touching the worker.
test.each(['RRPlusConfig_v3', 'SkuConfig_v1'])(
'GET /config/%s serves the file from static/config',
async (name) => {
const res = await exports.default.fetch(`${ORIGIN}/config/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
expect((await res.text()).length).toBeGreaterThan(0)
}
)
// Not everything in the directory is JSON: a config may be an opaque blob named by
// GUID, which is served as-is under its own name.
test('GET /config/:name serves an extension-less binary config', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/1b057e6e-979d-4f30-8856-a386f77c90da`)
expect(res.status).toBe(200)
expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0)
})
// The game configs name these files WITH the extension (`Econ.MakerAI.DayPass.Config`
// is `"SkuConfig_v1.json"`), so both spellings have to land on the same file.
test('GET /config/:name accepts the .json suffix', async () => {
const bare = await exports.default.fetch(`${ORIGIN}/config/SkuConfig_v1`)
const suffixed = await exports.default.fetch(`${ORIGIN}/config/SkuConfig_v1.json`)
expect(suffixed.status).toBe(200)
expect(await suffixed.text()).toBe(await bare.text())
})
// Byte-for-byte: RRPlusConfig_v3.json opens with a UTF-8 BOM, as the real CDN served
// it. Re-serializing the file (or parsing and re-emitting it) would strip those bytes.
test('GET /config/RRPlusConfig_v3 keeps the file’s bytes, BOM included', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/RRPlusConfig_v3`)
const bytes = new Uint8Array(await res.arrayBuffer())
expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf])
// `text()` decodes the BOM away, so the remainder still parses as the config.
expect(JSON.parse(new TextDecoder().decode(bytes))).toHaveProperty('BenefitLists')
})
test('GET /config/:name 404s a config that is not published', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/NoSuchConfig`)
expect(res.status).toBe(404)
})
// The name reaches the binding as a filename, so anything that could climb out of
// `static/config/` is refused before it gets there. (A bare `..` never arrives: the
// URL is normalized to `/` before routing, which is the liveness probe.)
test.each(['%2e%2e%2floading-screen-tip-data.json', 'sub%2Fdir', '.hidden', 'Sku.Config'])(
'GET /config/%s 404s rather than reaching the asset server',
async (name) => {
const res = await exports.default.fetch(`${ORIGIN}/config/${name}`)
expect(res.status).toBe(404)
}
)
// `run_worker_first` keeps the asset server from answering ahead of the Worker: the
// tip data is an asset too (`static/loading-screen-tip-data.json`), and it must stay
// unreachable at that path — its route is `/config/LoadingScreenTipData`.
test('assets are not served at their own paths', async () => {
const res = await exports.default.fetch(`${ORIGIN}/loading-screen-tip-data.json`)
expect(res.status).toBe(404)
})
test('GET /config/LoadingScreenTipData still wins over the wildcard', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
expect(res.status).toBe(200)
expect(Array.isArray(await res.json())).toBe(true)
})
test('GET /sigs/:sigName 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/sigs/does-not-exist`)
expect(res.status).toBe(404)
@@ -73,6 +142,44 @@ describe('cdn endpoints', () => {
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
})
// The corrupting answer to a byte-range request is a bare 200 carrying the whole
// object: the downloader asked for a slice, so it writes the body at that offset and
// the reassembled file is wrong (EAC "Signatures don't match"). R2 resolves a value
// it cannot parse or satisfy to the WHOLE object rather than failing, so these are
// exactly the inputs that used to fall through to a 200 — every one of them must
// still come back 206 with a Content-Range stating what the body actually holds.
test('GET /sigs/:sigName never answers a bytes range with a whole-object 200', async () => {
await env.CDN_ASSETS.put('sigs/ranged3', new Uint8Array([10, 11, 12, 13, 14, 15]))
const fetchRange = (range: string) =>
exports.default.fetch(`${ORIGIN}/sigs/ranged3`, { headers: { Range: range } })
for (const range of [
'bytes=100-200', // wholly past the end of a 6-byte object
'bytes=abc', // not the byte-range grammar
'bytes=0-1,3-4', // multi-range, which R2 does not serve
'bytes=0-5', // satisfiable, and covers everything
]) {
const res = await fetchRange(range)
expect(res.status, range).toBe(206)
expect(res.headers.get('content-range'), range).toBe('bytes 0-5/6')
}
// A range that runs off the end but starts inside is a real partial read.
const partial = await fetchRange('bytes=4-99')
expect(partial.status).toBe(206)
expect(partial.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await partial.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
// A unit other than bytes must be ignored outright — RFC 9110 — not answered
// with a byte-denominated Content-Range.
const other = await fetchRange('items=0-1')
expect(other.status).toBe(200)
expect(other.headers.get('content-range')).toBeNull()
expect(new Uint8Array(await other.arrayBuffer())).toEqual(
new Uint8Array([10, 11, 12, 13, 14, 15])
)
})
test('GET /room/:dataBlob streams the room blob from R2', async () => {
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
@@ -86,6 +193,20 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
// The website lets a room's owner download their own scene data (the room page in
// `www`), which is a browser reading these bytes from another origin. Without the
// header it can fetch them but not read the result — and the page can't tell that
// apart from the blob being gone.
test('answers CORS so a browser on another origin can read a blob', async () => {
await env.CDN_ASSETS.put('room/2026-08-01/cors-check', new Uint8Array([4, 2]))
const res = await exports.default.fetch(`${ORIGIN}/room/2026-08-01/cors-check`, {
headers: { origin: 'https://www.example.net' },
})
expect(res.status).toBe(200)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 2]))
})
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
// api worker hands back as the invention's BlobName.
@@ -101,6 +222,21 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /data/:id streams the data blob from R2', async () => {
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
})
test('GET /data/:id 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -124,6 +260,8 @@ describe('cdn endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /config/{name}',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
+219
View File
@@ -0,0 +1,219 @@
{
"BenefitLists": {
"0": [
0,
1,
2,
3,
4,
5,
6,
7
],
"1": [
0,
1,
2
],
"2": [
0,
1,
2
],
"3": [
8,
9
],
"4": [
10,
11,
12
],
"5": [
13,
14,
7
]
},
"BenefitLookup": {
"0": {
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
"DetailedText": "You get a {{Tokens}} token box every month. Due to platform restrictions, you must log in each week in order to claim this reward. If you want to claim this reward on another platform you can, but you must login at least once a month on the platform you became a member on.",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
"3": "You get a {{Tokens}} token box every month. You must log in at least once during each 30 day period in order to claim this reward."
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "{{Tokens}} tokens per month! ($11 value)",
"TitleText": "{{Tokens}} bonus tokens per month ($11 USD in monthly value)"
},
"1": {
"CustomSpriteName": "icon_inventory_item",
"DetailedText": "You get a box containing a random 4-star item every week. If you have all 4-star items you will get a 800 token box instead. The week resets at 12 AM UTC on Sundays. Due to platform restrictions, you must login each week in order to claim this reward. If you want to claim this reward on another platform you can, but you must login at least once a month on the platform you became a member on.",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Weekly 4-star box!",
"TitleText": "Free weekly 4-star box"
},
"10": {
"CustomSpriteName": "UI_Menu_Token_Image_50",
"DetailedText": "/month. Cancel anytime",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Join RR+ for Endless Rewards!",
"TitleText": "Join RR+ for Endless Rewards!"
},
"11": {
"CustomSpriteName": "icon_currency_coins",
"DetailedText": null,
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "2X token rewards",
"TitleText": "2X token rewards"
},
"12": {
"CustomSpriteName": "icon_RecToken",
"DetailedText": null,
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "{{Tokens}} monthly tokens",
"TitleText": "Monthly Tokens"
},
"13": {
"CustomSpriteName": "icon_currency_dollars",
"DetailedText": "Unlock the ability to turn earned tokens into real-world money",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": true,
"RequiresPublishingEnabled": false,
"ShortText": "Unlock the ability to turn earned tokens into real-world money",
"TitleText": "Unlock the ability to turn earned tokens into real-world money"
},
"14": {
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
"DetailedText": "Get {{Tokens}} every month and a {{RRODiscount}}% discount on eligible items",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Get {{Tokens}} every month and a {{RRODiscount}}% discount on eligible items",
"TitleText": "Monthly Benefits"
},
"2": {
"CustomSpriteName": "PriceTag",
"DetailedText": "You get {{RRODiscount}}% off all purchases in Rec Room Original token stores (e.g. the watch, merch booth, mirror, cafe, paintball, etc). This does not include Rec Room Original stores that don’t use tokens (e.g. Laser Tag, Lost Skulls, and Crescendo)\r",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "{{RRODiscount}}% RRO store discount!",
"TitleText": "{{RRODiscount}}% discount in Rec Room stores that accept tokens "
},
"3": {
"CustomSpriteName": "icon_OwnedCostumePieces",
"DetailedText": "You get early access to items for a limited time and can claim them for free by logging in while they are in early access.",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Free, early access to items for a limited time!",
"TitleText": "Free, early access to items for a limited time"
},
"4": {
"CustomSpriteName": "Shirt",
"DetailedText": "You get {{MemberOutfitSlots}} outfit slots on top of the regular {{NonMemberOutfitSlots}}. Access them via the Saved Outfits button in the Profile section of your watch or on your Dorm room mirror.",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "{{MemberOutfitSlots}} saved outfit slots!",
"TitleText": "More saved outfit slots"
},
"5": {
"CustomSpriteName": null,
"DetailedText": "You can sell inventions and custom clothing in the watch store and keys, currencies, and consumables in your rooms. You will be paid 70% of all tokens earned through these sales. Those tokens will be held for a week in an escrow account to mitigate fraud, then they’ll be delivered to you the same day as your other RR+ bonus tokens.\r",
"EnabledForPlatforms": 511,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": true,
"RequiresPublishingEnabled": false,
"ShortText": "Earn tokens for your creations!",
"TitleText": "Earn tokens for your creations"
},
"6": {
"CustomSpriteName": "icon_RecToken",
"DetailedText": "Whenever you are offered tokens from a post-activity or first activity of the day reward, you will be offered twice the normal amount.",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Double token rewards!",
"TitleText": "Double token rewards"
},
"7": {
"CustomSpriteName": "icon_Shirt",
"DetailedText": "You can create your own custom shirts with the clothing customizer! You can access the clothing customizer in your backpack, and publish shirts which can be purchased and worn.",
"EnabledForPlatforms": 511,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": true,
"ShortText": "Create your own custom shirts",
"TitleText": "Create your own custom shirts"
},
"8": {
"CustomSpriteName": "Campus_Club_Card_Token_Icon",
"DetailedText": "",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "Plus {{Tokens}} tokens NOW & monthly!",
"TitleText": "Plus {{Tokens}} tokens NOW & monthly!"
},
"9": {
"CustomSpriteName": "icon_Heart",
"DetailedText": "Keep saving {{RRODiscount}}% on every RRO order!",
"EnabledForPlatforms": -1,
"PlatformSpecificDetailedTexts": {
},
"RequiresMonetizationEnabled": false,
"RequiresPublishingEnabled": false,
"ShortText": "And much more!",
"TitleText": "And much more!"
}
},
"HighlightText": "The best deal in Rec Room!",
"MoreDetailsText": "And <u>much more!</u>",
"NumberReplacements": {
"{{MemberOutfitSlots}}": 200,
"{{NonMemberOutfitSlots}}": 16,
"{{RRODiscount}}": 10,
"{{Tokens}}": 6500
},
"StringConfigs": {
"RRPlusMessageBody": "Thanks for becoming a RR+ member. You now have access to all the member benefits!",
"RRPlusMessageTitle": "Welcome to the Club!"
},
"Version": 3
}
+245
View File
@@ -0,0 +1,245 @@
{
"Version": 0,
"SkuConfigs": [
{
"SkuId": 183,
"Name": "{{MakerAIDayPassName}}",
"Description": "{{MakerAIName}} springs to life for {{MakerAIHours}} hours* of creative collaboration!\n\nJust equip your {{MakerPenName}} in any Rooms 2.0 room you own (including Dorms with Maker AI), and your holographic helper can...\n• <b>Create images and patterns</b> on canvases\n• <b>Make objects come to life</b> through AI-generated circuits\n• <b>Generate objects</b> from the {{MakerPenName}} inventory \n• <u><link=\"ID\">Learn more about upcoming features</link> </u>",
"ThumbnailImageName": "img/makerAI_thumb_01.png",
"DetailsImageName": "img/makerAI_storeDetailsPromo_01.png",
"ShowSkuDetails": true,
"Footer": {
"Text": "* {{SubjectToDayPassUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
},
"DialogConfigs": {
"PurchaseSuccess": {
"Title": "Day Pass Purchased",
"Text": "Up to 24 hours of {{MakerAIName}} access has been added to your account. The clock starts now, so go equip your Maker Pen in one of your R2 rooms to get started!",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Explore my R2 Rooms",
"Type": "Primary",
"OnClick": "GoToCreate"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"UpsellAnnouncement": {
"Title": "Make Rooms Magical",
"Text": "Be among the first to try Maker AI in R2 rooms! Our new Day Pass extends beyond RR+ subscribers and dorms, empowering everyone to unleash their imagination.",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Unlock This Deal",
"Type": "Primary",
"OnClick": "GoToDayPass"
}
],
"Cooldown": 10080
},
"UsageLimitReached": {
"Title": "Energy Drained",
"Text": "{{MakerAIName}} hit our day pass usage limits and settled down for a power nap. Ready to wake it up with a fresh Day Pass?",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Wake Up Maker AI",
"Type": "Primary",
"OnClick": "GoToDayPass"
},
{
"Text": "Continue without Maker AI",
"Type": "Secondary",
"OnClick": "DismissDialog"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"TimeLimitReached": {
"Title": "Time's Up!",
"Text": "{{MakerAIName}} hit the day pass time limit and settled down for a power nap. Ready to wake it up with a fresh Day Pass?",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Wake Up Maker AI",
"Type": "Primary",
"OnClick": "GoToDayPass"
},
{
"Text": "Continue without Maker AI",
"Type": "Secondary",
"OnClick": "DismissDialog"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"UsageLimitWarning": {
"Title": "Energy Running Low",
"Text": "Heads up, Maker AI is nearing day pass usage limits and will need a power nap soon. Grab another day pass to keep the magic flowing uninterrupted!",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Add More Energy",
"Type": "Primary",
"OnClick": "GoToDayPass"
},
{
"Text": "Ignore",
"Type": "Secondary",
"OnClick": "DismissDialog"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"TimeLimitWarning": {
"Title": "Time Running Low",
"Text": "Heads up, {{MakerAIName}} has about <b>1 hour left</b> before it needs a recharge. Grab another day pass to keep the magic flowing uninterrupted!",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Add More Time",
"Type": "Primary",
"OnClick": "GoToDayPass"
},
{
"Text": "Ignore",
"Type": "Secondary",
"OnClick": "DismissDialog"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"ComingSoonToMakerAI": {
"Title": "Coming Soon to {{MakerAIName}}",
"Text": "There are some things Maker AI doesn't know how to do yet, and we're working on it!\n\nThis includes...\n•understanding circuits you've edited manually\n•generating objects out of basic shapes\n•placing objects relative to other objects\n\nSo, <b>\"put the lamp next to the couch\"</b> or <b>\"fix this bug in my circuit\"</b> will be confusing to Maker AI (for now).",
"SpriteName": "icon_MakerAI",
"Buttons": []
},
"ChatError": {
"Title": "Ready to create?",
"Text": "Your building bestie is taking a power nap. Wake it up with a fresh {{MakerAIDayPassName}}!",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Wake up Maker AI",
"Type": "Primary",
"OnClick": "GoToDayPass"
}
]
},
"PurchaseConfirmation": {
"Title": "Up to {{MakerAIHours}} Hours of Power!",
"Text": "Once a day pass is purchased, its timer keeps running even when you're not playing. The pass can't be paused or refunded even if you hit usage limits early. \n\n<size=75%><i>{{PlatformConfirmationMessage}}</i></size>",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Accept & Continue to Payment",
"Type": "Primary",
"OnClick": "PositiveEvent"
}
],
"Footer": {
"Text": "By pressing \"Accept & Continue to Payment\", I expressly agree that access to {{MakerAIName}} will begin immediately, and I acknowledge that I will lose my right to cancel or get a refund once access starts— even if it expires early due to <u>usage limits</u>.",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"DayPassActiveBanner": {
"Title": "Day Pass Active",
"Text": "You currently have {{MakerAITimeLeftDynamic}} of {{MakerAIName}} access. If you buy another pass now, you'll get an additional {{MakerAIHours}} hours.",
"SpriteName": "icon_hourglass",
"Buttons": []
},
"MakerAIUsageLimits": {
"Title": "{{MakerAIName}} Usage Limits and Conditions",
"Text": "<b>Why does Maker AI have usage limits?</b>\nMaker AI is a powerful tool that allows everyone to create, but every action has a cost. Usage limits ensure we can continue to offer Maker AI to everyone.\n\n<b>What are the usage limits?</b>\nWe limit day passes to many hundreds of simple requests or many dozens of our most expensive requests (e.g., images and circuits). Don’t worry—this should only affect less than 1–5% of users. \n\n<b>What happens if I hit these limits?</b>\n•If it looks like you are getting close, we will provide a warning that you're approaching the limit.\n• If you exceed the limit, your day pass will end early. You will be able to buy another pass and continue working.\n\nWe appreciate your understanding as we work to make this tool available to all.\n\n<u><link=\"{{LearnMoreAboutUsageLimitsUrl}}\">Learn More</link></u>",
"SpriteName": "icon_MakerAI",
"Buttons": []
},
"FreeTrialConfirmation": {
"Title": "Try {{MakerAIName}} for free",
"Text": "Get up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} in an R2 room you own, free of charge.\n\nOnce your free trial starts, its timer keeps running even when you're not playing. It can't be paused or reactivated even if you hit usage limits early.",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Accept & Start Free Trial",
"Type": "Primary",
"OnClick": "PositiveEvent"
}
],
"Footer": {
"Text": "By pressing \"Accept & Start Free Trial\", I expressly agree that access to {{MakerAIName}} will begin immediately, and I acknowledge that I will lose my right to cancel once access starts—even if it expires early due to <u>usage limits</u>.",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"FreeTrialSuccess": {
"Title": "Free Trial Activated",
"Text": "Up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} access has been added to your account, free of charge. The clock starts now, so go equip your {{MakerPenName}} in one of your R2 rooms to get started.",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Explore my R2 Rooms",
"Type": "Primary",
"OnClick": "GoToCreate"
}
],
"Footer": {
"Text": "{{LearnMoreAboutUsageLimits}}",
"ExternalUrl": "{{LearnMoreAboutUsageLimitsUrl}}"
}
},
"ActivateFreeTrialError": {
"Title": "Something Went Wrong",
"Text": "A problem occurred while attempting to activate your free trial, try again in a few minutes. If the problem persists, you can contact us at <u>https://recroom.zendesk.com</u>.",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Ok",
"Type": "Primary",
"OnClick": "DismissDialog"
}
]
},
"FreeTrialAnnouncement": {
"Title": "Creation for Everyone",
"Text": "Get up to {{MakerAIFreeTrialDurationDynamic}} of {{MakerAIName}} in an R2 room you own, free of charge. No strings attached—just pure creative power to generate images, circuits, and more!",
"SpriteName": "icon_MakerAI",
"Buttons": [
{
"Text": "Unlock This Deal",
"Type": "Primary",
"OnClick": "GoToDayPass"
}
],
"Cooldown": 10080
}
}
}
],
"StringReplacements": {
"{{MakerAIName}}": "Maker AI",
"{{MakerAIDayPassName}}": "Maker AI Day Pass",
"{{DayPassNamePlural}}": "Day Passes",
"{{MakerAIHours}}": "24",
"{{SubjectToDayPassUsageLimits}}": "Subject to <u>usage limits</u>. Currently unavailable on Playstation and Nintendo Switch.",
"{{LearnMoreAboutUsageLimits}}": "<u><link=\"MakerAIUsageLimits\">Learn more about day pass usage limits</link></u>",
"{{LearnMoreAboutUsageLimitsUrl}}": "https://rec.net/creator/p/makerai-daypass",
"{{MakerPenName}}": "Maker Pen"
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "gay",
"ImageName": "tip.jpg",
"PlatformMask": 175,
"CreatedAt": "2019-02-28T18:27:25Z"
},
@@ -23,7 +23,7 @@
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "gay",
"ImageName": "tip.jpg",
"PlatformMask": 167,
"CreatedAt": "2019-02-28T18:15:33Z"
},
+26 -1
View File
@@ -4,8 +4,33 @@
"main": "src/cdn.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Workers Caching is OFF here, and must stay off: it STRIPS the `Range` header before
// invoking the worker, asks for the whole body, and slices the 206 out of its own
// cache. That works only while the response is actually cacheable — on any bypass
// (see the automatic bypass rules) nothing slices, and the client that asked for a
// byte range receives the whole object with a 200. A chunked downloader writes that
// at the offset it asked for and the reassembled file is corrupt (EAC "Signatures
// don't match"). With caching off the `Range` header reaches serveAsset, which
// always answers a `bytes=` request with a 206 and a truthful Content-Range.
// The cost is that every asset read hits R2; correctness on these blobs is worth it.
"cache": {
"enabled": true
"enabled": false
},
// The JSON config files in `static/config/`, uploaded as Workers static assets rather
// than bundled into the script, so `/config/:name` serves whatever is published there:
// adding a config is dropping in a file (a bundled `import` can't do that — the
// bundler has to see every path at build time).
//
// `run_worker_first: true` because these are the WORKER'S data files, not a site.
// Without it the runtime answers any request whose path matches an asset before the
// Worker runs, which would both publish every config a second time at
// `/config/<name>.json` and put asset routing in front of the R2 routes below. With
// it, the Worker sees every request and the files are reachable only through
// `/config/:name`.
"assets": {
"binding": "ASSETS",
"directory": "./static",
"run_worker_first": true
},
// CDN binaries (signature blobs + room build data) are stored as R2 objects
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
+11 -5
View File
@@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import '../../chat.app'
import { NotificationType } from '../../../../notify/src/notification-types'
import {
ChatModerationState,
getMessage,
@@ -721,7 +722,7 @@ describe('ChatMessageReceived push', () => {
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
interface SentNotification {
playerId: number
notificationType: number
notificationType: NotificationType
data: Record<string, unknown>
}
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
@@ -759,8 +760,9 @@ describe('ChatMessageReceived push', () => {
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
// NotificationType.ChatMessageReceived
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
@@ -982,7 +984,9 @@ describe('POST /thread/:id', () => {
it('pushes ChatMessageReceived to every member', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): {
takeSent(): Promise<Array<{ playerId: number; notificationType: number }>>
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
}
}
const caller = 889005
@@ -992,7 +996,9 @@ describe('POST /thread/:id', () => {
await send(caller, `/thread/${chatThreadId}`)
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
-965
View File
@@ -1,965 +0,0 @@
/**
* 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 }
}
+26 -7
View File
@@ -2,9 +2,6 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
clearHomeClub,
ClubJoinability,
@@ -22,16 +19,22 @@ import {
getClubsByMember,
getHomeClub,
getMembership,
glyphLength,
joinClub,
leaveClub,
MAX_ADDITIONAL_IMAGES,
MAX_CLUB_DESCRIPTION_LENGTH,
MAX_CLUB_NAME_LENGTH,
requestToJoinClub,
searchClubs,
setClubAdditionalImage,
setHomeClub,
setMemberType,
updateClub,
} from './clubs-db'
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AnnouncementIdEnvelope,
AnnouncementRequest,
@@ -101,8 +104,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/
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
@@ -665,6 +666,14 @@ const app = new Hono<App>()
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
// Counted in code points like the name above, so an emoji-heavy description is
// measured the way a player sees it rather than by UTF-16 units.
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
// The per-account cap, checked after the cheap validations so a rejected name
// costs no extra D1 read.
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
@@ -778,9 +787,19 @@ 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, {
name,
description: field('description') || undefined,
description,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
+8 -5
View File
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
description: z.string().optional(),
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
description: z.string().optional().describe('At most 512 characters'),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('Empty means unchanged'),
name: z
.string()
.optional()
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
+12 -5
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../clubs.app'
import { SCHEMA_DDL } from '../../clubs-db'
import { CLUB_SCHEMA_DDL } from '@repo/domain'
import type { Env } from '../../context'
@@ -18,7 +18,7 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// Build the club / club_member tables (mirrors the migration).
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CLUB_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — a player's home club is a field on
// their account row, so /club/home/me reads and writes it here.
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Names cap at 40 characters.
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
// refused at half the length a player can see (the description has no charset rule
// — only the name does).
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
+8
View File
@@ -6,12 +6,20 @@ A Cloudflare Workers application using Hono
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
`false`.
- `POST /purchase/v1/initiatepurchase` — begins a purchase, answering
`{ "transactionId": 1234567890 }`. Nothing is charged and no transaction is
recorded, so the id is a fixed placeholder and the posted body is ignored.
- `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special
offers), served from the bundled `static/catalog-v1-all.json`. The client's
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
contains only available SKUs.
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
(limited-time offers/promos); `[]` (none active).
- `GET /reminder/currentTokenBundles/v2` — token-bundle purchase reminders (the
"buy more tokens" nudge); `[]` (none to show).
- `GET /openapi.json` — the generated OpenAPI 3.1 spec for the routes above.
Descriptive only; nothing is validated against it. Also aggregated into the
docs UI on `www` at `/docs`.
## Development
+6 -1
View File
@@ -16,8 +16,13 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+133 -6
View File
@@ -1,9 +1,21 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import catalog from '../static/catalog-v1-all.json'
import {
BareBoolean,
boolQuery,
CatalogSku,
HealthResponse,
InitiatePurchaseRequest,
InitiatePurchaseResponse,
json,
JsonArray,
jsonBody,
} from './openapi'
import type { App } from './context'
@@ -11,6 +23,14 @@ import type { App } from './context'
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
* method routes are served bare.
*/
/**
* The transaction id every purchase initiation answers with. Real money never changes
* hands here and nothing is persisted, so the client only needs a well-formed handle to
* carry through the rest of its store flow.
*/
const PLACEHOLDER_TRANSACTION_ID = 1234567890
const app = new Hono<App>()
.use(
'*',
@@ -25,24 +45,131 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the commerce worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'commerce', status: 'ok' })
)
// Whether the player has ever spent money. A 404 here makes the client treat
// it as an error, so we return `false` (no purchases).
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
.get(
'/purchase/v1/hasspentmoney',
describeRoute({
tags: ['Purchase'],
summary: 'Whether the player has ever spent money',
description: [
'Always `false` — nobody buys anything on this server. A 404 here makes the client',
'treat the call as an error, so the answer is the bare boolean rather than nothing.',
].join(' '),
responses: { 200: json(BareBoolean, 'Always false (no purchases)') },
}),
(c) => c.json(false)
)
// Begin a purchase. The client asks for a transaction handle before it takes the
// player to the platform store; nothing is charged or recorded here, so the id is a
// fixed placeholder and the posted body is ignored.
.post(
'/purchase/v1/initiatepurchase',
describeRoute({
tags: ['Purchase'],
summary: 'Begin a purchase',
description: [
'Hands the client the transaction handle it carries through the rest of the store',
'flow. Nothing is charged and no transaction is recorded, so the id is a fixed',
'placeholder and the posted body is accepted and ignored — an absent or unparseable',
'body is a 200, not a 400.',
].join(' '),
requestBody: jsonBody(InitiatePurchaseRequest, 'The purchase the player confirmed'),
responses: { 200: json(InitiatePurchaseResponse, 'The (placeholder) transaction id') },
}),
(c) => c.json({ transactionId: PLACEHOLDER_TRANSACTION_ID })
)
// The purchasable SKU catalog (token packs, special offers), served from the
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
// catalog is already only the available SKUs, so the param doesn't change the
// response.
.get('/api/catalog/v1/all', (c) => c.json(catalog))
.get(
'/api/catalog/v1/all',
describeRoute({
tags: ['Catalog'],
summary: 'The purchasable SKU catalog',
description: [
'The token packs, bundles and special offers the store shows, served from the bundled',
'static catalog. The client’s `onlyAvailableSkus` is accepted and ignored: the bundled',
'catalog already contains only available SKUs.',
].join(' '),
parameters: [
boolQuery('onlyAvailableSkus', 'Accepted and ignored — the catalog is already filtered'),
],
responses: { 200: json(CatalogSku.array(), 'Every available SKU') },
}),
(c) => c.json(catalog)
)
// Current purchase campaigns (limited-time offers/promos). None exist, and
// an empty list is the client's "no active campaigns" state.
.get('/purchasecampaign/allcurrent/v2', (c) => c.json([]))
.get(
'/purchasecampaign/allcurrent/v2',
describeRoute({
tags: ['Purchase'],
summary: 'Current purchase campaigns',
description: [
'Limited-time offers and promos. Always `[]` — none exist, and an empty list is the',
'client’s “no active campaigns” state.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty (no active campaigns)') },
}),
(c) => c.json([])
)
// Token-bundle purchase reminders (the "buy more tokens" nudge). None to show,
// and an empty list is the client's "no reminders" state.
.get('/reminder/currentTokenBundles/v2', (c) => c.json([]))
.get(
'/reminder/currentTokenBundles/v2',
describeRoute({
tags: ['Purchase'],
summary: 'Token-bundle purchase reminders',
description: [
'The “buy more tokens” nudges. Always `[]` — there are none to show, and an empty list',
'is the 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
+118
View File
@@ -0,0 +1,118 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the commerce worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match/playersettings workers: a reverse-engineered
* protocol, lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** An optional boolean query parameter. */
export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'boolean' } }
}
// ---- Loose shapes ----------------------------------------------------------
/** An opaque JSON object — a body whose fields haven't been reversed yet. */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (an empty-list stub). */
export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — `hasspentmoney` answers `false` with no envelope. */
export const BareBoolean = z.boolean()
// ---- Service ---------------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('commerce'),
status: z.literal('ok'),
})
// ---- Catalog ---------------------------------------------------------------
/**
* The per-SKU `data` blob. `giftDropIds` are the drops granted when the SKU is redeemed
* (empty for the bundles, which grant their contents directly); `message` is the label the
* store shows on the purchase.
*/
export const CatalogSkuData = z.object({
giftDropIds: z.array(z.int()),
message: z.string(),
subscriptionPurchase: z
.unknown()
.optional()
.describe('Present only on the subscription SKU; its shape is not reversed yet'),
})
/**
* One purchasable SKU from `GET /api/catalog/v1/all` — a token pack, a bundle or a
* special offer. `price` is in cents on the store the client is running against, and the
* per-store id fields are only present where that SKU ships on that store, so all of them
* are optional except the Oculus/Apple/Google ids the reference catalog always carries.
*/
export const CatalogSku = z.object({
skuId: z.int(),
name: z.string(),
description: z.string().describe('Often an empty string for token packs'),
imageName: z.string().describe('The store tile image; the img worker serves it by name'),
price: z.int().describe('Store price in cents, e.g. 99 = $0.99'),
oculusSkuId: z.string(),
appleProductId: z.string(),
googlePlaySkuId: z.string(),
picoSkuId: z.string().optional(),
xboxProductId: z.string().optional(),
xboxStoreId: z.string().optional(),
psnProductLabel: z.string().optional(),
psnEntitlementLabel: z.string().optional(),
nintendoSkuId: z.string().optional(),
isSingleUse: z.boolean(),
shouldAppearInTokenStore: z.boolean(),
dataSchemaVersion: z.int(),
data: CatalogSkuData,
})
// ---- Purchase --------------------------------------------------------------
/**
* `POST /purchase/v1/initiatepurchase` body — what the client sends when the player
* confirms a purchase (the SKU and the store it is being bought on). Accepted and
* ignored: the field names have not been reversed yet, and nothing here talks to a store.
*/
export const InitiatePurchaseRequest = JsonObject.describe(
'The 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,6 +18,22 @@ describe('commerce endpoints', () => {
expect(await res.json()).toBe(false)
})
it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ skuId: 178, platform: 'Standalone' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ transactionId: 1234567890 })
})
it('POST /purchase/v1/initiatepurchase ignores the body entirely', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, { method: 'POST' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ transactionId: 1234567890 })
})
it('GET /api/catalog/v1/all serves the SKU catalog', async () => {
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
expect(res.status).toBe(200)
@@ -38,4 +54,45 @@ describe('commerce endpoints', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /api/catalog/v1/all',
'GET /purchase/v1/hasspentmoney',
'GET /purchasecampaign/allcurrent/v2',
'GET /reminder/currentTokenBundles/v2',
'POST /purchase/v1/initiatepurchase',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
// not always hoist, leaving a dangling reference.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
# discovery
Discovery Worker served on the `discovery` subdomain (`discovery.recflare.net`) — a Hono
app that tells the client which carousels each of its discovery pages shows, and in what
order.
`GET /sections/pagesource/{type}` serves one page's sections verbatim from
`static/<type>.json`. `{type}` IS the filename — it is passed through unchanged and matched
exactly, case included — so the page sources that exist are whichever files are published:
`WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`, `PlayCategories`,
`StoreFeatured`, `StoreClothing`, `StoreConsumables` and `bulk` at the time of writing.
Adding one is dropping in a file; nothing in `src/` enumerates them.
That works because `static/` is uploaded as Workers static assets rather than bundled into
the script (a bundled `import` can't do it — the bundler has to see every path at build
time), and the handler reads them through the ASSETS binding. `run_worker_first` is set so
the runtime never serves a layout directly at `/WatchHome.json`: the files are reachable
only through the documented route. Names that could climb out of `static/` are refused
before they reach the binding, and the asset response is passed through whole, so
`If-None-Match` gets a 304 for free.
The body is a bare ARRAY of sections with camelCase fields (`id`, `sectionType`,
`sectionSubType`, `source`, `sourceMetadata`, `displayMetadata`), the last two nullable and
`displayMetadata` an embedded JSON _string_ the client parses itself. This replaces the
`Discovery.DiscoveryPageContent.*` game configs (see
`apps/api/static/gameconfigs-v1-all.json`), which the client reads instead when
`Discovery.UseNewDiscoveryServerAPI` is off — note they are NOT the same shape: the configs
wrap the list in `{ pageSource, sections }` with PascalCase fields.
A section only _names_ a feed — `source`/`sourceMetadata`, e.g. `Hot`, `Recent`,
`PlaylistById` + an id, `CarouselEndpoint` + a slug — which the client resolves against the
`rooms`/`api` workers itself. Nothing here is player-specific, so the routes are
unauthenticated and every client gets the same layout.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit alongside
each handler, with the schemas in `src/openapi.ts`. It's also aggregated into the docs page
www serves at `/docs`.
**The spec is descriptive, not enforced** — same rationale as the other workers: a
reverse-engineered protocol, lenient handlers, no runtime validation.
## Development
### Run in dev mode
```sh
pnpm dev
```
### Run tests
```sh
pnpm test
```
### Deploy
```sh
pnpm turbo deploy
```
+12
View File
@@ -0,0 +1,12 @@
// oxlint-disable @typescript-eslint/consistent-type-imports
type LocalEnv = import('./src/context').Env
type MainModule = typeof import('./src/discovery.app')
// Add Env to Cloudflare namespace so that we can access it via
// import { env } from 'cloudflare:workers'
declare namespace Cloudflare {
interface Env extends LocalEnv {}
interface GlobalProps {
mainModule: MainModule
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "discovery",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build:wrangler": "run-wrangler-build",
"check:lint": "run-oxlint",
"check:types": "run-tsc",
"check:workers-types": "run-wrangler-types --check",
"deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"test": "run-vitest"
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Static-asset fetcher for the page layouts in `static/` (see wrangler.jsonc
* `assets`). Fetched by filename so `{type}` is a wildcard; the binding is the only
* way in, since `run_worker_first` keeps the runtime from serving the files directly.
*/
ASSETS: Fetcher
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
+118
View File
@@ -0,0 +1,118 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi'
import { fetchPageSource } from './page-sources'
import type { App } from './context'
/**
* Discovery Worker. Serves the layout of the client's discovery pages — which carousels a
* page shows and in what order — out of `static/`, one file per page source, through the
* ASSETS binding (see `page-sources.ts`). It does not serve the carousels' CONTENTS: each
* section names a client-side feed the client resolves against the `rooms`/`api` workers
* itself.
*
* Unauthenticated: every client gets the same layout, and the client fetches this before
* anything player-specific.
*/
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the discovery worker. No auth.',
responses: { 200: json(ServiceStatus, 'Service is up') },
}),
(c) => c.json({ service: 'discovery', status: 'ok' })
)
// One discovery page's section layout, served verbatim from `static/<type>.json`.
.get(
'/sections/pagesource/:type',
describeRoute({
tags: ['Discovery'],
summary: 'Section layout for a page source',
description: [
'The sections of one discovery page, in the order the client draws them. `{type}` IS',
'the filename — the body is `static/<type>.json` served verbatim — so the page sources',
'that exist are whichever files are published (`WatchHome`, `PlayHighlight`,',
'`CommunityBoard`, `PlayMenuTabs`, `PlayCategories`, `StoreFeatured`, `StoreClothing`,',
'`StoreConsumables` and `bulk` at the time of writing). The match is exact, case',
'included.',
'',
'This replaces the `Discovery.DiscoveryPageContent.*` game configs, which carried the',
'same layouts as embedded JSON strings: with `Discovery.UseNewDiscoveryServerAPI` set',
'to `True` the client asks this service instead. The two are not the same shape — the',
'configs wrapped the list in `{ pageSource, sections }` with PascalCase fields, while',
'this answers the bare ARRAY with camelCase ones.',
'',
'A section only NAMES a feed (`source`/`sourceMetadata`); its rooms, items and accounts',
'are fetched separately by the client. Nothing here is player-specific, so there is no',
'auth and every client gets the same layout.',
].join('\n'),
parameters: [PAGE_SOURCE_PARAM],
responses: {
200: json(DiscoverySections, 'The page’s sections'),
304: { description: '`If-None-Match` matched the file’s etag (no body)' },
404: { description: 'No file is published under that name' },
},
}),
async (c) => {
const res = await fetchPageSource(c, c.req.param('type'))
return res ?? c.notFound()
}
)
// 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 discovery',
version: '1.0.0',
description: [
'Discovery page layouts for recflare, a private-server reimplementation of the Rec',
'Room backend. The client asks this service which sections each of its discovery',
'pages shows — Watch home, the play menu and its tabs, the community board, the store',
'pages — and draws them in the order given.',
'',
'Each layout is a file in `static/`, published as a Workers static asset and served',
'verbatim by filename, so the set of page sources is whatever is published rather',
'than anything the code enumerates. Nothing is editable at runtime and every client',
'gets the same answer, so the routes are unauthenticated.',
'',
'A section names a feed rather than carrying its contents: the client resolves the',
'rooms, items and accounts behind each carousel against the `rooms` and `api` workers',
'itself.',
].join('\n'),
},
servers: [{ url: 'https://discovery.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+78
View File
@@ -0,0 +1,78 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the discovery 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 other 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) } } }
}
/** The `{type}` path parameter, which is the filename in `static/`. */
export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'type',
in: 'path',
required: true,
description: [
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
'`PlayCategories`, `StoreFeatured`, `StoreClothing`, `StoreConsumables`, `bulk` at the',
'time of writing. It names a file in `static/` (`<type>.json`) and is matched exactly,',
'case included, so the set is whatever is published rather than anything this worker',
'enumerates.',
].join(' '),
// Deliberately not an `enum`: the accepted values are the published files, and a spec
// that froze today's list would be wrong the moment one is added.
schema: { type: 'string', example: 'WatchHome' },
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('discovery'),
status: z.literal('ok'),
})
/**
* How one carousel on a discovery page is filled and drawn. `source`/`sourceMetadata`
* name a feed (`Hot`, `Recent`, `PlaylistById` + an id, `CarouselEndpoint` + a slug, …)
* that the client resolves against the `rooms`/`api` workers itself — this worker only
* says WHICH carousels a page has and in what order, never their contents.
*/
export const DiscoverySection = z.object({
id: z.string().describe('Unique id of this section on this page, e.g. `Rooms_RRO_WatchHome`'),
sectionType: z.int().describe('What the section lists (0 rooms, 1 accounts, 4 store items, …)'),
sectionSubType: z.string().describe('The section’s kind, shared across pages, e.g. `Rooms_RRO`'),
source: z.string().describe('The feed that fills the section'),
sourceMetadata: z
.string()
.nullable()
.describe('Argument to `source` — a carousel slug, a playlist id, … `null` when it takes none'),
displayMetadata: z
.string()
.nullable()
.describe(
'How the section is drawn (`DisplayTitle`, `numRows`, `backgroundColor`, …), as an ' +
'embedded JSON *string* the client parses itself — not an object. Its booleans and ' +
'numbers are quoted in most sections and bare in some; both forms are live in the ' +
'captures, so the client evidently takes either. `null` where a section is drawn ' +
'however its type says (several store and play-highlight sections).'
),
})
/** `GET /sections/pagesource/{type}` — a page's sections, in the order they are drawn. */
export const DiscoverySections = DiscoverySection.array()
+37
View File
@@ -0,0 +1,37 @@
import type { Context } from 'hono'
import type { App } from './context'
/**
* The discovery page layouts, one file per page source in `static/`, served through the
* ASSETS binding (see wrangler.jsonc). `{type}` IS the filename: it is passed through to
* `static/<type>.json` unchanged, so publishing a new page source is dropping in a file —
* nothing in this worker enumerates or knows their names.
*
* Case included: the asset manifest is case-sensitive, so a file must be named exactly as
* the client asks for it (`WatchHome.json`, not `watchhome.json`). Nothing here folds the
* case, because there is no index to fold it against.
*/
/**
* What may reach the binding as a filename. Deliberately narrow — no dots, no slashes,
* nothing that could climb out of `static/` — so a path traversal is a 404 from this
* worker rather than a request the asset server has to be trusted to refuse.
*/
const SAFE_NAME = /^[A-Za-z0-9_-]+$/
/**
* Fetch `static/<type>.json` through the ASSETS binding. `null` when no such file is
* published, or when the name isn't one a file could have.
*
* The asset response is handed back whole rather than parsed and re-serialized: it
* already carries the right content type and an etag, so a client that sends
* `If-None-Match` gets its 304 for free.
*/
export async function fetchPageSource(c: Context<App>, type: string): Promise<Response | null> {
if (!SAFE_NAME.test(type)) return null
// Forwarding the original request keeps its conditional headers, so the binding
// answers 304 on a match; only the URL is rewritten to the asset's path.
const res = await c.env.ASSETS.fetch(new Request(new URL(`/${type}.json`, c.req.url), c.req.raw))
return res.ok || res.status === 304 ? res : null
}
@@ -0,0 +1,122 @@
import { SELF } from 'cloudflare:test'
import { describe, expect, it } from 'vitest'
/** Every layout published in `static/` today. Each is served under its own filename. */
const PAGE_SOURCES = [
'WatchHome',
'PlayHighlight',
'CommunityBoard',
'PlayMenuTabs',
'PlayCategories',
'StoreFeatured',
'StoreClothing',
'StoreConsumables',
'bulk',
]
interface Section {
id: string
sectionType: number
sectionSubType: string
source: string
sourceMetadata: string | null
displayMetadata: string | null
}
/** Fetch a page source and return its parsed body. */
async function pageSource(type: string) {
const res = await SELF.fetch(`https://discovery.example.com/sections/pagesource/${type}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
return (await res.json()) as Section[]
}
describe('GET /', () => {
it('answers the liveness probe', async () => {
const res = await SELF.fetch('https://discovery.example.com/')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'discovery', status: 'ok' })
})
})
describe('GET /sections/pagesource/:type', () => {
// The point of the ASSETS binding: `{type}` is the filename, so every published file
// is reachable without the worker knowing its name.
it.each(PAGE_SOURCES)('serves %s', async (type) => {
const sections = await pageSource(type)
expect(sections.length).toBeGreaterThan(0)
for (const section of sections) {
expect(typeof section.id).toBe('string')
expect(typeof section.sectionType).toBe('number')
expect(typeof section.source).toBe('string')
// An embedded JSON *string* the client parses itself, not an object — or null,
// which several store and play-highlight sections use.
if (section.displayMetadata !== null) {
expect(typeof section.displayMetadata).toBe('string')
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
}
}
})
it('serves the file verbatim', async () => {
const sections = await pageSource('WatchHome')
expect(sections[0]).toEqual({
id: 'Rooms_ForYou_WatchHome',
sectionType: 0,
sectionSubType: 'Rooms_ForYou',
source: 'CarouselEndpoint',
sourceMetadata: 'foryou',
displayMetadata: expect.stringContaining('"DisplayTitle":"Recommended For You"'),
})
})
// The asset manifest is case-sensitive and there is no index to fold case against, so
// the name has to match the file exactly.
it('404s a name whose case does not match the file', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/watchhome')
expect(res.status).toBe(404)
})
it('404s an unpublished page source', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/nope')
expect(res.status).toBe(404)
})
// The name reaches the ASSETS binding as a filename, so anything that could climb out
// of `static/` is refused before it gets there.
it.each(['..', '%2e%2e%2fwrangler.jsonc', 'sub%2Fdir', 'WatchHome.json'])(
'404s a name that could not be a file in static/ (%s)',
async (type) => {
const res = await SELF.fetch(`https://discovery.example.com/sections/pagesource/${type}`)
expect(res.status).toBe(404)
}
)
it('answers 304 when the etag matches', async () => {
const first = await SELF.fetch('https://discovery.example.com/sections/pagesource/WatchHome')
const etag = first.headers.get('etag')
expect(etag).toBeTruthy()
const second = await SELF.fetch('https://discovery.example.com/sections/pagesource/WatchHome', {
headers: { 'if-none-match': etag as string },
})
expect(second.status).toBe(304)
})
// `run_worker_first` keeps the layouts off their own asset URLs: the only way to a file
// is the documented route.
it('does not serve the files at their asset paths', async () => {
const res = await SELF.fetch('https://discovery.example.com/WatchHome.json')
expect(res.status).toBe(404)
})
})
describe('GET /openapi.json', () => {
it('generates a spec with no dangling $refs', async () => {
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as { paths: Record<string, unknown> }
expect(Object.keys(spec.paths)).toContain('/sections/pagesource/{type}')
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})
})
+162
View File
@@ -0,0 +1,162 @@
[
{
"id": "Accounts_OnlineFriends_WatchHome",
"sectionType": 1,
"sectionSubType": "Accounts_OnlineFriends",
"source": "OnlineFriends",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Online Friends\",\"itemCount\":3,\"cardStyle\":\"Horizontal\"}"
},
{
"id": "Rooms_ForYou_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_ForYou",
"source": "CarouselEndpoint",
"sourceMetadata": "foryou",
"displayMetadata": "{\"DisplayTitle\":\"Recommended For You\",\"supportsDedupe\":\"true\",\"numRows\":\"2\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"#F3DDCE\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MostPopular",
"sectionType": 0,
"sectionSubType": "Rooms_MostPopular",
"source": "Hot",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Trending\",\"supportsDedupe\":\"true\",\"numRows\":\"1\"}"
},
{
"id": "Rooms_FeaturedThisWeek",
"sectionType": 0,
"sectionSubType": "Rooms_Featured",
"source": "Featured",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Featured This Week\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"all=highlight_scale_150\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_New_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_New",
"source": "Hot",
"sourceMetadata": "new",
"displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "Rooms_RRO_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_RRO",
"source": "Hot",
"sourceMetadata": "rro",
"displayMetadata": "{\"DisplayTitle\":\"Rec Room Originals\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_WeLove",
"sectionType": 0,
"sectionSubType": "Rooms_WeLove",
"source": "CarouselEndpoint",
"sourceMetadata": "inkshowcase",
"displayMetadata": "{\"DisplayTitle\":\"Rooms We Love\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_ContinuePlaying_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_ContinuePlaying",
"source": "Recent",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_RecentlyUpdated",
"sectionType": 0,
"sectionSubType": "Rooms_RecentlyUpdated",
"source": "CarouselEndpoint",
"sourceMetadata": "recentlyupdated",
"displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Favorites",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerFavorites",
"source": "MyFavorites",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_TopEarning_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_TopEarning",
"source": "PlaylistById",
"sourceMetadata": "65380152",
"displayMetadata": "{\"DisplayTitle\":\"Top Earning\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_CreatorsYouFollow",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerSubscriptions",
"source": "MySubscriptions",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"From Creators You Follow\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_R2Econ",
"sectionType": 0,
"sectionSubType": "Rooms_StateOfTheArt",
"source": "PlaylistById",
"sourceMetadata": "1876634643426420466",
"displayMetadata": "{\"DisplayTitle\":\"State of the Art\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Roleplay",
"source": "CarouselEndpoint",
"sourceMetadata": "roleplay_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Casual_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Casual",
"source": "CarouselEndpoint",
"sourceMetadata": "casual_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Horror_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Horror",
"source": "CarouselEndpoint",
"sourceMetadata": "horror_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Battle_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Battle",
"source": "CarouselEndpoint",
"sourceMetadata": "battle_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Explore_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Exploration",
"source": "CarouselEndpoint",
"sourceMetadata": "explore_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Quests_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Quests",
"source": "CarouselEndpoint",
"sourceMetadata": "quests_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Hangout",
"source": "CarouselEndpoint",
"sourceMetadata": "hangout_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
}
]
+42
View File
@@ -0,0 +1,42 @@
[
{
"displayMetadata" : "{\"DisplayTitle\":\"Compete\",\"imageName\": \"roomCatSelect_pvp.jpg\"}",
"id" : "pvp_search_roomcategory",
"sectionSubType" : "pvp_search",
"sectionType" : 11,
"source" : "ServerSearch",
"sourceMetadata" : "PVP"
},
{
"displayMetadata" : "{\"DisplayTitle\":\"Fun with Friends\",\"imageName\": \"roomCatSelect_roomsWeLove.jpg\"}",
"id" : "rooms_friendshiprates",
"sectionSubType" : "roomcategories_friendshiprates",
"sectionType" : 11,
"source" : "PlaylistById",
"sourceMetadata" : "7732248245192312892"
},
{
"displayMetadata" : "{\"DisplayTitle\":\"Quest\",\"imageName\": \"roomCatSelect_quest.jpg\"}",
"id" : "quest_search_roomcategory",
"sectionSubType" : "quest",
"sectionType" : 11,
"source" : "ServerSearch",
"sourceMetadata" : "Quest"
},
{
"displayMetadata" : "{\"DisplayTitle\":\"Horror\",\"imageName\": \"roomCatSelect_horror.jpg\"}",
"id" : "horror_search_roomcategory",
"sectionSubType" : "horror",
"sectionType" : 11,
"source" : "ServerSearch",
"sourceMetadata" : "Horror"
},
{
"displayMetadata" : "{\"DisplayTitle\":\"Art\",\"imageName\": \"roomCatSelect_art.jpg\"}",
"id" : "art_search_roomcategory",
"sectionSubType" : "art",
"sectionType" : 11,
"source" : "ServerSearch",
"sourceMetadata" : "Art"
}
]
+74
View File
@@ -0,0 +1,74 @@
[
{
"id": "Rooms_RoomBanner_SketchyShowdown",
"sectionType": 6,
"sectionSubType": "RoomBanner",
"source": "2477031627165896495",
"sourceMetadata": "2477031627165896495",
"displayMetadata": null
},
{
"id": "Rooms_ForYou_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_ForYou",
"source": "CarouselEndpoint",
"sourceMetadata": "foryou",
"displayMetadata": "{\"DisplayTitle\":\"Recommended For You\",\"supportsDedupe\":\"true\",\"numRows\":\"2\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"#F3DDCE\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Friendship_New_Users",
"sectionType": 0,
"sectionSubType": "Rooms_Friendship",
"source": "PlaylistById",
"sourceMetadata": "7732248245192312892",
"displayMetadata": "{\"DisplayTitle\":\"Fun with Friends\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"accountAgeMinDays\":0, \"accountAgeMaxDays\":3}"
},
{
"id": "Rooms_StaffPicks_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_Featured",
"source": "CarouselEndpoint",
"sourceMetadata": "staffpicks",
"displayMetadata": "{\"DisplayTitle\":\"Featured This Week\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"all=highlight_scale_150\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_RRO_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_RRO",
"source": "Hot",
"sourceMetadata": "rro",
"displayMetadata": "{\"DisplayTitle\":\"Rec Room Originals\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MostPopular",
"sectionType": 0,
"sectionSubType": "Rooms_MostPopular",
"source": "Hot",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Trending\",\"supportsDedupe\":\"true\",\"numRows\":\"1\"}"
},
{
"id": "Events_CreatorWorkshops_New_Users",
"sectionType": 5,
"sectionSubType": "Creator_Workshop_New_Users",
"source": "ClubEventsById",
"sourceMetadata": "69",
"displayMetadata": "{\"DisplayTitle\":\"Creative Workshops\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"excludeJuniors\":\"true\",\"unsupportedPlatforms\":[\"Switch\"],\"accountAgeMaxDays\":3}"
},
{
"id": "Discovery_FeaturedCreator",
"sectionType": 13,
"sectionSubType": "featured_creator",
"source": "PlayerCreatedRooms",
"sourceMetadata": "730029",
"displayMetadata": "{\"DisplayTitle\":\"Featured Creator\", \"descriptionText\":\"Making moments you can play again and again!\",\"itemCount\":\"4\", \"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "Rooms_WeLove",
"sectionType": 0,
"sectionSubType": "Rooms_WeLove",
"source": "CarouselEndpoint",
"sourceMetadata": "inkshowcase",
"displayMetadata": "{\"DisplayTitle\":\"Rooms We Love\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
}
]
+26
View File
@@ -0,0 +1,26 @@
[
{
"id": "Highlights",
"sectionType": 13,
"sectionSubType": "highlights",
"source": "Highlights",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Highlights\"}"
},
{
"id": "PlayTabs_Explore_SearchButton",
"sectionType": 13,
"sectionSubType": "playtabs_explore",
"source": "CuratedList",
"sourceMetadata": "Discovery.PageSource.PlayExplore",
"displayMetadata": "{\"DisplayTitle\":\"Explore\", \"SearchType\":1}"
},
{
"id": "PlayTabs_Library",
"sectionType": 13,
"sectionSubType": "playtabs_library",
"source": "CuratedList",
"sourceMetadata": "Discovery.PageSource.PlayLibrary",
"displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}"
}
]
+106
View File
@@ -0,0 +1,106 @@
[
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_New",
"sectionType": 4,
"sectionSubType": "Items_NewlyReleased",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "newitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"New\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "Items_Headwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "headwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "topsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "bottomsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "footwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "waistitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "handsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "shoulderitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Skins",
"sectionType": 4,
"sectionSubType": "Items_Skins",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "skinsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Skins\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 5,\r\n \"categoryUriNames\": \"SkinsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Accessories",
"sectionType": 4,
"sectionSubType": "Items_Accessories",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "accessoriesitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Accessories\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "hairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "facialhairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "CustomAvatarItemCarousel_AlgorithmicList_Trending",
"sectionType": 8,
"sectionSubType": "CustomAvatarItems_Trending",
"source": "AlgorithmicList",
"sourceMetadata": "Trending",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Trending Custom Shirts\",\r\n \"itemCount\": 5\r\n}"
}
]
@@ -0,0 +1,74 @@
[
{
"id": "StoreItemCarousel_Storefront_PopUpShop_18",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_18",
"source": "Storefront",
"sourceMetadata": "PopUpShop_18",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_14",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_14",
"source": "Storefront",
"sourceMetadata": "PopUpShop_14",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_FoodItems",
"sectionType": 4,
"sectionSubType": "Store_FoodItems",
"source": "Store",
"sourceMetadata": "FoodItems",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_12",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_12",
"source": "Storefront",
"sourceMetadata": "PopUpShop_12",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_16",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_16",
"source": "Storefront",
"sourceMetadata": "PopUpShop_16",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_17",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_17",
"source": "Storefront",
"sourceMetadata": "PopUpShop_17",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Store_Potions",
"sectionType": 4,
"sectionSubType": "Store_Potions",
"source": "UnifiedCuratedList",
"sourceMetadata": "StoreItemList_Potions",
"displayMetadata": "{\r\n \"itemCount\": 5,\r\n \"DisplayTitle\": \"Potions\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_RandomBoxesItems",
"sectionType": 4,
"sectionSubType": "Store_RandomBoxesItems",
"source": "Store",
"sourceMetadata": "RandomBoxesItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_Store_KOIconsItems",
"sectionType": 4,
"sectionSubType": "Store_KOIconsItems",
"source": "Store",
"sourceMetadata": "KOIconsItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
}
]

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